Repository: shiftonelabs/laravel-cascade-deletes
Branch: master
Commit: bf1eeb195513
Files: 30
Total size: 57.3 KB
Directory structure:
gitextract_ik7z161v/
├── .github/
│ └── workflows/
│ └── phpunit.yml
├── .gitignore
├── .scrutinizer.yml
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE.md
├── README.md
├── composer.json
├── phpcs.xml
├── phpunit.10.ignore.deprecations.xml
├── phpunit.9.xml
├── phpunit.xml
├── src/
│ ├── CascadesDeletes.php
│ └── CascadesDeletesModel.php
└── tests/
├── IntegrationTest.php
├── ModelTest.php
├── Models/
│ ├── Comment.php
│ ├── ExtendedUser.php
│ ├── InvalidKid.php
│ ├── PermanentPost.php
│ ├── Photo.php
│ ├── Post.php
│ ├── Profile.php
│ ├── SoftPost.php
│ ├── SoftProfile.php
│ ├── SoftUser.php
│ └── User.php
├── TestCase.php
├── TraitTest.php
└── helpers.php
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/workflows/phpunit.yml
================================================
name: Phpunit
on: [push, pull_request]
jobs:
phpcs:
runs-on: ubuntu-latest
name: phpcs - PHP 8.4
steps:
- name: Checkout repo
uses: actions/checkout@v3
# Setup the PHP version to use.
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
# Dependencies needed for the shiftonelabs/codesniffer-standard package.
- name: Install dependencies
run: composer update --prefer-dist --no-interaction
# Run the phpcs tool.
- name: Run phpcs
run: ./vendor/bin/phpcs
tests:
runs-on: ${{ matrix.os }}
strategy:
# Turn off fail-fast so that all jobs will run even when one fails,
# and the build will still get marked as failed.
fail-fast: false
matrix:
os: [ubuntu-latest]
php: ['8.0', '8.1', '8.2', '8.3', '8.4']
laravel: ['9.*', '10.*', '11.*']
exclude:
- php: '8.0'
laravel: '10.*'
- php: '8.0'
laravel: '11.*'
- php: '8.1'
laravel: '11.*'
name: tests - PHP ${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.os }}
steps:
- name: Checkout repo
uses: actions/checkout@v3
with:
# We need more than 1 commit to prevent the "Failed to retrieve
# commit parents" error from the ocular code coverage upload.
fetch-depth: 5
# Setup the PHP version to use for the test and include xdebug to generate the code coverage file.
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: xdebug
# Setup the required packages for the version being tested and install the packages
- name: Install dependencies
run: |
COMPOSER_MEMORY_LIMIT=-1 composer require "illuminate/database:${{ matrix.laravel }}" --no-update
composer update --prefer-dist --no-interaction
# Run the unit tests and generate the code coverage file.
- name: Run phpunit tests
run: |
PHPUNIT_CONFIG=""
( [[ -z "${PHPUNIT_CONFIG}" ]] && [[ "${{ matrix.php }}" == "8.0" ]] ) && PHPUNIT_CONFIG="--configuration phpunit.9.xml"
( [[ -z "${PHPUNIT_CONFIG}" ]] && [[ "${{ matrix.php }}" == "8.4" ]] && ( [[ "${{ matrix.laravel }}" == "9.*" ]] || [[ "${{ matrix.laravel }}" == "10.*" ]] ) ) && PHPUNIT_CONFIG="--configuration phpunit.10.ignore.deprecations.xml"
./vendor/bin/phpunit ${PHPUNIT_CONFIG} --coverage-clover ./clover.xml
# Send the code coverage file regardless of the tests passing or failing.
- name: Send coverage
if: success() || failure()
run: |
composer global require scrutinizer/ocular
~/.composer/vendor/bin/ocular code-coverage:upload --format=php-clover ./clover.xml
================================================
FILE: .gitignore
================================================
/vendor/
composer.lock
================================================
FILE: .scrutinizer.yml
================================================
build:
nodes:
analysis:
environment:
php: 8.3.11
tests:
override:
- php-scrutinizer-run
- phpcs-run
filter:
excluded_paths: [tests/*]
tools:
external_code_coverage:
timeout: 5400
runs: 12
================================================
FILE: CHANGELOG.md
================================================
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [2.0.2] - 2024-12-14
### Added
- Added new phpunit config for older PHP version.
- Added new phpunit config for incompatible PHP/Laravel versions.
### Changed
- Updated phpunit config to latest version.
- Updated phpunit config to ensure tests fail on warnings, notices, and deprecations.
- Updated Github Actions to use different phpunit configs.
- Updated CI configs to add support for PHP 8.4.
### Fixed
- Fixed deprecation notice in PHP 8.4. ([#12](https://github.com/shiftonelabs/laravel-cascade-deletes/pull/12))
## [2.0.1] - 2024-09-22
### Changed
- Updated CI configs to add support for Laravel 11 and PHP 8.3.
- Updated readme with new version information.
### Fixed
- Fixed `morphMany()` relationship typo in example code in readme. ([#9](https://github.com/shiftonelabs/laravel-cascade-deletes/pull/9))
## [2.0.0] - 2023-03-27
### Removed
- Removed support for Laravel 4.1 - Laravel 8.x. These are all EOL and will never change, so version 1.0.3 will always work for them.
- Removed support for PHP 5.5 - PHP 7.4. These are all EOL and will never change, so version 1.0.3 will always work for them.
### Changed
- Updated package dependencies to support new minimum Laravel and PHP versions.
- Updated CI configs to support new minimum Laravel and PHP versions.
- Updated the README to reflect the new version changes.
## [1.0.3] - 2023-03-24
### Changed
- Converted CI from Travis CI to Github Actions.
- Updated CI config to stop running tests in Scrutinizer.
## [1.0.2] - 2023-03-23
### Changed
- Updated readme to make copying the composer command easier. ([#8](https://github.com/shiftonelabs/laravel-cascade-deletes/pull/8))
- Updated readme with new version information.
- Updated tense in changelog.
## [1.0.1] - 2020-04-02
### Added
- New changelog.
### Changed
- Updated tests to work with all supported Laravel versions.
- Updated CI configs for increased test coverage across versions.
- Small code cleanup items across the code base.
- Updated readme with new version information.
- Sort the packages in composer.json.
### Fixed
- Fix count of soft deleted records to work with changes in Laravel >= 5.5.
## 1.0.0 - 2016-12-08
### Added
- Initial release!
[Unreleased]: https://github.com/shiftonelabs/laravel-cascade-deletes/compare/2.0.2...HEAD
[2.0.2]: https://github.com/shiftonelabs/laravel-cascade-deletes/compare/2.0.1...2.0.2
[2.0.1]: https://github.com/shiftonelabs/laravel-cascade-deletes/compare/2.0.0...2.0.1
[2.0.0]: https://github.com/shiftonelabs/laravel-cascade-deletes/compare/1.0.3...2.0.0
[1.0.3]: https://github.com/shiftonelabs/laravel-cascade-deletes/compare/1.0.2...1.0.3
[1.0.2]: https://github.com/shiftonelabs/laravel-cascade-deletes/compare/1.0.1...1.0.2
[1.0.1]: https://github.com/shiftonelabs/laravel-cascade-deletes/compare/1.0.0...1.0.1
================================================
FILE: CONTRIBUTING.md
================================================
# Contributing
Contributions are **welcome** and will be fully **credited**.
We accept contributions via Pull Requests on [Github](https://github.com/shiftonelabs/laravel-cascade-deletes).
## Pull Requests
- **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](http://pear.php.net/package/PHP_CodeSniffer).
- **Add tests!** - Your patch won't be accepted if it doesn't have tests (within reason).
- **Document any change in behavior** - Make sure the `README.md` and any other relevant documentation are kept up-to-date.
- **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option.
- **Create feature branches** - Don't ask us to pull from your master branch.
- **One pull request per feature** - If you want to do more than one thing, send multiple pull requests.
- **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting.
**Happy coding**!
================================================
FILE: LICENSE.md
================================================
The MIT License (MIT)
Copyright (c) Patrick Carlo-Hickman
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================
FILE: README.md
================================================
# laravel-cascade-deletes
[![Latest Version on Packagist][ico-version]][link-packagist]
[![Software License][ico-license]](LICENSE.txt)
[![Build Status][ico-github-actions]][link-github-actions]
[![Coverage Status][ico-scrutinizer]][link-scrutinizer]
[![Quality Score][ico-code-quality]][link-code-quality]
[![Total Downloads][ico-downloads]][link-downloads]
This Laravel/Lumen package provides application level cascading deletes for the Laravel's Eloquent ORM. When referential integrity is not, or cannot be, enforced at the data storage level, this package makes it easy to set this up at the application level.
For example, if you are using `SoftDeletes`, or are using polymorphic relationships, these are situations where foreign keys in the database cannot enforce referential integrity, and the application needs to step in. This package can help.
## Versions
This package has been tested on Laravel 4.1 through Laravel 11.x, though it may continue to work on later versions as they are released. This section will be updated to reflect the versions on which the package has actually been tested.
This readme has been updated to show information for the most currently supported versions (9.x - 11.x). For Laravel 4.1 through Laravel 8.x, view the 1.x branch.
## Install
Via Composer
``` bash
composer require shiftonelabs/laravel-cascade-deletes
```
## Usage
Enabling cascading deletes can be done two ways. Either:
- update your `Model` to extend the `\ShiftOneLabs\LaravelCascadeDeletes\CascadesDeletesModel`, or
- update your `Model` to use the `\ShiftOneLabs\LaravelCascadeDeletes\CascadesDeletes` trait.
Once that is done, define the `$cascadeDeletes` property on the `Model`. The `$cascadeDeletes` property should be set to an array of the relationships that should be deleted when a parent record is deleted.
Now, when a parent record is deleted, the defined child records will also be deleted. Furthermore, in the case where a child record also has cascading deletes defined, the delete will cascade down and delete the related records of the child, as well. This will continue on until all children, grandchildren, great grandchildren, etc. are deleted.
Additionally, all cascading deletes are performed within a transaction. This makes the delete an "all or nothing" event. If, for any reason, a child record could not be deleted, the transaction will rollback and no records will be deleted at all. The `Exception` that caused the child not to be deleted will bubble up to where the `delete()` originally started, and will need to be caught and handled.**
#### Code Example
User Model:
``` php
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
use ShiftOneLabs\LaravelCascadeDeletes\CascadesDeletes;
class User extends Model {
use CascadesDeletes;
protected $cascadeDeletes = ['posts', 'profile'];
public function posts()
{
return $this->hasMany(Post::class);
}
public function profile()
{
return $this->hasOne(Profile::class);
}
public function type()
{
return $this->belongsTo(Type::class);
}
}
```
Profile Model:
``` php
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
use ShiftOneLabs\LaravelCascadeDeletes\CascadesDeletes;
class Profile extends Model {
use CascadesDeletes;
protected $cascadeDeletes = ['addresses'];
public function user()
{
return $this->belongsTo(User::class);
}
public function addresses()
{
return $this->morphMany(Address::class, 'addressable');
}
}
```
In the example above, the `CascadesDeletes` trait has been added to the `User` model to enable cascading deletes. Since the user is considered a parent of posts and profiles, these relationships have been added to the `$cascadeDeletes` property. Additionally, the `Profile` model has been set up to delete its related address records.
Given this setup, when a user record is deleted, all related posts and profile records will be deleted. The delete will also cascade down into the profile record, and it will delete all the addresses related to the profile, as well.
If any one of the posts, profiles, or addresses fails to be deleted, the transaction will roll back and no records will be deleted, including the original user record.**
** Transaction rollback will only occur if the database being used actually supports transactions. Most do, but some do not. For example, the MySQL `InnoDB` engine supports transactions, but the MySQL `MyISAM` engine does not.
#### SoftDeletes
This package also works with Models that are setup with `SoftDeletes`.
When using `SoftDeletes`, the delete method being used will cascade to the rest of the deletes, as well. That is, if you `delete()` a record, all the child records will also use `delete()`; if you `forceDelete()` a record, all the child records will also use `forceDelete()`.
The deletes will also cross the boundary between soft deletes and hard deletes. In the code example above, the the `User` record was setup to soft delete, but the `Profile` record was not, then when a user is deleted, the `User` record would be soft deleted, but the child `Profile` record would be hard deleted, and vice versa.
## Notes
- The functionality in this package is provided through the `deleting` event on the `Model`. Therefore, in order for the cascading deletes to work, `delete()` must be called on a model instance. Deletes will not cascade if a delete is performed through the query builder. For example, `App\User::where('active', 0)->delete();` will only delete those user records, and will not perform any cascading deletes, since the `delete()` was performed on the query builder and not on a model instance.
- Do not add a `BelongsTo` relationship to the `$cascadeDeletes` array. This will cause a `LogicException`, and no records will be deleted. This is done as a `BelongsTo` typically represents a child record, and it usually does not make sense to delete a parent record from a child record.
## Contributing
Contributions are welcome. Please see [CONTRIBUTING](CONTRIBUTING.md) for details.
## Security
If you discover any security related issues, please email patrick@shiftonelabs.com instead of using the issue tracker.
## Credits
- [Patrick Carlo-Hickman][link-author]
- [All Contributors][link-contributors]
## License
The MIT License (MIT). Please see [License File](LICENSE.txt) for more information.
[ico-version]: https://img.shields.io/packagist/v/shiftonelabs/laravel-cascade-deletes.svg?style=flat-square
[ico-license]: https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square
[ico-github-actions]: https://img.shields.io/github/actions/workflow/status/shiftonelabs/laravel-cascade-deletes/.github/workflows/phpunit.yml?style=flat-square
[ico-scrutinizer]: https://img.shields.io/scrutinizer/coverage/g/shiftonelabs/laravel-cascade-deletes.svg?style=flat-square
[ico-code-quality]: https://img.shields.io/scrutinizer/g/shiftonelabs/laravel-cascade-deletes.svg?style=flat-square
[ico-downloads]: https://img.shields.io/packagist/dt/shiftonelabs/laravel-cascade-deletes.svg?style=flat-square
[link-packagist]: https://packagist.org/packages/shiftonelabs/laravel-cascade-deletes
[link-github-actions]: https://github.com/shiftonelabs/laravel-cascade-deletes/actions
[link-scrutinizer]: https://scrutinizer-ci.com/g/shiftonelabs/laravel-cascade-deletes/code-structure
[link-code-quality]: https://scrutinizer-ci.com/g/shiftonelabs/laravel-cascade-deletes
[link-downloads]: https://packagist.org/packages/shiftonelabs/laravel-cascade-deletes
[link-author]: https://github.com/patrickcarlohickman
[link-contributors]: ../../contributors
================================================
FILE: composer.json
================================================
{
"name": "shiftonelabs/laravel-cascade-deletes",
"description": "Adds application level cascading deletes to Eloquent Models.",
"keywords": ["laravel", "lumen", "eloquent", "model", "cascade", "deletes"],
"homepage": "https://github.com/shiftonelabs/laravel-cascade-deletes",
"license": "MIT",
"authors": [
{
"name": "Patrick Carlo-Hickman",
"email": "patrick@shiftonelabs.com"
}
],
"support": {
"issues": "https://github.com/shiftonelabs/laravel-cascade-deletes/issues",
"source": "https://github.com/shiftonelabs/laravel-cascade-deletes"
},
"require": {
"php": ">=8.0.2",
"illuminate/database": ">=9.0",
"illuminate/events": ">=9.0"
},
"require-dev": {
"mockery/mockery": "~1.3",
"phpunit/phpunit": "~9.3 || ~10.0",
"shiftonelabs/codesniffer-standard": "0.*",
"squizlabs/php_codesniffer": "3.*"
},
"autoload": {
"psr-4": {
"ShiftOneLabs\\LaravelCascadeDeletes\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"ShiftOneLabs\\LaravelCascadeDeletes\\Tests\\": "tests/"
},
"files": [
"tests/helpers.php"
]
},
"config": {
"sort-packages": true,
"allow-plugins": {
"kylekatarnls/update-helper": false
}
},
"minimum-stability": "stable"
}
================================================
FILE: phpcs.xml
================================================
<?xml version="1.0"?>
<ruleset name="MYPSR2">
<description>Base PSR-2 with a few modifications.</description>
<file>src</file>
<file>tests</file>
<rule ref="PSR2">
<exclude name="Generic.Files.LineLength" />
</rule>
<rule ref="PSR1.Methods.CamelCapsMethodName.NotCamelCaps">
<exclude-pattern>tests/*</exclude-pattern>
</rule>
<rule ref="PSR1.Classes.ClassDeclaration.MultipleClasses">
<exclude-pattern>tests/*</exclude-pattern>
</rule>
<rule ref="vendor/shiftonelabs/codesniffer-standard/ShiftOneLabs" />
</ruleset>
================================================
FILE: phpunit.10.ignore.deprecations.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
processIsolation="false"
stopOnFailure="false"
failOnNotice="true"
failOnWarning="true"
displayDetailsOnTestsThatTriggerDeprecations="true"
displayDetailsOnTestsThatTriggerErrors="true"
displayDetailsOnTestsThatTriggerNotices="true"
displayDetailsOnTestsThatTriggerWarnings="true"
cacheResult="false">
<source>
<include>
<directory suffix=".php">./src</directory>
</include>
<exclude>
<file>./src/CascadesDeletesModel.php</file>
</exclude>
</source>
<testsuites>
<testsuite name="Laravel Cascade Deletes Test Suite">
<directory suffix="Test.php">./tests/</directory>
</testsuite>
</testsuites>
</phpunit>
================================================
FILE: phpunit.9.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.6/phpunit.xsd"
bootstrap="vendor/autoload.php"
convertDeprecationsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
cacheResult="false">
<coverage>
<include>
<directory suffix=".php">./src</directory>
</include>
<exclude>
<file>./src/CascadesDeletesModel.php</file>
</exclude>
</coverage>
<testsuites>
<testsuite name="Laravel Cascade Deletes Test Suite">
<directory suffix="Test.php">./tests/</directory>
</testsuite>
</testsuites>
<php>
<ini name="error_reporting" value="-1" />
</php>
</phpunit>
================================================
FILE: phpunit.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
processIsolation="false"
stopOnFailure="false"
failOnDeprecation="true"
failOnNotice="true"
failOnWarning="true"
displayDetailsOnTestsThatTriggerDeprecations="true"
displayDetailsOnTestsThatTriggerErrors="true"
displayDetailsOnTestsThatTriggerNotices="true"
displayDetailsOnTestsThatTriggerWarnings="true"
cacheResult="false">
<source>
<include>
<directory suffix=".php">./src</directory>
</include>
<exclude>
<file>./src/CascadesDeletesModel.php</file>
</exclude>
</source>
<testsuites>
<testsuite name="Laravel Cascade Deletes Test Suite">
<directory suffix="Test.php">./tests/</directory>
</testsuite>
</testsuites>
</phpunit>
================================================
FILE: src/CascadesDeletes.php
================================================
<?php
namespace ShiftOneLabs\LaravelCascadeDeletes;
use LogicException;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Eloquent\Relations\HasOneOrMany;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
trait CascadesDeletes
{
/**
* Use the boot function to setup model event listeners.
*
* @return void
*/
public static function bootCascadesDeletes()
{
// Setup the 'deleting' event listener.
static::deleting(function ($model) {
// Wrap all of the cascading deletes inside of a transaction to make this an
// all or nothing operation. Any exceptions thrown inside the transaction
// need to bubble up to make sure all transactions will be rolled back.
$model->getConnectionResolver()->transaction(function () use ($model) {
$relations = $model->getCascadeDeletesRelations();
if ($invalidRelations = $model->getInvalidCascadeDeletesRelations($relations)) {
throw new LogicException(sprintf('[%s]: invalid relationship(s) for cascading deletes. Relationship method(s) [%s] must return an object of type Illuminate\Database\Eloquent\Relations\Relation.', static::class, implode(', ', $invalidRelations)));
}
$deleteMethod = $model->isCascadeDeletesForceDeleting() ? 'forceDelete' : 'delete';
foreach ($relations as $relationName => $relation) {
$expected = 0;
$deleted = 0;
if ($relation instanceof BelongsToMany) {
// Process the many-to-many relationships on the model.
// These relationships should not delete the related
// record, but should just detach from each other.
$expected = $model->getCascadeDeletesRelationQuery($relationName)->count();
$deleted = $model->getCascadeDeletesRelationQuery($relationName)->detach();
} elseif ($relation instanceof HasOneOrMany) {
// Process the one-to-one and one-to-many relationships
// on the model. These relationships should actually
// delete the related records from the database.
$children = $model->getCascadeDeletesRelationQuery($relationName)->get();
// To protect against potential relationship defaults,
// filter out any children that may not actually be
// Model instances, or that don't actually exist.
$children = $children->filter(function ($child) {
return $child instanceof Model && $child->exists;
})->all();
$expected = count($children);
foreach ($children as $child) {
// Delete the record using the proper method.
$deleted += $child->$deleteMethod();
}
} else {
// Not all relationship types make sense for cascading. As an
// example, for a BelongsTo relationship, it does not make
// sense to delete the parent when the child is deleted.
throw new LogicException(sprintf('[%s]: error occurred deleting [%s]. Relation type [%s] not handled.', static::class, $relationName, get_class($relation)));
}
if ($deleted < $expected) {
throw new LogicException(sprintf('[%s]: error occurred deleting [%s]. Only deleted [%d] out of [%d] records.', static::class, $relationName, $deleted, $expected));
}
}
});
});
}
/**
* Get the value of the cascadeDeletes attribute, if it exists.
*
* @return mixed
*/
public function getCascadeDeletes()
{
return property_exists($this, 'cascadeDeletes') ? $this->cascadeDeletes : [];
}
/**
* Set the cascadeDeletes attribute.
*
* @param mixed $cascadeDeletes
*
* @return void
*/
public function setCascadeDeletes($cascadeDeletes)
{
$this->cascadeDeletes = $cascadeDeletes;
}
/**
* Get an array of cascading relation names.
*
* @return array
*/
public function getCascadeDeletesRelationNames()
{
$deletes = $this->getCascadeDeletes();
return array_filter(is_array($deletes) ? $deletes : [$deletes]);
}
/**
* Get an array of the cascading relation names mapped to their relation types.
*
* @return array
*/
public function getCascadeDeletesRelations()
{
$names = $this->getCascadeDeletesRelationNames();
return array_combine($names, array_map(function ($name) {
$relation = method_exists($this, $name) ? $this->$name() : null;
return $relation instanceof Relation ? $relation : null;
}, $names));
}
/**
* Get an array of the invalid cascading relation names.
*
* @param array|null $relations
*
* @return array
*/
public function getInvalidCascadeDeletesRelations(?array $relations = null)
{
// This will get the array keys for any item in the array where the
// value is null. If the value is null, that means that the name
// of the relation provided does not return a Relation object.
return array_keys($relations ?: $this->getCascadeDeletesRelations(), null);
}
/**
* Get the relationship query to use for the specified relation.
*
* @param string $relation
*
* @return \Illuminate\Database\Eloquent\Relations\Relation
*/
public function getCascadeDeletesRelationQuery($relation)
{
$query = $this->$relation();
// If this is a force delete and the related model is using soft deletes,
// we need to use the withTrashed() scope on the relationship query to
// ensure all related records, plus soft deleted, are force deleted.
if ($this->isCascadeDeletesForceDeleting() && !is_null($query->getMacro('withTrashed'))) {
$query = $query->withTrashed();
}
return $query;
}
/**
* Check if this cascading delete is a force delete.
*
* @return boolean
*/
public function isCascadeDeletesForceDeleting()
{
return property_exists($this, 'forceDeleting') && $this->forceDeleting;
}
}
================================================
FILE: src/CascadesDeletesModel.php
================================================
<?php
namespace ShiftOneLabs\LaravelCascadeDeletes;
use Illuminate\Database\Eloquent\Model;
class CascadesDeletesModel extends Model
{
use CascadesDeletes;
}
================================================
FILE: tests/IntegrationTest.php
================================================
<?php
namespace ShiftOneLabs\LaravelCascadeDeletes\Tests;
use LogicException;
use ShiftOneLabs\LaravelCascadeDeletes\Tests\Models\Post;
use ShiftOneLabs\LaravelCascadeDeletes\Tests\Models\User;
use ShiftOneLabs\LaravelCascadeDeletes\Tests\Models\Photo;
use ShiftOneLabs\LaravelCascadeDeletes\Tests\Models\Comment;
use ShiftOneLabs\LaravelCascadeDeletes\Tests\Models\Profile;
use ShiftOneLabs\LaravelCascadeDeletes\Tests\Models\SoftPost;
use ShiftOneLabs\LaravelCascadeDeletes\Tests\Models\SoftUser;
use ShiftOneLabs\LaravelCascadeDeletes\Tests\Models\InvalidKid;
use ShiftOneLabs\LaravelCascadeDeletes\Tests\Models\SoftProfile;
class IntegrationTest extends TestCase
{
/**
* Setup run before each test.
*
* @before
*/
public function beforeSetup()
{
$this->setUpDatabaseConnection();
$this->createSchema();
}
protected function createSchema()
{
$this->schema()->create('users', function ($table) {
$table->increments('id');
$table->timestamps();
$table->softDeletes();
$table->string('name')->nullable();
$table->string('email');
});
$this->schema()->create('friends', function ($table) {
$table->integer('user_id');
$table->integer('friend_id');
});
$this->schema()->create('posts', function ($table) {
$table->increments('id');
$table->timestamps();
$table->softDeletes();
$table->integer('user_id');
$table->integer('parent_id')->nullable();
$table->string('name');
});
$this->schema()->create('comments', function ($table) {
$table->increments('id');
$table->timestamps();
$table->softDeletes();
$table->integer('post_id');
$table->integer('user_id');
$table->string('comment');
});
$this->schema()->create('photos', function ($table) {
$table->increments('id');
$table->timestamps();
$table->softDeletes();
$table->morphs('imageable');
$table->string('name');
});
$this->schema()->create('invalid_kids', function ($table) {
$table->increments('id');
$table->timestamps();
$table->softDeletes();
$table->morphs('invalidable');
$table->string('name');
});
$this->schema()->create('profiles', function ($table) {
$table->increments('id');
$table->timestamps();
$table->softDeletes();
$table->integer('user_id');
});
}
/**
* Tear down run after each test.
*
* @after
*/
public function afterTearDown()
{
$this->schema()->dropIfExists('users');
$this->schema()->dropIfExists('friends');
$this->schema()->dropIfExists('posts');
$this->schema()->dropIfExists('comments');
$this->schema()->dropIfExists('photos');
$this->schema()->dropIfExists('invalid_kids');
$this->schema()->dropIfExists('profiles');
}
public function testInvalidRelationshipThrowsException()
{
$this->expectException(LogicException::class);
$this->expectExceptionMessage('invalid relationship(s) for cascading deletes');
$user = User::create(['email' => 'user@example.com']);
$user->setCascadeDeletes(['non_existing_relation']);
$user->delete();
}
public function testInvalidRelationshipTypeThrowsException()
{
$this->expectException(LogicException::class);
$this->expectExceptionMessageMatches('/Relation type .* not handled/');
$post = Post::create(['user_id' => 0, 'name' => 'First Post']);
$post->setCascadeDeletes(['user']);
$post->delete();
}
public function testNotAllRecordsDeletedThrowsException()
{
$this->expectException(LogicException::class);
$this->expectExceptionMessage('Only deleted [0] out of [1] records');
$user = User::create(['email' => 'user@example.com']);
$post = $user->permanentPosts()->create(['name' => 'First Post']);
$user->setCascadeDeletes(['permanentPosts']);
$user->delete();
}
public function testDeletesCascadeFirstLevel()
{
$user = User::create(['email' => 'user@example.com']);
$user->photos()->create(['name' => 'Avatar 1']);
$user->photos()->create(['name' => 'Avatar 2']);
$friend = $user->friends()->create(['email' => 'friend@example.com']);
$post = $user->posts()->create(['name' => 'First Post']);
$user->comments()->create(['post_id' => $post->id, 'comment' => 'First Comment']);
$user->comments()->create(['post_id' => $post->id, 'comment' => 'Second Comment']);
$this->assertEquals(7, User::count() + Photo::count() + Post::count() + Comment::count());
$user->delete();
$this->assertEquals(1, User::count());
$this->assertEquals(1, User::count() + Photo::count() + Post::count() + Comment::count());
}
public function testDeletesCascadeSecondLevel()
{
$user = User::create(['email' => 'user@example.com']);
$post = $user->posts()->create(['name' => 'First Post']);
$post->photos()->create(['name' => 'Hero 1']);
$post->photos()->create(['name' => 'Hero 2']);
$childPost = $post->childPosts()->create(['user_id' => $user->id, 'name' => 'First Child Post']);
$post->comments()->create(['user_id' => 0, 'comment' => 'First Comment']);
$post->comments()->create(['user_id' => 0, 'comment' => 'Second Comment']);
$this->assertEquals(7, User::count() + Photo::count() + Post::count() + Comment::count());
$user->delete();
$this->assertEquals(0, User::count() + Photo::count() + Post::count() + Comment::count());
}
public function testDeletesCascadeLowerLevels()
{
$user = User::create(['email' => 'user@example.com']);
$post = $user->posts()->create(['name' => 'First Post']);
$childPost = $post->childPosts()->create(['user_id' => 0, 'name' => 'First Child Post']);
$grandchildPost = $childPost->childPosts()->create(['user_id' => 0, 'name' => 'First Grandchild Post']);
$greatGrandchildPost = $grandchildPost->childPosts()->create(['user_id' => 0, 'name' => 'First Great Grandchild Post']);
$this->assertEquals(5, User::count() + Post::count());
$user->delete();
$this->assertEquals(0, User::count() + Post::count());
}
public function testEntireTransactionIsRolledBack()
{
$user = User::create(['email' => 'user@example.com']);
$post = $user->posts()->create(['name' => 'First Post']);
$post->photos()->create(['name' => 'Hero 1']);
$post->photos()->create(['name' => 'Hero 2']);
$invalidKid = $post->invalidKids()->create(['name' => 'First Invalid Kid']);
$post->comments()->create(['user_id' => 0, 'comment' => 'First Comment']);
$post->comments()->create(['user_id' => 0, 'comment' => 'Second Comment']);
$this->assertEquals(7, User::count() + Photo::count() + Post::count() + Comment::count() + InvalidKid::count());
try {
$exceptionThrown = false;
$user->delete();
} catch (LogicException $e) {
$exceptionThrown = true;
}
$this->assertEquals(7, User::count() + Photo::count() + Post::count() + Comment::count() + InvalidKid::count());
$this->assertTrue($exceptionThrown);
}
public function testDeletesHiddenRelatedRecords()
{
$user = User::create(['email' => 'user@example.com']);
$user->profile()->create([]);
$user->profile()->create([]);
$this->assertEquals(3, User::count() + Profile::count());
$user->delete();
$this->assertEquals(0, User::count() + Profile::count());
}
public function testDeletesOnlyRelatedRecords()
{
$user = User::create(['email' => 'user@example.com']);
$user->profile()->create([]);
$user->profile()->create([]);
$user2 = User::create(['email' => 'user2@example.com']);
$user2->profile()->create([]);
$this->assertEquals(5, User::count() + Profile::count());
$user->delete();
$this->assertEquals(2, User::count() + Profile::count());
}
public function testSoftDeletesCascade()
{
$user = SoftUser::create(['email' => 'user@example.com']);
$user->photos()->create(['name' => 'Avatar 1']);
$user->photos()->create(['name' => 'Avatar 2']);
$friend = $user->friends()->create(['email' => 'friend@example.com']);
$post = $user->posts()->create(['name' => 'First Post']);
$user->comments()->create(['post_id' => $post->id, 'comment' => 'First Comment']);
$user->comments()->create(['post_id' => $post->id, 'comment' => 'Second Comment']);
$this->assertEquals(7, SoftUser::count() + SoftPost::count() + Photo::count() + Comment::count());
$user->delete();
$this->assertEquals(1, SoftUser::count());
$this->assertEquals(0, SoftPost::count());
$this->assertEquals(1, SoftUser::count() + SoftPost::count() + Photo::count() + Comment::count());
$this->assertEquals(2, SoftUser::withTrashed()->count());
$this->assertEquals(1, SoftPost::withTrashed()->count());
$this->assertEquals(3, SoftUser::withTrashed()->count() + SoftPost::withTrashed()->count() + Photo::count() + Comment::count());
}
public function testForcedSoftDeletesCascade()
{
$user = SoftUser::create(['email' => 'user@example.com']);
$user->photos()->create(['name' => 'Avatar 1']);
$user->photos()->create(['name' => 'Avatar 2']);
$friend = $user->friends()->create(['email' => 'friend@example.com']);
$post = $user->posts()->create(['name' => 'First Post']);
$user->comments()->create(['post_id' => $post->id, 'comment' => 'First Comment']);
$user->comments()->create(['post_id' => $post->id, 'comment' => 'Second Comment']);
$this->assertEquals(7, SoftUser::count() + SoftPost::count() + Photo::count() + Comment::count());
$user->forceDelete();
$this->assertEquals(1, SoftUser::count());
$this->assertEquals(0, SoftPost::count());
$this->assertEquals(1, SoftUser::count() + SoftPost::count() + Photo::count() + Comment::count());
$this->assertEquals(1, SoftUser::withTrashed()->count());
$this->assertEquals(0, SoftPost::withTrashed()->count());
$this->assertEquals(1, SoftUser::withTrashed()->count() + SoftPost::withTrashed()->count() + Photo::count() + Comment::count());
}
public function testSoftDeletesHiddenRelatedRecords()
{
$user = SoftUser::create(['email' => 'user@example.com']);
$user->profile()->create([]);
$user->profile()->create([]);
$this->assertEquals(3, SoftUser::count() + SoftProfile::count());
$user->delete();
$this->assertEquals(0, SoftUser::count() + SoftProfile::count());
$this->assertEquals(3, SoftUser::withTrashed()->count() + SoftProfile::withTrashed()->count());
}
public function testForcedSoftDeletesHiddenRelatedRecords()
{
$user = SoftUser::create(['email' => 'user@example.com']);
$user->profile()->create([]);
$user->profile()->create([]);
$this->assertEquals(3, SoftUser::count() + SoftProfile::count());
$user->forceDelete();
$this->assertEquals(0, SoftUser::count() + SoftProfile::count());
$this->assertEquals(0, SoftUser::withTrashed()->count() + SoftProfile::withTrashed()->count());
}
public function testForcedSoftDeletesMixedRelatedRecords()
{
$user = SoftUser::create(['email' => 'user@example.com']);
$user->profile()->create([]);
$user->profile()->first()->delete();
$this->assertEquals(2, SoftUser::withTrashed()->count() + SoftProfile::withTrashed()->count());
$user->profile()->create([]);
$this->assertEquals(2, SoftUser::count() + SoftProfile::count());
$this->assertEquals(3, SoftUser::withTrashed()->count() + SoftProfile::withTrashed()->count());
$user->forceDelete();
$this->assertEquals(0, SoftUser::count() + SoftProfile::count());
$this->assertEquals(0, SoftUser::withTrashed()->count() + SoftProfile::withTrashed()->count());
}
public function testSoftDeletesTransactionIsRolledBack()
{
$user = SoftUser::create(['email' => 'user@example.com']);
$post = $user->posts()->create(['name' => 'First Post']);
$post->photos()->create(['name' => 'Hero 1']);
$post->photos()->create(['name' => 'Hero 2']);
$invalidKid = $post->invalidKids()->create(['name' => 'First Invalid Kid']);
$post->comments()->create(['user_id' => 0, 'comment' => 'First Comment']);
$post->comments()->create(['user_id' => 0, 'comment' => 'Second Comment']);
$this->assertEquals(7, User::count() + Photo::count() + Post::count() + Comment::count() + InvalidKid::count());
try {
$exceptionThrown = false;
$user->delete();
} catch (LogicException $e) {
$exceptionThrown = true;
}
$this->assertEquals(7, User::count() + Photo::count() + Post::count() + Comment::count() + InvalidKid::count());
$this->assertTrue($exceptionThrown);
}
public function testForcedSoftDeletesTransactionIsRolledBack()
{
$user = SoftUser::create(['email' => 'user@example.com']);
$post = $user->posts()->create(['name' => 'First Post']);
$post->photos()->create(['name' => 'Hero 1']);
$post->photos()->create(['name' => 'Hero 2']);
$invalidKid = $post->invalidKids()->create(['name' => 'First Invalid Kid']);
$post->comments()->create(['user_id' => 0, 'comment' => 'First Comment']);
$post->comments()->create(['user_id' => 0, 'comment' => 'Second Comment']);
$this->assertEquals(7, User::count() + Photo::count() + Post::count() + Comment::count() + InvalidKid::count());
try {
$exceptionThrown = false;
$user->forceDelete();
} catch (LogicException $e) {
$exceptionThrown = true;
}
$this->assertEquals(7, User::count() + Photo::count() + Post::count() + Comment::count() + InvalidKid::count());
$this->assertTrue($exceptionThrown);
}
}
================================================
FILE: tests/ModelTest.php
================================================
<?php
namespace ShiftOneLabs\LaravelCascadeDeletes\Tests;
use ShiftOneLabs\LaravelCascadeDeletes\CascadesDeletes;
use ShiftOneLabs\LaravelCascadeDeletes\Tests\Models\ExtendedUser;
class ModelTest extends TestCase
{
public function testModelUsesCascadingDeletesTrait()
{
$this->assertContains(CascadesDeletes::class, class_uses_recursive(ExtendedUser::class));
}
}
================================================
FILE: tests/Models/Comment.php
================================================
<?php
namespace ShiftOneLabs\LaravelCascadeDeletes\Tests\Models;
use Illuminate\Database\Eloquent\Model;
class Comment extends Model
{
protected $guarded = [];
public function user()
{
return $this->belongsTo(User::class);
}
public function post()
{
return $this->belongsTo(Post::class);
}
}
================================================
FILE: tests/Models/ExtendedUser.php
================================================
<?php
namespace ShiftOneLabs\LaravelCascadeDeletes\Tests\Models;
use ShiftOneLabs\LaravelCascadeDeletes\CascadesDeletesModel;
class ExtendedUser extends CascadesDeletesModel
{
protected $guarded = [];
protected $cascadeDeletes = ['friends', 'posts', 'photos', 'comments', 'profile'];
public function friends()
{
return $this->belongsToMany(User::class, 'friends', 'user_id', 'friend_id');
}
public function posts()
{
return $this->hasMany(Post::class);
}
public function photos()
{
return $this->morphMany(Photo::class, 'imageable');
}
public function comments()
{
return $this->hasMany(Comment::class);
}
public function profile()
{
return $this->hasOne(Profile::class);
}
public function permanentPosts()
{
return $this->hasMany(PermanentPost::class);
}
}
================================================
FILE: tests/Models/InvalidKid.php
================================================
<?php
namespace ShiftOneLabs\LaravelCascadeDeletes\Tests\Models;
use Illuminate\Database\Eloquent\Model;
use ShiftOneLabs\LaravelCascadeDeletes\CascadesDeletes;
class InvalidKid extends Model
{
use CascadesDeletes;
protected $guarded = [];
protected $cascadeDeletes = ['invalidable'];
public function invalidable()
{
return $this->morphTo();
}
}
================================================
FILE: tests/Models/PermanentPost.php
================================================
<?php
namespace ShiftOneLabs\LaravelCascadeDeletes\Tests\Models;
class PermanentPost extends Post
{
protected $table = 'posts';
public function user()
{
return $this->belongsTo(User::class);
}
public function childPosts()
{
return $this->hasMany(PermanentPost::class, 'parent_id');
}
public function parentPost()
{
return $this->belongsTo(PermanentPost::class, 'parent_id');
}
public function comments()
{
return $this->hasMany(Comment::class, 'post_id');
}
public function delete()
{
return false;
}
}
================================================
FILE: tests/Models/Photo.php
================================================
<?php
namespace ShiftOneLabs\LaravelCascadeDeletes\Tests\Models;
use Illuminate\Database\Eloquent\Model;
class Photo extends Model
{
protected $guarded = [];
public function imageable()
{
return $this->morphTo();
}
}
================================================
FILE: tests/Models/Post.php
================================================
<?php
namespace ShiftOneLabs\LaravelCascadeDeletes\Tests\Models;
use Illuminate\Database\Eloquent\Model;
use ShiftOneLabs\LaravelCascadeDeletes\CascadesDeletes;
class Post extends Model
{
use CascadesDeletes;
protected $guarded = [];
protected $cascadeDeletes = ['photos', 'childPosts', 'comments', 'invalidKids'];
public function user()
{
return $this->belongsTo(User::class);
}
public function photos()
{
return $this->morphMany(Photo::class, 'imageable');
}
public function childPosts()
{
return $this->hasMany(Post::class, 'parent_id');
}
public function parentPost()
{
return $this->belongsTo(Post::class, 'parent_id');
}
public function comments()
{
return $this->hasMany(Comment::class);
}
public function invalidKids()
{
return $this->morphMany(InvalidKid::class, 'invalidable');
}
}
================================================
FILE: tests/Models/Profile.php
================================================
<?php
namespace ShiftOneLabs\LaravelCascadeDeletes\Tests\Models;
use Illuminate\Database\Eloquent\Model;
class Profile extends Model
{
protected $guarded = [];
public function user()
{
return $this->belongsTo(User::class);
}
}
================================================
FILE: tests/Models/SoftPost.php
================================================
<?php
namespace ShiftOneLabs\LaravelCascadeDeletes\Tests\Models;
use Illuminate\Database\Eloquent\SoftDeletes;
class SoftPost extends Post
{
use SoftDeletes;
protected $table = 'posts';
public function user()
{
return $this->belongsTo(SoftUser::class);
}
public function childPosts()
{
return $this->hasMany(SoftPost::class, 'parent_id');
}
public function parentPost()
{
return $this->belongsTo(SoftPost::class, 'parent_id');
}
public function comments()
{
return $this->hasMany(Comment::class, 'post_id');
}
}
================================================
FILE: tests/Models/SoftProfile.php
================================================
<?php
namespace ShiftOneLabs\LaravelCascadeDeletes\Tests\Models;
use Illuminate\Database\Eloquent\SoftDeletes;
class SoftProfile extends Profile
{
use SoftDeletes;
protected $table = 'profiles';
public function user()
{
return $this->belongsTo(User::class);
}
}
================================================
FILE: tests/Models/SoftUser.php
================================================
<?php
namespace ShiftOneLabs\LaravelCascadeDeletes\Tests\Models;
use Illuminate\Database\Eloquent\SoftDeletes;
class SoftUser extends User
{
use SoftDeletes;
protected $table = 'users';
public function friends()
{
return $this->belongsToMany(SoftUser::class, 'friends', 'user_id', 'friend_id');
}
public function posts()
{
return $this->hasMany(SoftPost::class, 'user_id');
}
public function comments()
{
return $this->hasMany(Comment::class, 'user_id');
}
public function profile()
{
return $this->hasOne(SoftProfile::class, 'user_id');
}
}
================================================
FILE: tests/Models/User.php
================================================
<?php
namespace ShiftOneLabs\LaravelCascadeDeletes\Tests\Models;
use Illuminate\Database\Eloquent\Model;
use ShiftOneLabs\LaravelCascadeDeletes\CascadesDeletes;
class User extends Model
{
use CascadesDeletes;
protected $guarded = [];
protected $cascadeDeletes = ['friends', 'posts', 'photos', 'comments', 'profile'];
public function friends()
{
return $this->belongsToMany(User::class, 'friends', 'user_id', 'friend_id');
}
public function posts()
{
return $this->hasMany(Post::class);
}
public function photos()
{
return $this->morphMany(Photo::class, 'imageable');
}
public function comments()
{
return $this->hasMany(Comment::class);
}
public function profile()
{
return $this->hasOne(Profile::class);
}
public function permanentPosts()
{
return $this->hasMany(PermanentPost::class);
}
}
================================================
FILE: tests/TestCase.php
================================================
<?php
namespace ShiftOneLabs\LaravelCascadeDeletes\Tests;
use ReflectionProperty;
use Illuminate\Events\Dispatcher;
use Illuminate\Container\Container;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Capsule\Manager as DB;
use PHPUnit\Framework\TestCase as PhpunitTestCase;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
class TestCase extends PhpunitTestCase
{
/**
* Use the integration trait so PHPUnit understands Mockery assertions.
*/
use MockeryPHPUnitIntegration;
/**
* Setup the database connection.
*
* @return void
*/
public function setUpDatabaseConnection()
{
$db = new DB();
$db->addConnection([
'driver' => 'sqlite',
'database' => ':memory:',
]);
$db->setEventDispatcher(new Dispatcher(new Container()));
$db->bootEloquent();
$db->setAsGlobal();
// This is required for testing Model events. If this is not done, the
// events will only fire on the first test.
Model::clearBootedModels();
}
/**
* Get a schema builder instance.
*
* @return \Illuminate\Database\Schema\Builder
*/
protected function schema($connection = 'default')
{
return $this->connection($connection)->getSchemaBuilder();
}
/**
* Get a database connection instance.
*
* @return \Illuminate\Database\Connection
*/
protected function connection($connection = 'default')
{
return Model::getConnectionResolver()->connection($connection);
}
/**
* Use reflection to set the value of a restricted (private/protected)
* property on an object.
*
* @param object $object
* @param string $property
* @param mixed $value
*
* @return void
*/
protected function setRestrictedValue($object, $property, $value)
{
$reflectionProperty = new ReflectionProperty($object, $property);
$reflectionProperty->setAccessible(true);
if ($reflectionProperty->isStatic()) {
$reflectionProperty->setValue($value);
} else {
$reflectionProperty->setValue($object, $value);
}
}
}
================================================
FILE: tests/TraitTest.php
================================================
<?php
namespace ShiftOneLabs\LaravelCascadeDeletes\Tests;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Database\Eloquent\Relations\Relation;
use ShiftOneLabs\LaravelCascadeDeletes\Tests\Models\User;
use ShiftOneLabs\LaravelCascadeDeletes\Tests\Models\SoftUser;
class TraitTest extends TestCase
{
public function testCanGetCascadeDeletesProperty()
{
$user = new User();
$this->assertNotEmpty($user->getCascadeDeletes());
}
public function testCanSetCascadeDeletesProperty()
{
$user = new User();
$newDeletes = ['new', 'deletes'];
$user->setCascadeDeletes($newDeletes);
$this->assertEquals($newDeletes, $user->getCascadeDeletes());
}
public function testGetRelationNamesReturnsArrayFromArray()
{
$user = new User();
$names = $user->getCascadeDeletesRelationNames();
$this->assertIsArray($names);
}
public function testGetRelationNamesReturnsPopulatedArrayFromArray()
{
$user = new User();
$names = $user->getCascadeDeletesRelationNames();
$this->assertNotEmpty($names);
}
public function testGetRelationNamesReturnsArrayFromString()
{
$user = new User();
$user->setCascadeDeletes('string_value');
$names = $user->getCascadeDeletesRelationNames();
$this->assertIsArray($names);
}
public function testGetRelationNamesReturnsPopulatedArrayFromString()
{
$user = new User();
$user->setCascadeDeletes('string_value');
$names = $user->getCascadeDeletesRelationNames();
$this->assertNotEmpty($names);
}
public function testGetRelationNamesReturnsArrayFromNonEmptyValue()
{
$user = new User();
$user->setCascadeDeletes(1234);
$names = $user->getCascadeDeletesRelationNames();
$this->assertIsArray($names);
}
public function testGetRelationNamesReturnsPopulatedArrayFromNonEmptyValue()
{
$user = new User();
$user->setCascadeDeletes(1234);
$names = $user->getCascadeDeletesRelationNames();
$this->assertNotEmpty($names);
}
public function testGetRelationNamesReturnsArrayFromEmptyValue()
{
$user = new User();
$user->setCascadeDeletes(null);
$names = $user->getCascadeDeletesRelationNames();
$this->assertIsArray($names);
}
public function testGetRelationNamesReturnsEmptyArrayFromEmptyValue()
{
$user = new User();
$user->setCascadeDeletes(null);
$names = $user->getCascadeDeletesRelationNames();
$this->assertEmpty($names);
}
public function testGetRelationsReturnsRelationObjectsForValidNames()
{
$user = new User();
$user->setCascadeDeletes(['friends', 'posts', 'photos']);
$expected = [
'friends' => $user->friends(),
'posts' => $user->posts(),
'photos' => $user->photos(),
];
$relations = $user->getCascadeDeletesRelations();
$this->assertEquals($expected, $relations);
}
public function testGetRelationsReturnsNullForInvalidNames()
{
$user = new User();
$user->setCascadeDeletes(['friends', 'asdf', 1234]);
$expected = [
'friends' => $user->friends(),
'asdf' => null,
1234 => null,
];
$relations = $user->getCascadeDeletesRelations();
$this->assertEquals($expected, $relations);
}
public function testGetRelationsExcludesEmptyNames()
{
$user = new User();
$user->setCascadeDeletes(['friends', '', 0, null, 'posts']);
$expected = [
'friends' => $user->friends(),
'posts' => $user->posts(),
];
$relations = $user->getCascadeDeletesRelations();
$this->assertEquals($expected, $relations);
}
public function testGetInvalidRelationsReturnsInvalidNames()
{
$user = new User();
$user->setCascadeDeletes(['friends', 'asdf', 1234]);
$expected = ['asdf', 1234];
$names = $user->getInvalidCascadeDeletesRelations();
$this->assertEquals($expected, $names);
}
public function testGetInvalidRelationsExcludesEmptyNames()
{
$user = new User();
$user->setCascadeDeletes(['asdf', '', 0, null, 1234]);
$expected = ['asdf', 1234];
$names = $user->getInvalidCascadeDeletesRelations();
$this->assertEquals($expected, $names);
}
public function testIsForceDeletingReturnsTrueWhenForceDeleting()
{
$user = new SoftUser();
$this->setRestrictedValue($user, 'forceDeleting', true);
$this->assertTrue($user->isCascadeDeletesForceDeleting());
}
public function testIsForceDeletingReturnsFalseWhenNotForceDeleting()
{
$user = new SoftUser();
$this->assertFalse($user->isCascadeDeletesForceDeleting());
}
public function testGetCascadeDeletesRelationQueryReturnsRelation()
{
$user = new User();
$query = $user->getCascadeDeletesRelationQuery($user->getCascadeDeletesRelationNames()[0]);
$this->assertInstanceOf(Relation::class, $query);
}
public function testCascadeDeletesRelationQueryExcludesTrashedWhenNotForceDeleting()
{
$user = new SoftUser();
$query = $user->getCascadeDeletesRelationQuery($user->getCascadeDeletesRelationNames()[0])->getQuery();
$this->assertNotContains(SoftDeletingScope::class, $query->removedScopes());
}
public function testCascadeDeletesRelationQueryIncludesTrashedWhenForceDeleting()
{
$user = new SoftUser();
$this->setRestrictedValue($user, 'forceDeleting', true);
$query = $user->getCascadeDeletesRelationQuery($user->getCascadeDeletesRelationNames()[0])->getQuery();
$this->assertContains(SoftDeletingScope::class, $query->removedScopes());
}
}
================================================
FILE: tests/helpers.php
================================================
<?php
if (!function_exists('class_uses_recursive')) {
/**
* Returns all traits used by a class, its subclasses and trait of their traits.
*
* @param object|string $class
*
* @return array
*/
function class_uses_recursive($class)
{
if (is_object($class)) {
$class = get_class($class);
}
$results = [];
foreach (array_merge([$class => $class], class_parents($class)) as $class) {
$results += trait_uses_recursive($class);
}
return array_unique($results);
}
}
if (!function_exists('trait_uses_recursive')) {
/**
* Returns all traits used by a trait and its traits.
*
* @param string $trait
*
* @return array
*/
function trait_uses_recursive($trait)
{
$traits = class_uses($trait);
foreach ($traits as $trait) {
$traits += trait_uses_recursive($trait);
}
return $traits;
}
}
gitextract_ik7z161v/
├── .github/
│ └── workflows/
│ └── phpunit.yml
├── .gitignore
├── .scrutinizer.yml
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE.md
├── README.md
├── composer.json
├── phpcs.xml
├── phpunit.10.ignore.deprecations.xml
├── phpunit.9.xml
├── phpunit.xml
├── src/
│ ├── CascadesDeletes.php
│ └── CascadesDeletesModel.php
└── tests/
├── IntegrationTest.php
├── ModelTest.php
├── Models/
│ ├── Comment.php
│ ├── ExtendedUser.php
│ ├── InvalidKid.php
│ ├── PermanentPost.php
│ ├── Photo.php
│ ├── Post.php
│ ├── Profile.php
│ ├── SoftPost.php
│ ├── SoftProfile.php
│ ├── SoftUser.php
│ └── User.php
├── TestCase.php
├── TraitTest.php
└── helpers.php
SYMBOL INDEX (108 symbols across 18 files)
FILE: src/CascadesDeletes.php
type CascadesDeletes (line 11) | trait CascadesDeletes
method bootCascadesDeletes (line 18) | public static function bootCascadesDeletes()
method getCascadeDeletes (line 88) | public function getCascadeDeletes()
method setCascadeDeletes (line 100) | public function setCascadeDeletes($cascadeDeletes)
method getCascadeDeletesRelationNames (line 110) | public function getCascadeDeletesRelationNames()
method getCascadeDeletesRelations (line 122) | public function getCascadeDeletesRelations()
method getInvalidCascadeDeletesRelations (line 140) | public function getInvalidCascadeDeletesRelations(?array $relations = ...
method getCascadeDeletesRelationQuery (line 155) | public function getCascadeDeletesRelationQuery($relation)
method isCascadeDeletesForceDeleting (line 174) | public function isCascadeDeletesForceDeleting()
FILE: src/CascadesDeletesModel.php
class CascadesDeletesModel (line 7) | class CascadesDeletesModel extends Model
FILE: tests/IntegrationTest.php
class IntegrationTest (line 16) | class IntegrationTest extends TestCase
method beforeSetup (line 23) | public function beforeSetup()
method createSchema (line 30) | protected function createSchema()
method afterTearDown (line 92) | public function afterTearDown()
method testInvalidRelationshipThrowsException (line 103) | public function testInvalidRelationshipThrowsException()
method testInvalidRelationshipTypeThrowsException (line 114) | public function testInvalidRelationshipTypeThrowsException()
method testNotAllRecordsDeletedThrowsException (line 125) | public function testNotAllRecordsDeletedThrowsException()
method testDeletesCascadeFirstLevel (line 137) | public function testDeletesCascadeFirstLevel()
method testDeletesCascadeSecondLevel (line 155) | public function testDeletesCascadeSecondLevel()
method testDeletesCascadeLowerLevels (line 172) | public function testDeletesCascadeLowerLevels()
method testEntireTransactionIsRolledBack (line 187) | public function testEntireTransactionIsRolledBack()
method testDeletesHiddenRelatedRecords (line 211) | public function testDeletesHiddenRelatedRecords()
method testDeletesOnlyRelatedRecords (line 224) | public function testDeletesOnlyRelatedRecords()
method testSoftDeletesCascade (line 239) | public function testSoftDeletesCascade()
method testForcedSoftDeletesCascade (line 262) | public function testForcedSoftDeletesCascade()
method testSoftDeletesHiddenRelatedRecords (line 285) | public function testSoftDeletesHiddenRelatedRecords()
method testForcedSoftDeletesHiddenRelatedRecords (line 299) | public function testForcedSoftDeletesHiddenRelatedRecords()
method testForcedSoftDeletesMixedRelatedRecords (line 313) | public function testForcedSoftDeletesMixedRelatedRecords()
method testSoftDeletesTransactionIsRolledBack (line 332) | public function testSoftDeletesTransactionIsRolledBack()
method testForcedSoftDeletesTransactionIsRolledBack (line 356) | public function testForcedSoftDeletesTransactionIsRolledBack()
FILE: tests/ModelTest.php
class ModelTest (line 8) | class ModelTest extends TestCase
method testModelUsesCascadingDeletesTrait (line 10) | public function testModelUsesCascadingDeletesTrait()
FILE: tests/Models/Comment.php
class Comment (line 7) | class Comment extends Model
method user (line 11) | public function user()
method post (line 16) | public function post()
FILE: tests/Models/ExtendedUser.php
class ExtendedUser (line 7) | class ExtendedUser extends CascadesDeletesModel
method friends (line 13) | public function friends()
method posts (line 18) | public function posts()
method photos (line 23) | public function photos()
method comments (line 28) | public function comments()
method profile (line 33) | public function profile()
method permanentPosts (line 38) | public function permanentPosts()
FILE: tests/Models/InvalidKid.php
class InvalidKid (line 8) | class InvalidKid extends Model
method invalidable (line 16) | public function invalidable()
FILE: tests/Models/PermanentPost.php
class PermanentPost (line 5) | class PermanentPost extends Post
method user (line 9) | public function user()
method childPosts (line 14) | public function childPosts()
method parentPost (line 19) | public function parentPost()
method comments (line 24) | public function comments()
method delete (line 29) | public function delete()
FILE: tests/Models/Photo.php
class Photo (line 7) | class Photo extends Model
method imageable (line 11) | public function imageable()
FILE: tests/Models/Post.php
class Post (line 8) | class Post extends Model
method user (line 16) | public function user()
method photos (line 21) | public function photos()
method childPosts (line 26) | public function childPosts()
method parentPost (line 31) | public function parentPost()
method comments (line 36) | public function comments()
method invalidKids (line 41) | public function invalidKids()
FILE: tests/Models/Profile.php
class Profile (line 7) | class Profile extends Model
method user (line 11) | public function user()
FILE: tests/Models/SoftPost.php
class SoftPost (line 7) | class SoftPost extends Post
method user (line 13) | public function user()
method childPosts (line 18) | public function childPosts()
method parentPost (line 23) | public function parentPost()
method comments (line 28) | public function comments()
FILE: tests/Models/SoftProfile.php
class SoftProfile (line 7) | class SoftProfile extends Profile
method user (line 13) | public function user()
FILE: tests/Models/SoftUser.php
class SoftUser (line 7) | class SoftUser extends User
method friends (line 13) | public function friends()
method posts (line 18) | public function posts()
method comments (line 23) | public function comments()
method profile (line 28) | public function profile()
FILE: tests/Models/User.php
class User (line 8) | class User extends Model
method friends (line 16) | public function friends()
method posts (line 21) | public function posts()
method photos (line 26) | public function photos()
method comments (line 31) | public function comments()
method profile (line 36) | public function profile()
method permanentPosts (line 41) | public function permanentPosts()
FILE: tests/TestCase.php
class TestCase (line 13) | class TestCase extends PhpunitTestCase
method setUpDatabaseConnection (line 25) | public function setUpDatabaseConnection()
method schema (line 49) | protected function schema($connection = 'default')
method connection (line 59) | protected function connection($connection = 'default')
method setRestrictedValue (line 74) | protected function setRestrictedValue($object, $property, $value)
FILE: tests/TraitTest.php
class TraitTest (line 10) | class TraitTest extends TestCase
method testCanGetCascadeDeletesProperty (line 12) | public function testCanGetCascadeDeletesProperty()
method testCanSetCascadeDeletesProperty (line 19) | public function testCanSetCascadeDeletesProperty()
method testGetRelationNamesReturnsArrayFromArray (line 29) | public function testGetRelationNamesReturnsArrayFromArray()
method testGetRelationNamesReturnsPopulatedArrayFromArray (line 38) | public function testGetRelationNamesReturnsPopulatedArrayFromArray()
method testGetRelationNamesReturnsArrayFromString (line 47) | public function testGetRelationNamesReturnsArrayFromString()
method testGetRelationNamesReturnsPopulatedArrayFromString (line 57) | public function testGetRelationNamesReturnsPopulatedArrayFromString()
method testGetRelationNamesReturnsArrayFromNonEmptyValue (line 67) | public function testGetRelationNamesReturnsArrayFromNonEmptyValue()
method testGetRelationNamesReturnsPopulatedArrayFromNonEmptyValue (line 77) | public function testGetRelationNamesReturnsPopulatedArrayFromNonEmptyV...
method testGetRelationNamesReturnsArrayFromEmptyValue (line 87) | public function testGetRelationNamesReturnsArrayFromEmptyValue()
method testGetRelationNamesReturnsEmptyArrayFromEmptyValue (line 97) | public function testGetRelationNamesReturnsEmptyArrayFromEmptyValue()
method testGetRelationsReturnsRelationObjectsForValidNames (line 107) | public function testGetRelationsReturnsRelationObjectsForValidNames()
method testGetRelationsReturnsNullForInvalidNames (line 122) | public function testGetRelationsReturnsNullForInvalidNames()
method testGetRelationsExcludesEmptyNames (line 137) | public function testGetRelationsExcludesEmptyNames()
method testGetInvalidRelationsReturnsInvalidNames (line 151) | public function testGetInvalidRelationsReturnsInvalidNames()
method testGetInvalidRelationsExcludesEmptyNames (line 162) | public function testGetInvalidRelationsExcludesEmptyNames()
method testIsForceDeletingReturnsTrueWhenForceDeleting (line 173) | public function testIsForceDeletingReturnsTrueWhenForceDeleting()
method testIsForceDeletingReturnsFalseWhenNotForceDeleting (line 182) | public function testIsForceDeletingReturnsFalseWhenNotForceDeleting()
method testGetCascadeDeletesRelationQueryReturnsRelation (line 189) | public function testGetCascadeDeletesRelationQueryReturnsRelation()
method testCascadeDeletesRelationQueryExcludesTrashedWhenNotForceDeleting (line 198) | public function testCascadeDeletesRelationQueryExcludesTrashedWhenNotF...
method testCascadeDeletesRelationQueryIncludesTrashedWhenForceDeleting (line 207) | public function testCascadeDeletesRelationQueryIncludesTrashedWhenForc...
FILE: tests/helpers.php
function class_uses_recursive (line 11) | function class_uses_recursive($class)
function trait_uses_recursive (line 35) | function trait_uses_recursive($trait)
Condensed preview — 30 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (62K chars).
[
{
"path": ".github/workflows/phpunit.yml",
"chars": 2899,
"preview": "name: Phpunit\n\non: [push, pull_request]\n\njobs:\n phpcs:\n runs-on: ubuntu-latest\n\n name: phpcs - PHP 8.4\n\n steps"
},
{
"path": ".gitignore",
"chars": 22,
"preview": "/vendor/\ncomposer.lock"
},
{
"path": ".scrutinizer.yml",
"chars": 322,
"preview": "build:\n nodes:\n analysis:\n environment:\n php: 8.3.11\n tests:\n "
},
{
"path": "CHANGELOG.md",
"chars": 3076,
"preview": "# Changelog\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changel"
},
{
"path": "CONTRIBUTING.md",
"chars": 1317,
"preview": "# Contributing\n\nContributions are **welcome** and will be fully **credited**.\n\nWe accept contributions via Pull Requests"
},
{
"path": "LICENSE.md",
"chars": 1083,
"preview": "The MIT License (MIT)\n\nCopyright (c) Patrick Carlo-Hickman\n\nPermission is hereby granted, free of charge, to any person "
},
{
"path": "README.md",
"chars": 7711,
"preview": "# laravel-cascade-deletes\n\n[![Latest Version on Packagist][ico-version]][link-packagist]\n[![Software License][ico-licens"
},
{
"path": "composer.json",
"chars": 1443,
"preview": "{\n \"name\": \"shiftonelabs/laravel-cascade-deletes\",\n \"description\": \"Adds application level cascading deletes to El"
},
{
"path": "phpcs.xml",
"chars": 606,
"preview": "<?xml version=\"1.0\"?>\r\n<ruleset name=\"MYPSR2\">\r\n <description>Base PSR-2 with a few modifications.</description>\r\n\r\n "
},
{
"path": "phpunit.10.ignore.deprecations.xml",
"chars": 986,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<phpunit xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n xsi:noNam"
},
{
"path": "phpunit.9.xml",
"chars": 811,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<phpunit xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n xsi:noNam"
},
{
"path": "phpunit.xml",
"chars": 1020,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<phpunit xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n xsi:noNam"
},
{
"path": "src/CascadesDeletes.php",
"chars": 6756,
"preview": "<?php\n\nnamespace ShiftOneLabs\\LaravelCascadeDeletes;\n\nuse LogicException;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Il"
},
{
"path": "src/CascadesDeletesModel.php",
"chars": 165,
"preview": "<?php\n\nnamespace ShiftOneLabs\\LaravelCascadeDeletes;\n\nuse Illuminate\\Database\\Eloquent\\Model;\n\nclass CascadesDeletesMode"
},
{
"path": "tests/IntegrationTest.php",
"chars": 14710,
"preview": "<?php\n\nnamespace ShiftOneLabs\\LaravelCascadeDeletes\\Tests;\n\nuse LogicException;\nuse ShiftOneLabs\\LaravelCascadeDeletes\\T"
},
{
"path": "tests/ModelTest.php",
"chars": 387,
"preview": "<?php\n\nnamespace ShiftOneLabs\\LaravelCascadeDeletes\\Tests;\n\nuse ShiftOneLabs\\LaravelCascadeDeletes\\CascadesDeletes;\nuse "
},
{
"path": "tests/Models/Comment.php",
"chars": 341,
"preview": "<?php\n\nnamespace ShiftOneLabs\\LaravelCascadeDeletes\\Tests\\Models;\n\nuse Illuminate\\Database\\Eloquent\\Model;\n\nclass Commen"
},
{
"path": "tests/Models/ExtendedUser.php",
"chars": 896,
"preview": "<?php\n\nnamespace ShiftOneLabs\\LaravelCascadeDeletes\\Tests\\Models;\n\nuse ShiftOneLabs\\LaravelCascadeDeletes\\CascadesDelete"
},
{
"path": "tests/Models/InvalidKid.php",
"chars": 384,
"preview": "<?php\n\nnamespace ShiftOneLabs\\LaravelCascadeDeletes\\Tests\\Models;\n\nuse Illuminate\\Database\\Eloquent\\Model;\nuse ShiftOneL"
},
{
"path": "tests/Models/PermanentPost.php",
"chars": 614,
"preview": "<?php\n\nnamespace ShiftOneLabs\\LaravelCascadeDeletes\\Tests\\Models;\n\nclass PermanentPost extends Post\n{\n protected $tab"
},
{
"path": "tests/Models/Photo.php",
"chars": 245,
"preview": "<?php\n\nnamespace ShiftOneLabs\\LaravelCascadeDeletes\\Tests\\Models;\n\nuse Illuminate\\Database\\Eloquent\\Model;\n\nclass Photo "
},
{
"path": "tests/Models/Post.php",
"chars": 935,
"preview": "<?php\n\nnamespace ShiftOneLabs\\LaravelCascadeDeletes\\Tests\\Models;\n\nuse Illuminate\\Database\\Eloquent\\Model;\nuse ShiftOneL"
},
{
"path": "tests/Models/Profile.php",
"chars": 255,
"preview": "<?php\n\nnamespace ShiftOneLabs\\LaravelCascadeDeletes\\Tests\\Models;\n\nuse Illuminate\\Database\\Eloquent\\Model;\n\nclass Profil"
},
{
"path": "tests/Models/SoftPost.php",
"chars": 608,
"preview": "<?php\n\nnamespace ShiftOneLabs\\LaravelCascadeDeletes\\Tests\\Models;\n\nuse Illuminate\\Database\\Eloquent\\SoftDeletes;\n\nclass "
},
{
"path": "tests/Models/SoftProfile.php",
"chars": 295,
"preview": "<?php\n\nnamespace ShiftOneLabs\\LaravelCascadeDeletes\\Tests\\Models;\n\nuse Illuminate\\Database\\Eloquent\\SoftDeletes;\n\nclass "
},
{
"path": "tests/Models/SoftUser.php",
"chars": 638,
"preview": "<?php\n\nnamespace ShiftOneLabs\\LaravelCascadeDeletes\\Tests\\Models;\n\nuse Illuminate\\Database\\Eloquent\\SoftDeletes;\n\nclass "
},
{
"path": "tests/Models/User.php",
"chars": 934,
"preview": "<?php\n\nnamespace ShiftOneLabs\\LaravelCascadeDeletes\\Tests\\Models;\n\nuse Illuminate\\Database\\Eloquent\\Model;\nuse ShiftOneL"
},
{
"path": "tests/TestCase.php",
"chars": 2238,
"preview": "<?php\n\nnamespace ShiftOneLabs\\LaravelCascadeDeletes\\Tests;\n\nuse ReflectionProperty;\nuse Illuminate\\Events\\Dispatcher;\nus"
},
{
"path": "tests/TraitTest.php",
"chars": 5997,
"preview": "<?php\n\nnamespace ShiftOneLabs\\LaravelCascadeDeletes\\Tests;\n\nuse Illuminate\\Database\\Eloquent\\SoftDeletingScope;\nuse Illu"
},
{
"path": "tests/helpers.php",
"chars": 990,
"preview": "<?php\n\nif (!function_exists('class_uses_recursive')) {\n /**\n * Returns all traits used by a class, its subclasses"
}
]
About this extraction
This page contains the full source code of the shiftonelabs/laravel-cascade-deletes GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 30 files (57.3 KB), approximately 14.7k tokens, and a symbol index with 108 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.