diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6715f3c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +ij_php_line_comment_add_space = true +ij_php_line_comment_at_first_column = false +ij_shell_use_unix_line_separator = true +indent_size = 4 +indent_style = space +insert_final_newline = false +trim_trailing_whitespace = true +max_line_length = 170 +ij_visual_guides = 150 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc6f44f --- /dev/null +++ b/.gitignore @@ -0,0 +1,175 @@ +################################## +######## Encore Digital ######## +################################## +.encoredigital +.do + +################################## +######## Composer ######## +################################## +/vendor/ +composer.lock +auth.json +mpc + +################################## +######## Node & NPM ######## +################################## +node_modules/ +npm-debug.log +yarn-error.log + +################################## +######## Laravel ######## +################################## + +# Storage +/storage/*.key +/storage/doctum_docs + +# Environment +.env +.env.backup +.env.production +.env.development +.env.testing +.env.local +.env.staging +/.do + +# Resources +/resources/views/vendor/themes/ +/resources/doctum_docs + +# Public +/public/build +/public/hot +/public/storage +/public/css +/public/js +/public/vendor + +#JetBrains +.idea/laravel-idea-personal.xml +.idea/commandlinetools/Laravel_* +.idea/copilot/* + +################################## +######## Testing ######## +################################## +/.phpunit.cache +.phpunit.result.cache + +################################## +######## Legacy Laravel ######## +################################## +#Laravel 4 specific +bootstrap/compiled.php +app/storage/ + +#Laravel 5 & Lumen specific +public/storage +public/hot + +#Laravel 5 & Lumen specific with changed public path +public_html/storage +public_html/hot + +################################## +######## JetBrains ######## +################################## +#Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +#Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +#IDEs +/.fleet +.phpstorm.meta.php +_ide_helper.php + +#User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +#AWS User-specific +.idea/**/aws.xml + +#Generated files +.idea/**/contentModel.xml + +#Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +#Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +#CMake +cmake-build-*/ + +#Mongo Explorer plugin +.idea/**/mongoSettings.xml + +#File-based project format +*.iws + +#IntelliJ +out/ + +#mpeltonen/sbt-idea plugin +.idea_modules/ + +#JIRA plugin +atlassian-ide-plugin.xml + +#Cursive Clojure plugin +.idea/replstate.xml + +#SonarLint plugin +.idea/sonarlint/ + +#New Relic Plugin +.idea/codestream.xml + +#Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +#Editor-based Rest Client +.idea/httpRequests + +#Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +################################## +######## VS Code ######## +################################## +.vscode/* + +################################## +######## MacOS ######## +################################## +.DS_Store \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/codeception.xml b/.idea/codeception.xml new file mode 100644 index 0000000..330f2dd --- /dev/null +++ b/.idea/codeception.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/.idea/fileColors.xml b/.idea/fileColors.xml new file mode 100644 index 0000000..b548304 --- /dev/null +++ b/.idea/fileColors.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..8d66637 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/mergemodels.iml b/.idea/mergemodels.iml new file mode 100644 index 0000000..1f080aa --- /dev/null +++ b/.idea/mergemodels.iml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..7a996b2 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,53 @@ + + + + + + + + + Code smellPHP + + + Code stylePHP + + + Control flowPHP + + + DOM issuesJavaScript and TypeScript + + + GeneralJavaScript and TypeScript + + + GeneralPHP + + + HTTP Client + + + JavaScript and TypeScript + + + Oracle + + + PHP + + + Probable bugsPHP + + + Quality toolsPHP + + + + + Blade files + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..600266e --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/php-test-framework.xml b/.idea/php-test-framework.xml new file mode 100644 index 0000000..7c50033 --- /dev/null +++ b/.idea/php-test-framework.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/php.xml b/.idea/php.xml new file mode 100644 index 0000000..ae25b38 --- /dev/null +++ b/.idea/php.xml @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/phpspec.xml b/.idea/phpspec.xml new file mode 100644 index 0000000..c7cfbc2 --- /dev/null +++ b/.idea/phpspec.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/phpunit.xml b/.idea/phpunit.xml new file mode 100644 index 0000000..4f8104c --- /dev/null +++ b/.idea/phpunit.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..1c0a760 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,49 @@ +setFinder(PhpCsFixer::getFinder()) + ->setUsingCache(false) + ->registerCustomFixers([ + new CustomControllerOrderFixer(), + new CustomOrderedClassElementsFixer(), + new CustomPhpUnitOrderFixer(), + ]) + ->setRules([ + 'Tighten/custom_controller_order' => true, + 'Tighten/custom_ordered_class_elements' => [ + 'order' => [ + 'use_trait', + 'case', + 'property_public_static', + 'property_protected_static', + 'property_private_static', + 'constant_public', + 'constant_protected', + 'constant_private', + 'property_public', + 'property_protected', + 'property_private', + 'construct', + 'method:__invoke', + 'method_public_static', + 'method_protected_static', + 'method_private_static', + 'method_public', + 'method_protected', + 'method_private', + 'magic', + ], + ], + 'Tighten/custom_phpunit_order' => true, + ]); diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist new file mode 100644 index 0000000..4209b74 --- /dev/null +++ b/.phpcs.xml.dist @@ -0,0 +1,25 @@ + + + + + + src + tests + + + + + + + + + + + + + + + diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..31a4931 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,13 @@ +Copyright 2024 [Encore Digital Group](https://EncoreDigitalGroup.com) + +Copyright 2018 Ariel Vallese https://alariva.com + +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..e7149ad --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# ModelMerge Laravel package + +[![Latest Stable Version](https://poser.pugx.org/alariva/modelmerge/v/stable?format=flat)](https://packagist.org/packages/alariva/modelmerge) +[![Total Downloads](https://poser.pugx.org/alariva/modelmerge/downloads?format=flat)](https://packagist.org/packages/alariva/modelmerge) +[![Latest Unstable Version](https://poser.pugx.org/alariva/modelmerge/v/unstable?format=flat)](https://packagist.org/packages/alariva/modelmerge) +[![Build Status](https://travis-ci.org/alariva/laravel-modelmerge.svg?branch=master)](https://travis-ci.org/alariva/laravel-modelmerge) +[![Maintainability](https://api.codeclimate.com/v1/badges/f8829aab2f787e403d3e/maintainability)](https://codeclimate.com/github/alariva/laravel-modelmerge/maintainability) +[![Test Coverage](https://api.codeclimate.com/v1/badges/f8829aab2f787e403d3e/test_coverage)](https://codeclimate.com/github/alariva/laravel-modelmerge/test_coverage) +[![License](https://poser.pugx.org/alariva/modelmerge/license?format=flat)](https://packagist.org/packages/alariva/modelmerge) +[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Falariva%2Flaravel-modelmerge.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2Falariva%2Flaravel-modelmerge?ref=badge_shield) + +Easy merging for Eloquent Models. + +

