+
+
\ 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/
+
+
+
+
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 @@
+