From 1643b82d9a6f47fd718ae545aabec6762c0aa441 Mon Sep 17 00:00:00 2001 From: Niclas Schirrmeister Date: Wed, 4 Mar 2020 12:34:08 +0100 Subject: [PATCH] initial commit --- .gitattributes | 11 + .github/workflows/run-tests.yml | 59 +++ .gitignore | 5 + .phpunit.result.cache | 1 + CHANGELOG.md | 7 + CONTRIBUTING.md | 12 + LICENSE.md | 21 ++ README.md | 83 ++++ SECURITY.md | 19 + composer.json | 65 ++++ config/config.php | 8 + phpstan.neon.dist | 7 + phpunit.xml.dist | 22 ++ src/IdAndAttributesCollection.php | 32 ++ src/IdAndAttributesContainer.php | 45 +++ src/OneToManySync.php | 105 ++++++ src/SyncOneToManyServiceProvider.php | 32 ++ tests/IdAndAttributesCollectionTest.php | 55 +++ tests/IdAndAttributesContainerTest.php | 64 ++++ tests/Models/Task.php | 13 + tests/Models/User.php | 13 + tests/OneToManySyncTest.php | 356 ++++++++++++++++++ tests/TestCase.php | 78 ++++ tests/factories/TaskFactory.php | 15 + tests/factories/UserFactory.php | 12 + .../2020_03_02_134146_create_test_tables.php | 31 ++ tlint.json | 3 + 27 files changed, 1174 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/run-tests.yml create mode 100644 .gitignore create mode 100644 .phpunit.result.cache create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 composer.json create mode 100644 config/config.php create mode 100644 phpstan.neon.dist create mode 100644 phpunit.xml.dist create mode 100644 src/IdAndAttributesCollection.php create mode 100644 src/IdAndAttributesContainer.php create mode 100644 src/OneToManySync.php create mode 100644 src/SyncOneToManyServiceProvider.php create mode 100644 tests/IdAndAttributesCollectionTest.php create mode 100644 tests/IdAndAttributesContainerTest.php create mode 100644 tests/Models/Task.php create mode 100644 tests/Models/User.php create mode 100644 tests/OneToManySyncTest.php create mode 100644 tests/TestCase.php create mode 100644 tests/factories/TaskFactory.php create mode 100644 tests/factories/UserFactory.php create mode 100644 tests/migrations/2020_03_02_134146_create_test_tables.php create mode 100644 tlint.json diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..bb6265e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore all test and documentation with "export-ignore". +/.gitattributes export-ignore +/.gitignore export-ignore +/.travis.yml export-ignore +/phpunit.xml.dist export-ignore +/.scrutinizer.yml export-ignore +/tests export-ignore +/.editorconfig export-ignore diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..5947968 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,59 @@ +name: run-tests + +on: push + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: true + matrix: + os: [ubuntu-latest] + php: [7.4] + laravel: [6.*] + dependency-version: [prefer-lowest, prefer-stable] + + name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} - ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Get Composer Cache Directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + - name: Cache Composer + uses: actions/cache@v1 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} + restore-keys: | + composer-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer- + composer-laravel-${{ matrix.laravel }}-php- + composer-laravel- + + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update + composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction --no-suggest + + - name: phpunit + run: vendor/bin/phpunit + + - name: php-cs-test + run: vendor/bin/php-cs-test + + - name: php-md-test + run: vendor/bin/php-md-test ./src + + - name: php-tlint-test + run: vendor/bin/php-tlint-test ./src + + - name: php-insights-test + run: vendor/bin/php-insights-test + + - name: php-stan-test + run: vendor/bin/php-stan-test + + - name: php-mn-test + run: vendor/bin/php-mn-test ./src diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..808f8c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +build +composer.lock +docs +vendor +coverage \ No newline at end of file diff --git a/.phpunit.result.cache b/.phpunit.result.cache new file mode 100644 index 0000000..b8a0622 --- /dev/null +++ b/.phpunit.result.cache @@ -0,0 +1 @@ +C:37:"PHPUnit\Runner\DefaultTestResultCache":9466:{a:2:{s:7:"defects";a:42:{s:74:"Elbgoods\LaravelSyncOneToMany\Tests\OneToManySyncTest::it_attaches_a_model";i:4;s:102:"Elbgoods\LaravelSyncOneToMany\Tests\OneToManySyncTest::it_attaches_a_model_with_a_changes_given_fields";i:4;s:77:"Elbgoods\LaravelSyncOneToMany\Tests\OneToManySyncTest::it_attaches_two_models";i:4;s:76:"Elbgoods\LaravelSyncOneToMany\Tests\OneToManySyncTest::it_changes_model_data";i:3;s:76:"Elbgoods\LaravelSyncOneToMany\Tests\OneToManySyncTest::it_changes_two_models";i:3;s:74:"Elbgoods\LaravelSyncOneToMany\Tests\OneToManySyncTest::it_detaches_a_model";i:3;s:84:"Elbgoods\LaravelSyncOneToMany\Tests\OneToManySyncTest::it_detaches_one_of_two_models";i:3;s:87:"Elbgoods\LaravelSyncOneToMany\Tests\OneToManySyncTest::it_set_default_data_after_detach";i:3;s:77:"Elbgoods\LaravelSyncOneToMany\Tests\OneToManySyncTest::it_detaches_two_models";i:3;s:99:"Elbgoods\LaravelSyncOneToMany\Tests\OneToManySyncTest::it_attaches_a_model_and_detaches_another_one";i:4;s:118:"Elbgoods\LaravelSyncOneToMany\Tests\OneToManySyncTest::it_attaches_a_model_and_does_not_detach_when_detaching_is_false";i:4;s:126:"Elbgoods\LaravelSyncOneToMany\Tests\OneToManySyncTest::it_attaches_a_model_and_does_not_detach_when_using_syncWithoutDetaching";i:4;s:92:"Elbgoods\LaravelSyncOneToMany\Tests\OneToManySyncTest::it_attaches_changes_and_detach_models";i:3;s:89:"Elbgoods\LaravelSyncOneToMany\Tests\RelatedRowInputDataTest::it_shows_id_from_array_input";i:4;s:122:"Elbgoods\LaravelSyncOneToMany\Tests\RelatedRowInputDataTest::it_shows_empty_additional_attributes_when_input_is_from_array";i:4;s:96:"Elbgoods\LaravelSyncOneToMany\Tests\RelatedRowInputDataTest::it_shows_key_from_assoc_array_as_id";i:4;s:117:"Elbgoods\LaravelSyncOneToMany\Tests\RelatedRowInputDataTest::it_shows_value_from_assoc_array_as_additional_attributes";i:4;s:102:"Elbgoods\LaravelSyncOneToMany\Tests\IdAndAttributesCollectionTest::it_inherits_from_laravel_collection";i:4;s:81:"Elbgoods\LaravelSyncOneToMany\Tests\IdAndAttributesCollectionTest::it_returns_ids";i:4;s:103:"Elbgoods\LaravelSyncOneToMany\Tests\IdAndAttributesCollectionTest::it_returns_keys_of_assoc_array_as_id";i:4;s:119:"Elbgoods\LaravelSyncOneToMany\Tests\IdAndAttributesCollectionTest::it_converts_items_to_IdAndAttributeContainer_objects";i:4;s:95:"Elbgoods\SyncOneToMany\Tests\IdAndAttributesCollectionTest::it_inherits_from_laravel_collection";i:4;s:74:"Elbgoods\SyncOneToMany\Tests\IdAndAttributesCollectionTest::it_returns_ids";i:4;s:96:"Elbgoods\SyncOneToMany\Tests\IdAndAttributesCollectionTest::it_returns_keys_of_assoc_array_as_id";i:4;s:112:"Elbgoods\SyncOneToMany\Tests\IdAndAttributesCollectionTest::it_converts_items_to_IdAndAttributeContainer_objects";i:4;s:87:"Elbgoods\SyncOneToMany\Tests\IdAndAttributesContainerTest::it_shows_id_from_array_input";i:4;s:120:"Elbgoods\SyncOneToMany\Tests\IdAndAttributesContainerTest::it_shows_empty_additional_attributes_when_input_is_from_array";i:4;s:94:"Elbgoods\SyncOneToMany\Tests\IdAndAttributesContainerTest::it_shows_key_from_assoc_array_as_id";i:4;s:115:"Elbgoods\SyncOneToMany\Tests\IdAndAttributesContainerTest::it_shows_value_from_assoc_array_as_additional_attributes";i:4;s:67:"Elbgoods\SyncOneToMany\Tests\OneToManySyncTest::it_attaches_a_model";i:4;s:95:"Elbgoods\SyncOneToMany\Tests\OneToManySyncTest::it_attaches_a_model_with_a_changes_given_fields";i:4;s:70:"Elbgoods\SyncOneToMany\Tests\OneToManySyncTest::it_attaches_two_models";i:4;s:69:"Elbgoods\SyncOneToMany\Tests\OneToManySyncTest::it_changes_model_data";i:4;s:69:"Elbgoods\SyncOneToMany\Tests\OneToManySyncTest::it_changes_two_models";i:4;s:67:"Elbgoods\SyncOneToMany\Tests\OneToManySyncTest::it_detaches_a_model";i:4;s:77:"Elbgoods\SyncOneToMany\Tests\OneToManySyncTest::it_detaches_one_of_two_models";i:4;s:80:"Elbgoods\SyncOneToMany\Tests\OneToManySyncTest::it_set_default_data_after_detach";i:4;s:70:"Elbgoods\SyncOneToMany\Tests\OneToManySyncTest::it_detaches_two_models";i:4;s:92:"Elbgoods\SyncOneToMany\Tests\OneToManySyncTest::it_attaches_a_model_and_detaches_another_one";i:4;s:111:"Elbgoods\SyncOneToMany\Tests\OneToManySyncTest::it_attaches_a_model_and_does_not_detach_when_detaching_is_false";i:4;s:119:"Elbgoods\SyncOneToMany\Tests\OneToManySyncTest::it_attaches_a_model_and_does_not_detach_when_using_syncWithoutDetaching";i:4;s:85:"Elbgoods\SyncOneToMany\Tests\OneToManySyncTest::it_attaches_changes_and_detach_models";i:4;}s:5:"times";a:46:{s:74:"Elbgoods\LaravelSyncOneToMany\Tests\OneToManySyncTest::it_attaches_a_model";d:0.016;s:102:"Elbgoods\LaravelSyncOneToMany\Tests\OneToManySyncTest::it_attaches_a_model_with_a_changes_given_fields";d:0.009;s:77:"Elbgoods\LaravelSyncOneToMany\Tests\OneToManySyncTest::it_attaches_two_models";d:0.009;s:76:"Elbgoods\LaravelSyncOneToMany\Tests\OneToManySyncTest::it_changes_model_data";d:0.009;s:76:"Elbgoods\LaravelSyncOneToMany\Tests\OneToManySyncTest::it_changes_two_models";d:0.01;s:74:"Elbgoods\LaravelSyncOneToMany\Tests\OneToManySyncTest::it_detaches_a_model";d:0.008;s:84:"Elbgoods\LaravelSyncOneToMany\Tests\OneToManySyncTest::it_detaches_one_of_two_models";d:0.009;s:87:"Elbgoods\LaravelSyncOneToMany\Tests\OneToManySyncTest::it_set_default_data_after_detach";d:0.009;s:77:"Elbgoods\LaravelSyncOneToMany\Tests\OneToManySyncTest::it_detaches_two_models";d:0.012;s:99:"Elbgoods\LaravelSyncOneToMany\Tests\OneToManySyncTest::it_attaches_a_model_and_detaches_another_one";d:0.009;s:118:"Elbgoods\LaravelSyncOneToMany\Tests\OneToManySyncTest::it_attaches_a_model_and_does_not_detach_when_detaching_is_false";d:0.01;s:126:"Elbgoods\LaravelSyncOneToMany\Tests\OneToManySyncTest::it_attaches_a_model_and_does_not_detach_when_using_syncWithoutDetaching";d:0.009;s:92:"Elbgoods\LaravelSyncOneToMany\Tests\OneToManySyncTest::it_attaches_changes_and_detach_models";d:0.011;s:89:"Elbgoods\LaravelSyncOneToMany\Tests\RelatedRowInputDataTest::it_shows_id_from_array_input";d:0.007;s:122:"Elbgoods\LaravelSyncOneToMany\Tests\RelatedRowInputDataTest::it_shows_empty_additional_attributes_when_input_is_from_array";d:0.006;s:96:"Elbgoods\LaravelSyncOneToMany\Tests\RelatedRowInputDataTest::it_shows_key_from_assoc_array_as_id";d:0.006;s:117:"Elbgoods\LaravelSyncOneToMany\Tests\RelatedRowInputDataTest::it_shows_value_from_assoc_array_as_additional_attributes";d:0.006;s:94:"Elbgoods\LaravelSyncOneToMany\Tests\IdAndAttributesContainerTest::it_shows_id_from_array_input";d:0.006;s:127:"Elbgoods\LaravelSyncOneToMany\Tests\IdAndAttributesContainerTest::it_shows_empty_additional_attributes_when_input_is_from_array";d:0.006;s:101:"Elbgoods\LaravelSyncOneToMany\Tests\IdAndAttributesContainerTest::it_shows_key_from_assoc_array_as_id";d:0.006;s:122:"Elbgoods\LaravelSyncOneToMany\Tests\IdAndAttributesContainerTest::it_shows_value_from_assoc_array_as_additional_attributes";d:0.007;s:102:"Elbgoods\LaravelSyncOneToMany\Tests\IdAndAttributesCollectionTest::it_inherits_from_laravel_collection";d:0.084;s:81:"Elbgoods\LaravelSyncOneToMany\Tests\IdAndAttributesCollectionTest::it_returns_ids";d:0.007;s:103:"Elbgoods\LaravelSyncOneToMany\Tests\IdAndAttributesCollectionTest::it_returns_keys_of_assoc_array_as_id";d:0.007;s:119:"Elbgoods\LaravelSyncOneToMany\Tests\IdAndAttributesCollectionTest::it_converts_items_to_IdAndAttributeContainer_objects";d:0.007;s:95:"Elbgoods\SyncOneToMany\Tests\IdAndAttributesCollectionTest::it_inherits_from_laravel_collection";d:0.086;s:74:"Elbgoods\SyncOneToMany\Tests\IdAndAttributesCollectionTest::it_returns_ids";d:0.006;s:96:"Elbgoods\SyncOneToMany\Tests\IdAndAttributesCollectionTest::it_returns_keys_of_assoc_array_as_id";d:0.006;s:112:"Elbgoods\SyncOneToMany\Tests\IdAndAttributesCollectionTest::it_converts_items_to_IdAndAttributeContainer_objects";d:0.008;s:87:"Elbgoods\SyncOneToMany\Tests\IdAndAttributesContainerTest::it_shows_id_from_array_input";d:0.006;s:120:"Elbgoods\SyncOneToMany\Tests\IdAndAttributesContainerTest::it_shows_empty_additional_attributes_when_input_is_from_array";d:0.006;s:94:"Elbgoods\SyncOneToMany\Tests\IdAndAttributesContainerTest::it_shows_key_from_assoc_array_as_id";d:0.006;s:115:"Elbgoods\SyncOneToMany\Tests\IdAndAttributesContainerTest::it_shows_value_from_assoc_array_as_additional_attributes";d:0.007;s:67:"Elbgoods\SyncOneToMany\Tests\OneToManySyncTest::it_attaches_a_model";d:0.015;s:95:"Elbgoods\SyncOneToMany\Tests\OneToManySyncTest::it_attaches_a_model_with_a_changes_given_fields";d:0.008;s:70:"Elbgoods\SyncOneToMany\Tests\OneToManySyncTest::it_attaches_two_models";d:0.011;s:69:"Elbgoods\SyncOneToMany\Tests\OneToManySyncTest::it_changes_model_data";d:0.009;s:69:"Elbgoods\SyncOneToMany\Tests\OneToManySyncTest::it_changes_two_models";d:0.009;s:67:"Elbgoods\SyncOneToMany\Tests\OneToManySyncTest::it_detaches_a_model";d:0.008;s:77:"Elbgoods\SyncOneToMany\Tests\OneToManySyncTest::it_detaches_one_of_two_models";d:0.01;s:80:"Elbgoods\SyncOneToMany\Tests\OneToManySyncTest::it_set_default_data_after_detach";d:0.009;s:70:"Elbgoods\SyncOneToMany\Tests\OneToManySyncTest::it_detaches_two_models";d:0.012;s:92:"Elbgoods\SyncOneToMany\Tests\OneToManySyncTest::it_attaches_a_model_and_detaches_another_one";d:0.011;s:111:"Elbgoods\SyncOneToMany\Tests\OneToManySyncTest::it_attaches_a_model_and_does_not_detach_when_detaching_is_false";d:0.009;s:119:"Elbgoods\SyncOneToMany\Tests\OneToManySyncTest::it_attaches_a_model_and_does_not_detach_when_using_syncWithoutDetaching";d:0.009;s:85:"Elbgoods\SyncOneToMany\Tests\OneToManySyncTest::it_attaches_changes_and_detach_models";d:0.012;}}} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..77d8625 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +All notable changes to `laravel-sync-one-to-many` will be documented in this file + +## 0.1.0 - 2013-03-04 + +- initial release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a988973 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,12 @@ +## Coding Guidelines + +* This package follows the [coding guidelines](https://github.com/laravel/framework/blob/master/CONTRIBUTING.md#coding-guidelines) used by Laravel. +* Pull requests for the latest major release MUST be sent to the master branch. +* To preserve the quality of the package, only **tested** code changes will by reviewed. + +### Testing + +Execute tests and code quality tool with following command. +``` bash +composer test +``` diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..8858fd3 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) John Doe + +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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0e2c2a0 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# Laravel Sync OneToMany + +This package provides the sync function for one to many relations similar to the sync method from BelongsToMany. + +## Installation + +You can install the package via composer: + +```bash +composer require elbgoods/laravel-sync-one-to-many +``` + +## Usage + +Sync using ids +``` php +$user->tasks()->sync([1, 2, 4]); +``` + +Sync using ids and additional attributes +``` php +$user->tasks()->sync([ + 1 => ['status' => 'wip', 'priority' => 1], + 4 => ['status' => 'finished', 'priority' => 3], +]); +``` + +Sync without detaching +``` php +$user->tasks()->syncWithoutDetaching([1, 2, 4]); + +// or + +$user->tasks()->sync([1, 2, 4], ['detaching' => false]); +``` +Sync and set additional attributes to detached +``` php +$user->tasks()->sync( + [1, 2, 4], + 'set_after_detach' => [ + 'status' => 'open', + 'priority' => 0, + ], +); +``` + +Result is the same as the result of the sync method of `BelongsToMany`, an array with attach, detached and updated rows. + +### Changelog + +Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. + +## Contributing + +Please see [CONTRIBUTING](CONTRIBUTING.md) for details. + +## Security + +Please see [SECURITY](SECURITY.md) for details. + +## Credits + +- [Niclas Schirrmeister](https://github.com/eisfeuer) +- [Tom Witkowski](https://github.com/gummibeer) +- [All Contributors](../../contributors) + +## License + +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. + +## Treeware + +You're free to use this package, but if it makes it to your production environment we would highly appreciate you buying or planting the world a tree. + +It’s now common knowledge that one of the best tools to tackle the climate crisis and keep our temperatures from rising above 1.5C is to [plant trees](https://www.bbc.co.uk/news/science-environment-48870920). If you contribute to my forest you’ll be creating employment for local families and restoring wildlife habitats. + +You can buy trees at https://offset.earth/treeware + +Read more about Treeware at https://treeware.earth + +[![We offset our carbon footprint via Offset Earth](https://toolkit.offset.earth/carbonpositiveworkforce/badge/5e186e68516eb60018c5172b?black=true&landscape=true)](https://offset.earth/treeware) + +This package was generated using the [Laravel Package Boilerplate](https://laravelpackageboilerplate.com). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d418f23 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,19 @@ +# Security Policy + +## Supported Versions + +This package has no LTS releases - this means that we will only support the latest minor release with feature updates. +We have never had any security vulnerabilities and therefore we don't promise any list of versions. +Instead we will determine this on a case by case basis - depending on factors like: +* vulnerable versions +* age of the versions +* usage of the versions (packagist downloads) +* effort needed to fix it in the versions + +We will do our best to fix security issues as and when they become apparent. We will attempt inform all users about possible issues. + +## Reporting a Vulnerability + +Due to the fact that security vulnerabilities could harm users, we ask that you don't use the public issue tracker to report them. +Please write a mail to [moin@elbgoods.de](mailto:moin@elbgoods.de). +We will either create a public issue/security alert, or we will fix the vulnerability and inform users afterwards. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..bb57f9a --- /dev/null +++ b/composer.json @@ -0,0 +1,65 @@ +{ + "name": "elbgoods/laravel-sync-one-to-many", + "description": "", + "keywords": [ + "elbgoods", + "laravel-sync-one-to-many" + ], + "homepage": "https://github.com/elbgoods/laravel-sync-one-to-many", + "license": "MIT", + "type": "library", + "authors": [ + { + "name": "Niclas Schirrmeister", + "email": "nschirrmeister@elbgoods.de", + "role": "Developer" + }, { + "name": "Tom Witkowski", + "email": "twitkowski@elbgoods.de", + "role": "Developer" + } + ], + "require": { + "php": "^7.4", + "illuminate/support": "^6.0" + }, + "require-dev": { + "elbgoods/ci-test-tools": "^1.6", + "orchestra/database": "^4.3", + "orchestra/testbench": "^4.0", + "phpunit/phpunit": "^8.0" + }, + "autoload": { + "psr-4": { + "Elbgoods\\SyncOneToMany\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Elbgoods\\SyncOneToMany\\Tests\\": "tests" + } + }, + "scripts": { + "test": [ + "vendor/bin/phpunit", + "vendor/bin/php-cs-test", + "vendor/bin/php-tlint-test .", + "vendor/bin/php-md-test ./src", + "vendor/bin/php-insights-test", + "vendor/bin/php-mn-test", + "vendor/bin/php-stan-test" + ], + "test-coverage": "vendor/bin/phpunit --coverage-html coverage" + + }, + "config": { + "sort-packages": true + }, + "extra": { + "laravel": { + "providers": [ + "Elbgoods\\SyncOneToMany\\SyncOneToManyServiceProvider" + ] + } + } +} diff --git a/config/config.php b/config/config.php new file mode 100644 index 0000000..aaec092 --- /dev/null +++ b/config/config.php @@ -0,0 +1,8 @@ + + + + + tests + + + + + src/ + + + diff --git a/src/IdAndAttributesCollection.php b/src/IdAndAttributesCollection.php new file mode 100644 index 0000000..ee3d77a --- /dev/null +++ b/src/IdAndAttributesCollection.php @@ -0,0 +1,32 @@ +convertItems($items)); + } + + public function getIds(): array + { + return $this->toLaravelCollection()->map(static function (IdAndAttributesContainer $value): int { + return $value->getId(); + })->toArray(); + } + + protected function convertItems(array $items): array + { + return collect($items)->map(static function ($value, $key) { + return new IdAndAttributesContainer($key, $value); + })->values()->toArray(); + } + + protected function toLaravelCollection(): LaravelCollection + { + return collect($this); + } +} diff --git a/src/IdAndAttributesContainer.php b/src/IdAndAttributesContainer.php new file mode 100644 index 0000000..80a7588 --- /dev/null +++ b/src/IdAndAttributesContainer.php @@ -0,0 +1,45 @@ +hasAdditionalAttributes($arrayValue)) { + $this->id = $arrayKey; + $this->additionalAttributes = $arrayValue; + } else { + $this->id = $arrayValue; + $this->additionalAttributes = []; + } + } + + /** + * @return mixed + */ + public function getId() + { + return $this->id; + } + + public function getAdditionalAttributes(): array + { + return $this->additionalAttributes; + } + + /** + * @param int|array $arrayValue + */ + protected function hasAdditionalAttributes($arrayValue): bool + { + return is_array($arrayValue); + } +} diff --git a/src/OneToManySync.php b/src/OneToManySync.php new file mode 100644 index 0000000..bbbd1fa --- /dev/null +++ b/src/OneToManySync.php @@ -0,0 +1,105 @@ +hasMany = $hasMany; + $this->idsAndAttributes = new IdAndAttributesCollection($idsAndAttributes); + $this->options = $options; + } + + public function execute(): array + { + $changes = [ + 'attached' => [], + 'detached' => $this->detachingEnabled() ? $this->detach() : [], + 'updated' => [], + ]; + + return $this->attachNew($changes); + } + + protected function getCurrentRelatedModels(): EloquentCollection + { + if (! $this->currentRelatedModels) { + $this->currentRelatedModels = $this->hasMany->get(); + } + + return $this->currentRelatedModels; + } + + protected function getCurrentRelatedIds(): array + { + return $this->getCurrentRelatedModels()->pluck('id')->toArray(); + } + + protected function getDetachingIds(): array + { + return array_diff($this->getCurrentRelatedIds(), $this->idsAndAttributes->getIds()); + } + + protected function detachingEnabled(): bool + { + return Arr::get($this->options, 'detaching', true); + } + + protected function detach(): array + { + $detaching = $this->getDetachingIds(); + + $this->hasMany->getRelated()->query()->whereKey($detaching)->update( + array_merge( + $this->getValuesToSetOnDetach(), + [$this->hasMany->getForeignKeyName() => null] + ) + ); + + return $detaching; + } + + protected function getValuesToSetOnDetach(): array + { + return Arr::get($this->options, 'set_after_detach', []); + } + + protected function attachNew(array $changes): array + { + $currentIds = $this->getCurrentRelatedIds(); + + foreach ($this->idsAndAttributes as $idAndAttributesContainer) { + if ($this->update($idAndAttributesContainer) > 0) { + $changingStatus = in_array($idAndAttributesContainer->getId(), $currentIds) ? 'updated' : 'attached'; + array_push($changes[$changingStatus], $idAndAttributesContainer->getId()); + } + } + + return $changes; + } + + protected function update(IdAndAttributesContainer $idAndAttributesContainer): int + { + return $this->hasMany->getRelated()->query()->whereKey($idAndAttributesContainer->getId())->update( + array_merge( + $idAndAttributesContainer->getAdditionalAttributes(), + [$this->hasMany->getForeignKeyName() => $this->hasMany->getParentKey()] + ) + ); + } +} diff --git a/src/SyncOneToManyServiceProvider.php b/src/SyncOneToManyServiceProvider.php new file mode 100644 index 0000000..6a85152 --- /dev/null +++ b/src/SyncOneToManyServiceProvider.php @@ -0,0 +1,32 @@ +execute(); + }); + + HasMany::macro('syncWithoutDetaching', function (array $ids, array $options = []): array { + /** @var HasMany $this */ + return $this->sync($ids, array_merge($options, ['detaching' => false])); + }); + } + + /** + * Register the application services. + */ + public function register(): void + { + } +} diff --git a/tests/IdAndAttributesCollectionTest.php b/tests/IdAndAttributesCollectionTest.php new file mode 100644 index 0000000..2d79243 --- /dev/null +++ b/tests/IdAndAttributesCollectionTest.php @@ -0,0 +1,55 @@ +assertInstanceOf(LaravelCollection::class, $collection); + } + + /** + * @test + */ + public function it_returns_ids(): void + { + $collection = new IdAndAttributesCollection([4, 7, 2]); + $this->assertArrayContainsExact([2, 4, 7], $collection->getIds()); + } + + /** + * @test + */ + public function it_returns_keys_of_assoc_array_as_id(): void + { + $collection = new IdAndAttributesCollection([ + 2 => ['status' => 'wip'], + 4 => ['status' => 'finished'], + ]); + $this->assertArrayContainsExact([2, 4], $collection->getIds()); + } + + /** + * @test + */ + public function it_converts_items_to_IdAndAttributeContainer_objects(): void + { + $collection = new IdAndAttributesCollection([ + 4 => ['status' => 'wip'], + ]); + + $this->assertCount(1, $collection); + $this->assertInstanceOf(IdAndAttributesContainer::class, $collection->first()); + $this->assertEquals(4, $collection->first()->getId()); + $this->assertEquals(['status' => 'wip'], $collection->first()->getAdditionalAttributes()); + } +} diff --git a/tests/IdAndAttributesContainerTest.php b/tests/IdAndAttributesContainerTest.php new file mode 100644 index 0000000..7763ce5 --- /dev/null +++ b/tests/IdAndAttributesContainerTest.php @@ -0,0 +1,64 @@ +assertEquals(4, $relatedRowInputData->getId()); + } + + /** + * @test + */ + public function it_shows_empty_additional_attributes_when_input_is_from_array(): void + { + $array = [4]; + $key = array_keys($array)[0]; + $value = array_values($array)[0]; + + $relatedRowInputData = new IdAndAttributesContainer($key, $value); + + $this->assertEquals([], $relatedRowInputData->getAdditionalAttributes()); + } + + /** + * @test + */ + public function it_shows_key_from_assoc_array_as_id(): void + { + $array = [4 => ['status' => 'wip']]; + $key = array_keys($array)[0]; + $value = array_values($array)[0]; + + $relatedRowInputData = new IdAndAttributesContainer($key, $value); + + $this->assertEquals(4, $relatedRowInputData->getId()); + } + + /** + * @test + */ + public function it_shows_value_from_assoc_array_as_additional_attributes(): void + { + $array = [4 => ['status' => 'wip']]; + $key = array_keys($array)[0]; + $value = array_values($array)[0]; + + $relatedRowInputData = new IdAndAttributesContainer($key, $value); + + $this->assertEquals(['status' => 'wip'], $relatedRowInputData->getAdditionalAttributes()); + } +} diff --git a/tests/Models/Task.php b/tests/Models/Task.php new file mode 100644 index 0000000..a715cfb --- /dev/null +++ b/tests/Models/Task.php @@ -0,0 +1,13 @@ +belongsTo(User::class); + } +} diff --git a/tests/Models/User.php b/tests/Models/User.php new file mode 100644 index 0000000..ca36903 --- /dev/null +++ b/tests/Models/User.php @@ -0,0 +1,13 @@ +hasMany(Task::class); + } +} diff --git a/tests/OneToManySyncTest.php b/tests/OneToManySyncTest.php new file mode 100644 index 0000000..7965e95 --- /dev/null +++ b/tests/OneToManySyncTest.php @@ -0,0 +1,356 @@ +create(); + $task = factory(Task::class)->create(); + + $result = $user->tasks()->sync([$task->id]); + + $this->assertAttached([$task->id], $result); + $this->assertChanged([], $result); + $this->assertDetached([], $result); + + $this->assertModelEquals($user, $task->fresh()->user); + } + + /** + * @test + */ + public function it_attaches_a_model_with_a_changes_given_fields(): void + { + $user = factory(User::class)->create(); + $task = factory(Task::class)->create(); + + $result = $user->tasks()->sync([ + $task->id => ['status' => 'wip', 'priority' => 1], + ]); + + $this->assertAttached([$task->id], $result); + $this->assertChanged([], $result); + $this->assertDetached([], $result); + + $task->refresh(); + $this->assertModelEquals($user, $task->user); + $this->assertEquals('wip', $task->status); + $this->assertEquals(1, $task->priority); + } + + /** + * @test + */ + public function it_attaches_two_models(): void + { + $user = factory(User::class)->create(); + $task1 = factory(Task::class)->create(); + $task2 = factory(Task::class)->create(); + + $result = $user->tasks()->sync([ + $task1->id => ['status' => 'wip'], + $task2->id => ['status' => 'finished'], + ]); + + $this->assertAttached([$task1->id, $task2->id], $result); + $this->assertChanged([], $result); + $this->assertDetached([], $result); + + $task1->refresh(); + $task2->refresh(); + $this->assertModelEquals($user, $task1->user); + $this->assertEquals('wip', $task1->status); + $this->assertModelEquals($user, $task2->user); + $this->assertEquals('finished', $task2->status); + } + + /** + * @test + */ + public function it_changes_model_data(): void + { + $user = factory(User::class)->create(); + $task = factory(Task::class)->create([ + 'user_id' => $user->id, + 'status' => 'wip', + ]); + + $result = $user->tasks()->sync([ + $task->id => ['status' => 'finished'], + ]); + + $this->assertAttached([], $result); + $this->assertChanged([$task->id], $result); + $this->assertDetached([], $result); + + $task->refresh(); + $this->assertModelEquals($user, $task->user); + $this->assertEquals('finished', $task->status); + } + + /** + * @test + */ + public function it_changes_two_models(): void + { + $user = factory(User::class)->create(); + $task1 = factory(Task::class)->create([ + 'user_id' => $user->id, + 'status' => 'wip', + ]); + $task2 = factory(Task::class)->create([ + 'user_id' => $user->id, + 'status' => 'open', + ]); + + $result = $user->tasks()->sync([ + $task1->id => ['status' => 'finished'], + $task2->id => ['status' => 'wip'], + ]); + + $this->assertAttached([], $result); + $this->assertChanged([$task1->id, $task2->id], $result); + + $task1->refresh(); + $task2->refresh(); + $this->assertModelEquals($user, $task1->user); + $this->assertEquals('finished', $task1->status); + $this->assertModelEquals($user, $task2->user); + $this->assertEquals('wip', $task2->status); + } + + /** + * @test + */ + public function it_detaches_a_model(): void + { + $user = factory(User::class)->create(); + $task = factory(Task::class)->create([ + 'user_id' => $user->id, + ]); + + $result = $user->tasks()->sync([]); + + $this->assertAttached([], $result); + $this->assertChanged([], $result); + $this->assertDetached([$task->id], $result); + + $task->refresh(); + $this->assertNull($task->user_id); + $this->assertNotNull($task->status); + $this->assertNotNull($task->priority); + $this->assertNotNull($task->name); + } + + /** + * @test + */ + public function it_detaches_one_of_two_models(): void + { + $user = factory(User::class)->create(); + $task1 = factory(Task::class)->create([ + 'user_id' => $user->id, + ]); + $task2 = factory(Task::class)->create([ + 'user_id' => $user->id, + ]); + + $result = $user->tasks()->sync([$task1->id]); + + $this->assertAttached([], $result); + $this->assertChanged([$task1->id], $result); + $this->assertDetached([$task2->id], $result); + + $task1->refresh(); + $task2->refresh(); + $this->assertModelEquals($user, $task1->user); + $this->assertNull($task2->user); + } + + /** + * @test + */ + public function it_set_default_data_after_detach(): void + { + $user = factory(User::class)->create(); + $task = factory(Task::class)->create([ + 'user_id' => $user->id, + 'status' => 'wip', + 'priority' => 1, + ]); + + $result = $user->tasks()->sync([], [ + 'set_after_detach' => [ + 'status' => 'open', + 'priority' => 0, + ], + ]); + + $this->assertAttached([], $result); + $this->assertChanged([], $result); + $this->assertDetached([$task->id], $result); + + $task->refresh(); + $this->assertNull($task->user_id); + $this->assertEquals('open', $task->status); + $this->assertEquals(0, $task->proirity); + } + + /** + * @test + */ + public function it_detaches_two_models(): void + { + $user = factory(User::class)->create(); + $task1 = factory(Task::class)->create([ + 'user_id' => $user->id, + ]); + $task2 = factory(Task::class)->create([ + 'user_id' => $user->id, + ]); + + $result = $user->tasks()->sync([]); + + $this->assertAttached([], $result); + $this->assertChanged([], $result); + $this->assertDetached([$task1->id, $task2->id], $result); + + $task1->refresh(); + $task2->refresh(); + + $this->assertNull($task1->user_id); + $this->assertNull($task2->user_id); + } + + /** + * @test + */ + public function it_attaches_a_model_and_detaches_another_one(): void + { + $user = factory(User::class)->create(); + $task1 = factory(Task::class)->create([ + 'user_id' => $user->id, + ]); + $task2 = factory(Task::class)->create(); + + $result = $user->tasks()->sync([$task2->id]); + + $this->assertAttached([$task2->id], $result); + $this->assertChanged([], $result); + $this->assertDetached([$task1->id], $result); + + $task1->refresh(); + $task2->refresh(); + $this->assertNull($task1->user_id); + $this->assertModelEquals($user, $task2->user); + } + + /** + * @test + */ + public function it_attaches_a_model_and_does_not_detach_when_detaching_is_false(): void + { + $user = factory(User::class)->create(); + $task1 = factory(Task::class)->create([ + 'user_id' => $user->id, + ]); + $task2 = factory(Task::class)->create(); + + $result = $user->tasks()->sync([$task2->id], [ + 'detaching' => false, + ]); + + $this->assertAttached([$task2->id], $result); + $this->assertChanged([], $result); + $this->assertDetached([], $result); + + $task1->refresh(); + $task2->refresh(); + $this->assertModelEquals($user, $task1->user); + $this->assertModelEquals($user, $task2->user); + } + + /** + * @test + */ + public function it_attaches_a_model_and_does_not_detach_when_using_syncWithoutDetaching(): void + { + $user = factory(User::class)->create(); + $task1 = factory(Task::class)->create([ + 'user_id' => $user->id, + ]); + $task2 = factory(Task::class)->create(); + + $result = $user->tasks()->syncWithoutDetaching([$task2->id]); + + $this->assertAttached([$task2->id], $result); + $this->assertChanged([], $result); + $this->assertDetached([], $result); + + $task1->refresh(); + $task2->refresh(); + $this->assertModelEquals($user, $task1->user); + $this->assertModelEquals($user, $task2->user); + } + + /** + * @test + */ + public function it_attaches_changes_and_detach_models(): void + { + $user = factory(User::class)->create(); + $task1 = factory(Task::class)->create([ + 'user_id' => $user->id, + 'status' => 'wip', + ]); + $task2 = factory(Task::class)->create([ + 'user_id' => $user->id, + 'status' => 'finished', + ]); + $task3 = factory(Task::class)->create([ + 'user_id' => $user->id, + 'status' => 'wip', + ]); + $task4 = factory(Task::class)->create(); + $task5 = factory(Task::class)->create(); + + $result = $user->tasks()->sync([ + $task1->id => ['status' => 'finished'], + $task2->id => ['status' => 'finished'], + $task4->id => ['status' => 'wip'], + $task5->id => ['status' => 'wip'], + ]); + + $this->assertAttached([$task4->id, $task5->id], $result); + $this->assertChanged([$task1->id, $task2->id], $result); + $this->assertDetached([$task3->id], $result); + + $task1->refresh(); + $task2->refresh(); + $task3->refresh(); + $task4->refresh(); + $task5->refresh(); + + $this->assertModelEquals($user, $task1->user); + $this->assertEquals('finished', $task1->status); + $this->assertModelEquals($user, $task2->user); + $this->assertEquals('finished', $task2->status); + $this->assertNull($task3->user); + $this->assertEquals('wip', $task3->status); + $this->assertModelEquals($user, $task4->user); + $this->assertEquals('wip', $task4->status); + $this->assertModelEquals($user, $task5->user); + $this->assertEquals('wip', $task5->status); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..c5e2135 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,78 @@ +loadMigrationsFrom([ + '--database' => 'testing', + '--path' => realpath('tests/migrations'), + ]); + + $this->withFactories(realpath('tests/factories')); + } + + protected function getPackageProviders($app) + { + return [ + SyncOneToManyServiceProvider::class, + ]; + } + + protected function getEnvironmentSetUp($app) + { + // Setup default database to use sqlite :memory: + $app['config']->set('database.default', 'testbench'); + $app['config']->set('database.connections.testbench', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + } + + protected function assertAttached(array $expectedIds, array $syncResult): void + { + $this->assertArrayHasKey('attached', $syncResult); + $this->assertArrayContainsExact($expectedIds, $syncResult['attached']); + } + + protected function assertChanged(array $expectedIds, array $syncResult): void + { + $this->assertArrayHasKey('updated', $syncResult); + $this->assertArrayContainsExact($expectedIds, $syncResult['updated']); + } + + protected function assertDetached(array $expectedIds, array $syncResult): void + { + $this->assertArrayHasKey('detached', $syncResult); + $this->assertArrayContainsExact($expectedIds, $syncResult['detached']); + } + + protected function assertArrayContainsExact(array $expected, array $passedIn): void + { + $this->assertIsArray($passedIn); + + $this->assertEquals( + count($expected), + count($passedIn), + sprintf('Failed that array size of %s matches array size of %s', count($passedIn), count($expected)) + ); + + foreach ($expected as $value) { + $this->assertTrue( + in_array($value, $passedIn), + sprintf('Failed that array contains %s.', $value) + ); + } + } +} diff --git a/tests/factories/TaskFactory.php b/tests/factories/TaskFactory.php new file mode 100644 index 0000000..c2261fa --- /dev/null +++ b/tests/factories/TaskFactory.php @@ -0,0 +1,15 @@ +define(Task::class, static function (Faker $faker) { + return [ + 'status' => 'open', + 'priority' => rand(0, 10), + 'name' => $faker->word, + ]; +}); diff --git a/tests/factories/UserFactory.php b/tests/factories/UserFactory.php new file mode 100644 index 0000000..e1c22ae --- /dev/null +++ b/tests/factories/UserFactory.php @@ -0,0 +1,12 @@ +define(User::class, static function (Faker $faker) { + return [ + ]; +}); diff --git a/tests/migrations/2020_03_02_134146_create_test_tables.php b/tests/migrations/2020_03_02_134146_create_test_tables.php new file mode 100644 index 0000000..565b514 --- /dev/null +++ b/tests/migrations/2020_03_02_134146_create_test_tables.php @@ -0,0 +1,31 @@ +bigIncrements('id'); + $table->timestamps(); + }); + Schema::create('tasks', static function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->bigInteger('user_id')->nullable(); + $table->string('status'); + $table->integer('priority'); + $table->string('name'); + $table->timestamps(); + + $table->foreign('user_id')->references('id')->on('users'); + }); + } + + public function down(): void + { + Schema::dropIfExists('users'); + } +} diff --git a/tlint.json b/tlint.json new file mode 100644 index 0000000..8ab8bad --- /dev/null +++ b/tlint.json @@ -0,0 +1,3 @@ +{ + "preset": "\\Elbgoods\\CiTestTools\\TlintPreset" +}