+ +

+ +## Installation + +Via Composer + +``` bash +$ composer require alariva/modelmerge +``` + +## Usage + +```php + $modelA = SampleModel::make(['firstname' => 'John', 'age' => 33]); + $modelB = SampleModel::make(['firstname' => 'John', 'lastname' => 'Doe']); + + $mergedModel = ModelMerge::setModelA($modelA)->setModelB($modelB)->merge(); + + $mergedModel->firstname; // John + $mergedModel->lastname; // Doe + $mergedModel->age; // 33 +``` + +## Change log + +Please see the [changelog](changelog.md) for more information on what has changed recently. + +## Testing + +``` bash +$ composer test +``` + +## Contributing + +Please see [contributing.md](contributing.md) for details and a todolist. + +## Security + +If you discover any security related issues, please email author email instead of using the issue tracker. + +## Credits + +- [Ariel Vallese](https://alariva.com) +- Icons made by [Freepik](http://www.freepik.com) from [Flaticon](http://www.flaticon.com) is licensed by [CC 3.0 BY](http://creativecommons.org/licenses/by/3.0/) +- Icons made by [Roundicons](https://www.flaticon.com/authors/roundicons) from [Flaticon](http://www.flaticon.com) is licensed by [CC 3.0 BY](http://creativecommons.org/licenses/by/3.0/) + +## License + +MIT. Please see the [license file](license.md) for more information. + + +[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Falariva%2Flaravel-modelmerge.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2Falariva%2Flaravel-modelmerge?ref=badge_large) \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..f3cbc57 --- /dev/null +++ b/composer.json @@ -0,0 +1,72 @@ +{ + "name": "encoredigitalgroup/mergemodels", + "description": "A Laravel package for Merging Eloquent Models", + "homepage": "https://github.com/EncoreDigitalGroup/mergemodels", + "license": "MIT", + "keywords": [ + "Laravel", + "Merge Models" + ], + "authors": [ + { + "name": "Encore Digital Group", + "homepage": "https://EncoreDigitalGroup.com", + "role": "Maintainer" + }, + { + "name": "Ariel Vallese", + "email": "alariva@gmail.com", + "homepage": "https://alariva.com", + "role": "Original Developer" + } + ], + "config": { + "preferred-install": "dist", + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true, + "neronmoon/scriptsdev": true, + "phpstan/extension-installer": true + } + }, + "require": { + "illuminate/support": "^11.0", + "illuminate/database": "^11.0", + "phpgenesis/common": "^0.2" + }, + "require-dev": { + "larastan/larastan": "^2.9", + "orchestra/testbench": "^9.1", + "pestphp/pest": "^2.34", + "phpstan/extension-installer": "^1.4", + "rector/rector": "^1.1", + "tightenco/duster": "^3.0", + "tomasvotruba/cognitive-complexity": "^0.2.3", + "tomasvotruba/unused-public": "^0.3.7" + }, + "autoload": { + "psr-4": { + "EncoreDigitalGroup\\MergeModels\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "EncoreDigitalGroup\\MergeModels\\Tests\\": "tests/" + } + }, + "scripts": { + "post-autoload-dump": [ + "PHPGenesis\\Common\\Composer\\Scripts::postAutoloadDump" + ] + }, + "extra": { + "laravel": { + "providers": [ + "EncoreDigitalGroup\\MergeModels\\Providers\\MergeModelsServiceProvider" + ], + "aliases": { + "ModelMerge": "EncoreDigitalGroup\\MergeModels\\Facades\\ModelMerge" + } + } + } +} diff --git a/config/mergemodels.php b/config/mergemodels.php new file mode 100644 index 0000000..25058db --- /dev/null +++ b/config/mergemodels.php @@ -0,0 +1,5 @@ + + + + + ./tests/ + + + + + src/ + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..9760b45 --- /dev/null +++ b/pint.json @@ -0,0 +1,52 @@ +{ + "preset": "laravel", + "rules": { + "blank_line_between_import_groups": true, + "class_attributes_separation": { + "elements": { + "const": "none", + "method": "one", + "property": "none", + "trait_import": "none", + "case": "none" + } + }, + "concat_space": { + "spacing": "one" + }, + "explicit_string_variable": true, + "global_namespace_import": { + "import_classes": true, + "import_constants": true, + "import_functions": true + }, + "ordered_imports": { + "sort_algorithm": "alpha" + }, + "php_unit_test_annotation": { + "style": "annotation" + }, + "simple_to_complex_string_variable": true, + "blank_line_after_namespace": true, + "array_push": true, + "cast_spaces": true, + "braces_position": true, + "constant_case": true, + "indentation_type": true, + "line_ending": true, + "linebreak_after_opening_tag": true, + "lowercase_static_reference": true, + "method_argument_space": true, + "no_closing_tag": true, + "no_empty_comment": true, + "no_empty_phpdoc": true, + "no_spaces_after_function_name": true, + "no_useless_return": true, + "no_whitespace_in_blank_line": true, + "new_with_parentheses": true, + "new_with_braces": true, + "not_operator_with_successor_space": false, + "ternary_to_null_coalescing": true, + "single_blank_line_at_eof": false + } +} \ No newline at end of file diff --git a/readme.md b/readme.md deleted file mode 100644 index 3b94f91..0000000 --- a/readme.md +++ /dev/null @@ -1 +0,0 @@ -Placeholder diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..2d0e407 --- /dev/null +++ b/rector.php @@ -0,0 +1,68 @@ +withPaths([ + __DIR__ . '/src', + ]) + // uncomment to reach your current PHP version + // ->withPhpSets() + ->withRules([ + AddVoidReturnTypeWhereNoReturnRector::class, + ]) + ->withSkip([ + CallableThisArrayToAnonymousFunctionRector::class, + CompactToVariablesRector::class, + UseIdenticalOverEqualWithSameTypeRector::class, + LogicalToBooleanRector::class, + StaticClosureRector::class, + CatchExceptionNameMatchingTypeRector::class, + StaticArrowFunctionRector::class, + EncapsedStringsToSprintfRector::class, + PostIncDecToPreIncDecRector::class, + RenameForeachValueVariableToMatchExprVariableRector::class, + RenameParamToMatchTypeRector::class, + RenameVariableToMatchMethodCallReturnTypeRector::class, + ChangeAndIfToEarlyReturnRector::class, + SeparateMultiUseImportsRector::class, + RemoveExtraParametersRector::class, + NewlineAfterStatementRector::class, + BooleanInBooleanNotRuleFixerRector::class, + ]) + ->withPreparedSets( + deadCode: true, + codeQuality: true, + codingStyle: true, + typeDeclarations: true, + privatization: true, + naming: true, + instanceOf: true, + earlyReturn: true, + strictBooleans: true + ); diff --git a/src/Exceptions/ModelsBelongToDivergedParentsException.php b/src/Exceptions/ModelsBelongToDivergedParentsException.php new file mode 100644 index 0000000..e729c1c --- /dev/null +++ b/src/Exceptions/ModelsBelongToDivergedParentsException.php @@ -0,0 +1,11 @@ +useStrategy($strategy); + } + + /** + * Pick a strategy class for merge operation. + */ + public function useStrategy(?MergeModelStrategy $strategy = null): static + { + $this->strategy = $strategy instanceof MergeModelStrategy ? $strategy : new MergeModelSimple(); + + return $this; + } + + /** + * Set model A + * + * + * @return $this + */ + public function setBaseModel(Model $model): static + { + $this->baseModel = $model; + + return $this; + } + + public function getBaseModel(): Model + { + return $this->baseModel; + } + + public function getDuplicateModel(): Model + { + return $this->duplicateModel; + } + + public function getBase(): Model + { + return $this->getBaseModel(); + } + + public function getDuplicate(): Model + { + return $this->getDuplicateModel(); + } + + /** + * Set model B + */ + public function setDuplicateModel(Model $model): static + { + $this->duplicateModel = $model; + + return $this; + } + + /** + * Alias for setBaseModel + */ + public function setBase(Model $baseModel): static + { + $this->setBaseModel($baseModel); + + return $this; + } + + /** + * Alias for setDuplicateModel + */ + public function setDuplicate(Model $duplicateModel): static + { + $this->setDuplicateModel($duplicateModel); + + return $this; + } + + /** + * Specify a compound key to match models and verify identity. + * + * @param string|array $keys Keys that make the model identifiable + * @return $this + */ + public function withKey(string|array $keys): static + { + if (is_array($keys)) { + $this->keys = $keys; + } + + if (is_string($keys)) { + $this->keys = [$keys]; + } + + return $this; + } + + /** + * Executes the merge for base and duplicate models + */ + public function merge(): Model + { + $this->validateKeys(); + + $this->validateBelongsToSameParent(); + + $this->transferRelationships(); + + if (is_null($this->strategy)) { + throw new LogicException('Strategy must not be null'); + } + + return $this->strategy->merge($this->baseModel, $this->duplicateModel); + } + + /** + * Executes the merge and performs save/delete accordingly to preserve base and discard dupe + */ + public function unifyOnBase(): Model + { + $mergeModel = $this->merge(); + + $this->baseModel->fill($mergeModel->toArray()); + + $this->baseModel->save(); + + $this->duplicateModel->delete(); + + return $this->baseModel; + } + + /** + * Prefer the oldest of the models to be preserved + */ + public function preferOldest(): static + { + //@phpstan-ignore-next-line + if ($this->duplicateModel->created_at < $this->baseModel->created_at) { + $this->swapPriority(); + } + + return $this; + } + + /** + * Prefer the newest of the models to be preserved + */ + public function preferNewest(): static + { + // @phpstan-ignore-next-line + if ($this->duplicateModel->created_at > $this->baseModel->created_at) { + $this->swapPriority(); + } + + return $this; + } + + /** + * Swap models from base to dupe and vice versa + */ + public function swapPriority(): static + { + $tmp = $this->baseModel; + + $this->baseModel = $this->duplicateModel; + $this->duplicateModel = $tmp; + + return $this; + } + + public function belongsTo(?string $belongsTo = null): static + { + $this->belongsTo = $belongsTo; + + return $this; + } + + /** + * Alias for belongsTo + */ + public function mustBelongToSame(?string $belongsTo = null): static + { + return $this->belongsTo($belongsTo); + } + + public function withRelationships(array $relationships): static + { + $this->relationships = $relationships; + + return $this; + } + + public function transferRelationships(): void + { + foreach ($this->relationships as $relationship) { + $this->transferChilds($relationship); + } + } + + public function transferChilds(mixed $relationship): void + { + foreach ($this->duplicateModel->$relationship as $child) { + $this->baseModel->$relationship()->save($child); + } + } + + protected function validateKeys(): void + { + if ($this->keys === null) { + return; + } + + $dataA = $this->baseModel->only($this->keys); + $dataB = $this->duplicateModel->only($this->keys); + + if ($dataA != $dataB) { + throw new ModelsNotDupeException('Models are not dupes', 1); + } + } + + protected function validateBelongsToSameParent(): void + { + if ($this->belongsTo === null) { + return; + } + + if ($this->baseModel->{$this->belongsTo} != $this->duplicateModel->{$this->belongsTo}) { + throw new ModelsBelongToDivergedParentsException('Models do not belong to same parent', 1); + } + } +} diff --git a/src/Providers/MergeModelsServiceProvider.php b/src/Providers/MergeModelsServiceProvider.php new file mode 100644 index 0000000..70f2dc7 --- /dev/null +++ b/src/Providers/MergeModelsServiceProvider.php @@ -0,0 +1,50 @@ +app->runningInConsole()) { + + // Publishing the configuration file. + $this->publishes([ + __DIR__ . '/../../config/mergemodels.php' => config_path('mergemodels.php'), + ], 'mergemodels.config'); + + // Registering package commands. + // $this->commands([]); + } + } + + /** + * Register any package services. + */ + public function register(): void + { + $this->mergeConfigFrom(__DIR__ . '/../../config/mergemodels.php', 'mergemodels'); + + // Register the service the package provides. + $this->app->singleton('mergemodels', function ($app): \EncoreDigitalGroup\MergeModels\ModelMerge { + return new ModelMerge(); + }); + } + + /** + * Get the services provided by the provider. + * + * @return array + */ + public function provides() + { + return ['mergemodels']; + } +} \ No newline at end of file diff --git a/src/Strategies/MergeModelSimple.php b/src/Strategies/MergeModelSimple.php new file mode 100644 index 0000000..938b523 --- /dev/null +++ b/src/Strategies/MergeModelSimple.php @@ -0,0 +1,21 @@ +toArray(); + $dataB = $modelB->toArray(); + + $dataMerge = array_merge($dataB, $dataA); + + $modelA->fill($dataMerge); + + return $modelA; + } +} diff --git a/src/Strategies/MergeModelStrategy.php b/src/Strategies/MergeModelStrategy.php new file mode 100644 index 0000000..4aebdfe --- /dev/null +++ b/src/Strategies/MergeModelStrategy.php @@ -0,0 +1,10 @@ +in('Feature'); + +/* +|-------------------------------------------------------------------------- +| Expectations +|-------------------------------------------------------------------------- +| +| When you're writing tests, you often need to check that values meet certain conditions. The +| "expect()" function gives you access to a set of "expectations" methods that you can use +| to assert different things. Of course, you may extend the Expectation API at any time. +| +*/ + +expect()->extend('toBeOne', function () { + return $this->toBe(1); +}); + +/* +|-------------------------------------------------------------------------- +| Functions +|-------------------------------------------------------------------------- +| +| While Pest is very powerful out-of-the-box, you may have some testing code specific to your +| project that you don't want to repeat in every file. Here you can also expose helpers as +| global functions to help you to reduce the number of lines of code in your test files. +| +*/ + +function something() +{ + // .. +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..86c3a96 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,10 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + + Capsule::schema()->create('dummy_contacts', function (Blueprint $table) { + $table->increments('id'); + $table->string('firstname'); + $table->string('lastname'); + $table->string('age')->nullable(); + $table->string('eyes')->nullable(); + $table->string('phone')->nullable(); + $table->string('address')->nullable(); + $table->timestamps(); + }); + + Capsule::schema()->create('dummy_sheep', function (Blueprint $table) { + $table->increments('id'); + $table->integer('dummy_contact_id'); + $table->string('name'); + $table->string('color')->nullable(); + $table->timestamps(); + }); + } + + protected function getPackageProviders($app): array + { + return [ + MergeModelsServiceProvider::class, + ]; + } +} diff --git a/tests/Unit/DummyContact.php b/tests/Unit/DummyContact.php new file mode 100644 index 0000000..9ce6f21 --- /dev/null +++ b/tests/Unit/DummyContact.php @@ -0,0 +1,22 @@ +hasMany(DummySheep::class); + } +} diff --git a/tests/Unit/DummySheep.php b/tests/Unit/DummySheep.php new file mode 100644 index 0000000..2ed66f8 --- /dev/null +++ b/tests/Unit/DummySheep.php @@ -0,0 +1,22 @@ +belongsTo(DummyContact::class, 'dummy_contact_id'); + } +} diff --git a/tests/Unit/ModelMergeFacadeTest.php b/tests/Unit/ModelMergeFacadeTest.php new file mode 100644 index 0000000..dca3566 --- /dev/null +++ b/tests/Unit/ModelMergeFacadeTest.php @@ -0,0 +1,19 @@ + 'John', 'age' => 33]); + $modelB = DummyContact::make(['firstname' => 'John', 'lastname' => 'Doe']); + + $mergedModel = MergeModels::setBaseModel($modelA)->setDuplicateModel($modelB)->merge(); + + $this->assertInstanceOf(Model::class, $mergedModel, 'Merged model should extend an Eloquent Model'); + } +} diff --git a/tests/Unit/ModelMergeRelationshipsTest.php b/tests/Unit/ModelMergeRelationshipsTest.php new file mode 100644 index 0000000..5ed4ce0 --- /dev/null +++ b/tests/Unit/ModelMergeRelationshipsTest.php @@ -0,0 +1,121 @@ + 'John', + 'lastname' => 'Doe', + 'age' => 33, + 'phone' => '+1 123 456 789', + 'created_at' => Carbon::now(), + ]); + $newestModel = DummyContact::create(['firstname' => 'John', + 'lastname' => 'Doe', + 'age' => 33, + 'created_at' => Carbon::now()->addDay(), + ]); + + $sheepDolly = DummySheep::make(['name' => 'Dolly', 'color' => 'white']); + $sheepMolly = DummySheep::make(['name' => 'Molly', 'color' => 'gray']); + $sheepRoberta = DummySheep::make(['name' => 'Roberta', 'color' => 'black']); + + $newestModel->sheeps()->save($sheepDolly); + $newestModel->sheeps()->save($sheepMolly); + $newestModel->sheeps()->save($sheepRoberta); + + $modelMerge = new ModelMerge(); + + $this->assertEquals($oldestModel->sheeps()->count(), 0); + $this->assertEquals($newestModel->sheeps()->count(), 3); + + $mergedModel = $modelMerge->withRelationships(['sheeps']) + ->setBase($oldestModel) + ->setDuplicate($newestModel) + ->merge(); + + // Merge was correct + $this->assertEquals($mergedModel->firstname, 'John'); + $this->assertEquals($mergedModel->lastname, 'Doe'); + $this->assertEquals($mergedModel->age, 33); + $this->assertEquals($mergedModel->phone, '+1 123 456 789'); + + // Child models were transferred + $this->assertEquals($oldestModel->sheeps()->count(), 3); + $this->assertEquals($newestModel->sheeps()->count(), 0); + + // And total count of child models was preserved + $this->assertEquals(DummySheep::count(), 3); + } + + public function test_it_aborts_merge_on_belong_to_diverge() + { + $shepherdJohn = DummyContact::create(['firstname' => 'John', + 'lastname' => 'Doe',]); + $shepherdMatt = DummyContact::create(['firstname' => 'Matt', + 'lastname' => 'Power',]); + + $sheepWhiteDolly = DummySheep::make(['name' => 'Dolly', 'color' => 'white']); + $sheepBlackDolly = DummySheep::make(['name' => 'Dolly', 'color' => 'black']); + + $shepherdJohn->sheeps()->save($sheepWhiteDolly); + $shepherdMatt->sheeps()->save($sheepBlackDolly); + + $modelMerge = new ModelMerge(); + + $this->assertEquals($shepherdJohn->sheeps()->count(), 1); + $this->assertEquals($shepherdMatt->sheeps()->count(), 1); + + $this->expectException(ModelsBelongToDivergedParentsException::class); + + $mergedModel = $modelMerge->mustBelongToSame('owner') + ->setBase($sheepWhiteDolly) + ->setDuplicate($sheepBlackDolly) + ->unifyOnBase(); + + // Relationships were untouched + $this->assertEquals($shepherdJohn->sheeps()->count(), 1); + $this->assertEquals($shepherdMatt->sheeps()->count(), 1); + + // And total count of child models was preserved + $this->assertEquals(DummySheep::count(), 2); + } + + public function test_it_merges_on_belong_to_same_parent() + { + $shepherd = DummyContact::create(['firstname' => 'John', + 'lastname' => 'Doe',]); + + $sheepBaseDolly = DummySheep::make(['name' => 'Dolly', 'color' => 'white']); + $sheepDupeDolly = DummySheep::make(['name' => 'Dolly', 'color' => 'white']); + + $shepherd->sheeps()->save($sheepBaseDolly); + $shepherd->sheeps()->save($sheepDupeDolly); + + $modelMerge = new ModelMerge(); + + $this->assertEquals($shepherd->sheeps()->count(), 2); + + $mergedModel = $modelMerge->mustBelongToSame('owner') + ->setBase($sheepBaseDolly) + ->setDuplicate($sheepDupeDolly) + ->unifyOnBase(); + + // Merge was correct + $this->assertEquals($mergedModel->name, 'Dolly'); + $this->assertEquals($mergedModel->color, 'white'); + $this->assertEquals($mergedModel->owner->id, $shepherd->id); + + // Relationships were untouched + $this->assertEquals($shepherd->sheeps()->count(), 1); + + // And total count of child models was preserved + $this->assertEquals(DummySheep::count(), 1); + } +} diff --git a/tests/Unit/ModelMergeStrategiesTest.php b/tests/Unit/ModelMergeStrategiesTest.php new file mode 100644 index 0000000..7d1c566 --- /dev/null +++ b/tests/Unit/ModelMergeStrategiesTest.php @@ -0,0 +1,23 @@ + 'John', 'age' => 33]); + $modelB = DummyContact::make(['firstname' => 'John', 'lastname' => 'Doe']); + + $modelMerge = new ModelMerge(new MergeModelSimple); + $modelMerge->setBaseModel($modelA)->setDuplicateModel($modelB); + $mergedModel = $modelMerge->merge(); + + $this->assertEquals($mergedModel->firstname, 'John'); + $this->assertEquals($mergedModel->lastname, 'Doe'); + $this->assertEquals($mergedModel->age, 33); + } +} diff --git a/tests/Unit/ModelMergeTest.php b/tests/Unit/ModelMergeTest.php new file mode 100644 index 0000000..413e271 --- /dev/null +++ b/tests/Unit/ModelMergeTest.php @@ -0,0 +1,208 @@ + 'John']); + $modelB = DummyContact::make(['firstname' => 'John', 'lastname' => 'Doe']); + + $modelMerge = new ModelMerge(); + $modelMerge->setBaseModel($modelA)->setDuplicateModel($modelB); + $mergedModel = $modelMerge->merge(); + + $this->assertInstanceOf(Model::class, $mergedModel, 'Merged model should extend an Eloquent Model'); + } + + public function test_it_provides_getter_for_base() + { + $modelA = DummyContact::make(['firstname' => 'John']); + $modelB = DummyContact::make(['firstname' => 'John', 'lastname' => 'Doe']); + + $modelMerge = new ModelMerge(); + $modelMerge->setBaseModel($modelA)->setDuplicateModel($modelB); + $baseModel = $modelMerge->getBase(); + + $this->assertInstanceOf(Model::class, $baseModel); + $this->assertEquals($modelA, $baseModel); + } + + public function test_it_provides_getter_for_dupe() + { + $modelA = DummyContact::make(['firstname' => 'John']); + $modelB = DummyContact::make(['firstname' => 'John', 'lastname' => 'Doe']); + + $modelMerge = new ModelMerge(); + $modelMerge->setBaseModel($modelA)->setDuplicateModel($modelB); + $dupeModel = $modelMerge->getDuplicate(); + + $this->assertInstanceOf(Model::class, $dupeModel); + $this->assertEquals($modelB, $dupeModel); + } + + public function test_it_performs_a_valid_merge() + { + $modelA = DummyContact::make(['firstname' => 'John', 'age' => 33]); + $modelB = DummyContact::make(['firstname' => 'John', 'lastname' => 'Doe']); + + $modelMerge = new ModelMerge(); + $modelMerge->setBaseModel($modelA)->setDuplicateModel($modelB); + $mergedModel = $modelMerge->merge(); + + $this->assertEquals($mergedModel->firstname, 'John'); + $this->assertEquals($mergedModel->lastname, 'Doe'); + $this->assertEquals($mergedModel->age, 33); + } + + public function test_it_allows_an_external_strategy_class() + { + $modelA = DummyContact::make(['firstname' => 'John', 'age' => 33]); + $modelB = DummyContact::make(['firstname' => 'John', 'lastname' => 'Doe']); + + $strategy = new MergeModelSimple(); + + $modelMerge = new ModelMerge($strategy); + $modelMerge->setBaseModel($modelA)->setDuplicateModel($modelB); + $mergedModel = $modelMerge->merge(); + + $this->assertEquals($mergedModel->firstname, 'John'); + $this->assertEquals($mergedModel->lastname, 'Doe'); + $this->assertEquals($mergedModel->age, 33); + } + + public function test_it_verifies_nok_identity_compound_key_before_merge() + { + $modelA = DummyContact::make(['firstname' => 'John', 'lastname' => 'Doe', 'age' => 33]); + $modelB = DummyContact::make(['firstname' => 'John', 'lastname' => 'Other', 'age' => 33, 'phone' => '+1 123 456 789']); + + $modelMerge = new ModelMerge(new MergeModelSimple()); + $modelMerge->withKey(['firstname', 'lastname', 'age'])->setBaseModel($modelA)->setDuplicateModel($modelB); + + $this->expectException(\EncoreDigitalGroup\MergeModels\Exceptions\ModelsNotDupeException::class); + + $mergedModel = $modelMerge->merge(); + } + + public function test_it_verifies_nok_identity_compound_key_before_merge_passed_as_string() + { + $modelA = DummyContact::make(['firstname' => 'John', 'lastname' => 'Doe', 'age' => 33]); + $modelB = DummyContact::make(['firstname' => 'John', 'lastname' => 'Other', 'age' => 33, 'phone' => '+1 123 456 789']); + + $modelMerge = new ModelMerge(new MergeModelSimple()); + $modelMerge->withKey('lastname')->setBaseModel($modelA)->setDuplicateModel($modelB); + + $this->expectException(\EncoreDigitalGroup\MergeModels\Exceptions\ModelsNotDupeException::class); + + $mergedModel = $modelMerge->merge(); + } + + public function test_it_verifies_ok_identity_compound_key_before_merge_passed_as_string() + { + $modelA = DummyContact::make(['firstname' => 'John', 'lastname' => 'Doe', 'age' => 33]); + $modelB = DummyContact::make(['firstname' => 'John', 'lastname' => 'Doe', 'age' => 33, 'phone' => '+1 123 456 789']); + + $modelMerge = new ModelMerge(new MergeModelSimple()); + $modelMerge->withKey('lastname')->setBaseModel($modelA)->setDuplicateModel($modelB); + + $mergedModel = $modelMerge->merge(); + + $this->assertEquals($mergedModel->firstname, 'John'); + $this->assertEquals($mergedModel->lastname, 'Doe'); + $this->assertEquals($mergedModel->age, 33); + } + + public function test_it_saves_the_merged_model() + { + $baseModel = DummyContact::create(['firstname' => 'John', 'lastname' => 'Doe', 'age' => 33]); + $dupeModel = DummyContact::create(['firstname' => 'John', 'lastname' => 'Doe', 'age' => 33, 'phone' => '+1 123 456 789']); + + $modelMerge = new ModelMerge(); + $baseModel = $modelMerge->setBase($baseModel)->setDuplicate($dupeModel)->unifyOnBase(); + + // Merge was correct + $this->assertEquals($baseModel->firstname, 'John'); + $this->assertEquals($baseModel->lastname, 'Doe'); + $this->assertEquals($baseModel->age, 33); + $this->assertEquals($baseModel->phone, '+1 123 456 789'); + + // Base was saved and dupe was deleted + $this->assertEquals(true, $baseModel->exists); + $this->assertEquals(false, $dupeModel->exists); + } + + public function test_it_can_prefer_newest_record() + { + $oldestModel = DummyContact::create(['firstname' => 'John', + 'lastname' => 'Doe', + 'age' => 33, + 'created_at' => Carbon::now(), + ]); + $newestModel = DummyContact::create(['firstname' => 'John', + 'lastname' => 'Doe', + 'age' => 34, + 'phone' => '+1 123 456 789', + 'created_at' => Carbon::now()->addDay(), + ]); + + $modelMerge = new ModelMerge(); + + $modelA = $modelMerge->setBase($oldestModel)->setDuplicate($newestModel)->preferNewest()->getBase(); + + // Merge was correct + $this->assertEquals($modelA->firstname, 'John'); + $this->assertEquals($modelA->lastname, 'Doe'); + $this->assertEquals($modelA->age, 34); + } + + public function test_it_can_prefer_oldest_record() + { + $oldestModel = DummyContact::create(['firstname' => 'John', + 'lastname' => 'Doe', + 'age' => 33, + 'created_at' => Carbon::now(),]); + $newestModel = DummyContact::create(['firstname' => 'John', + 'lastname' => 'Doe', + 'age' => 34, + 'phone' => '+1 123 456 789', + 'created_at' => Carbon::now()->addDay(),]); + + $modelMerge = new ModelMerge(); + + $modelA = $modelMerge->setBase($oldestModel)->setDuplicate($newestModel)->preferOldest()->getBase(); + + // Merge was correct + $this->assertEquals($modelA->firstname, 'John'); + $this->assertEquals($modelA->lastname, 'Doe'); + $this->assertEquals($modelA->age, 33); + } + + public function test_it_merges_correctly_after_swap() + { + $oldestModel = DummyContact::create(['firstname' => 'John', + 'lastname' => 'Doe', + 'age' => 33, + 'phone' => '+1 123 456 789', + 'created_at' => Carbon::now(),]); + $newestModel = DummyContact::create(['firstname' => 'John', + 'lastname' => 'Doe', + 'age' => 34, + 'created_at' => Carbon::now()->addDay(),]); + + $modelMerge = new ModelMerge(); + + $mergedModel = $modelMerge->setBase($oldestModel)->setDuplicate($newestModel)->swapPriority()->merge(); + + // Merge was correct + $this->assertEquals($mergedModel->firstname, 'John'); + $this->assertEquals($mergedModel->lastname, 'Doe'); + $this->assertEquals($mergedModel->age, 34); + $this->assertEquals($mergedModel->phone, '+1 123 456 789'); + } +} diff --git a/tests/Unit/bootstrap.php b/tests/Unit/bootstrap.php new file mode 100644 index 0000000..ee315a0 --- /dev/null +++ b/tests/Unit/bootstrap.php @@ -0,0 +1,13 @@ +