From 0d7c5a9ecec641fee31c307d7daaa162b9f10f45 Mon Sep 17 00:00:00 2001 From: Sam Minnee Date: Thu, 2 Jul 2020 14:44:27 +1200 Subject: [PATCH] NEW Add/remove callbacks on RelationList MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This provides a mechanism for adjusting the behaviour of these relations when building more complex data models. For example the following example has a status field incorporates a Status field into the relationship: ```php function MyRelation() { $rel = $this->getManyManyComponents(‘MyRelation’); $rel = $rel->filter(‘Status’, ‘Active’); $rel->addCallbacks()->add(function ($relation, $item, $extra) { $item->Status = ‘Active’; $item->write(); }); } ``` Introduces a new library dependency: http://github.com/sminnee/callbacklist --- composer.json | 1 + src/ORM/HasManyList.php | 8 ++ src/ORM/ManyManyList.php | 29 ++++++- src/ORM/ManyManyThroughList.php | 15 ++++ src/ORM/RelationList.php | 74 ++++++++++++++++++ tests/php/ORM/HasManyListTest.php | 76 ++++++++++++++++++ tests/php/ORM/ManyManyListTest.php | 91 ++++++++++++++++++++++ tests/php/ORM/ManyManyThroughListTest.php | 94 +++++++++++++++++++++++ 8 files changed, 387 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index c7513cc79..473d82a15 100644 --- a/composer.json +++ b/composer.json @@ -35,6 +35,7 @@ "silverstripe/config": "^1@dev", "silverstripe/assets": "^1@dev", "silverstripe/vendor-plugin": "^1.4", + "sminnee/callbacklist": "^0.1", "swiftmailer/swiftmailer": "~5.4", "symfony/cache": "^3.3@dev", "symfony/config": "^3.2", diff --git a/src/ORM/HasManyList.php b/src/ORM/HasManyList.php index a8728ab9a..13fcbacaa 100644 --- a/src/ORM/HasManyList.php +++ b/src/ORM/HasManyList.php @@ -93,6 +93,10 @@ class HasManyList extends RelationList $item->$foreignKey = $foreignID; $item->write(); + + if ($this->addCallbacks) { + $this->addCallbacks->call($this, $item, []); + } } /** @@ -136,5 +140,9 @@ class HasManyList extends RelationList $item->$foreignKey = null; $item->write(); } + + if ($this->removeCallbacks) { + $this->removeCallbacks->call($this, [$item->ID]); + } } } diff --git a/src/ORM/ManyManyList.php b/src/ORM/ManyManyList.php index 28f2e11b5..9f185e085 100644 --- a/src/ORM/ManyManyList.php +++ b/src/ORM/ManyManyList.php @@ -322,6 +322,10 @@ class ManyManyList extends RelationList DB::manipulate($manipulation); } + + if ($this->addCallbacks) { + $this->addCallbacks->call($this, $item, $extraFields); + } } /** @@ -338,7 +342,9 @@ class ManyManyList extends RelationList throw new InvalidArgumentException("ManyManyList::remove() expecting a $this->dataClass object"); } - return $this->removeByID($item->ID); + $result = $this->removeByID($item->ID); + + return $result; } /** @@ -366,7 +372,13 @@ class ManyManyList extends RelationList $query->addWhere([ "\"{$this->joinTable}\".\"{$this->localKey}\"" => $itemID ]); + + // Perform the deletion $query->execute(); + + if ($this->removeCallbacks) { + $this->removeCallbacks->call($this, [$itemID]); + } } /** @@ -403,7 +415,22 @@ class ManyManyList extends RelationList $delete->addWhere([ "\"{$this->joinTable}\".\"{$this->localKey}\" IN ($subSelect)" => $parameters ]); + + $affectedIds = []; + if ($this->removeCallbacks) { + $affectedIds = $delete + ->toSelect() + ->setSelect("\"{$this->joinTable}\".\"{$this->localKey}\"") + ->execute() + ->column(); + } + + // Perform the deletion $delete->execute(); + + if ($this->removeCallbacks && $affectedIds) { + $this->removeCallbacks->call($this, $affectedIds); + } } /** diff --git a/src/ORM/ManyManyThroughList.php b/src/ORM/ManyManyThroughList.php index 93160703a..3f2b41c91 100644 --- a/src/ORM/ManyManyThroughList.php +++ b/src/ORM/ManyManyThroughList.php @@ -130,20 +130,31 @@ class ManyManyThroughList extends RelationList // Find has_many row with a local key matching the given id $hasManyList = $this->manipulator->getParentRelationship($this->dataQuery()); $records = $hasManyList->filter($this->manipulator->getLocalKey(), $itemID); + $affectedIds = []; // Rather than simple un-associating the record (as in has_many list) // Delete the actual mapping row as many_many deletions behave. /** @var DataObject $record */ foreach ($records as $record) { + $affectedIds[] = $record->ID; $record->delete(); } + + if ($this->removeCallbacks && $affectedIds) { + $this->removeCallbacks->call($this, $affectedIds); + } } public function removeAll() { // Empty has_many table matching the current foreign key $hasManyList = $this->manipulator->getParentRelationship($this->dataQuery()); + $affectedIds = $hasManyList->column('ID'); $hasManyList->removeAll(); + + if ($this->removeCallbacks && $affectedIds) { + $this->removeCallbacks->call($this, $affectedIds); + } } /** @@ -222,6 +233,10 @@ class ManyManyThroughList extends RelationList if ($item instanceof DataObject) { $item->setJoin($record, $this->manipulator->getJoinAlias()); } + + if ($this->addCallbacks) { + $this->addCallbacks->call($this, $item, $extraFields); + } } /** diff --git a/src/ORM/RelationList.php b/src/ORM/RelationList.php index 9a50be520..d6fb5a18a 100644 --- a/src/ORM/RelationList.php +++ b/src/ORM/RelationList.php @@ -3,6 +3,7 @@ namespace SilverStripe\ORM; use Exception; +use Sminnee\CallbackList\CallbackList; /** * A DataList that represents a relation. @@ -11,6 +12,79 @@ use Exception; */ abstract class RelationList extends DataList implements Relation { + /** + * @var CallbackList|null + */ + protected $addCallbacks; + + /** + * @var CallbackList|null + */ + protected $removeCallbacks; + + /** + * Manage callbacks which are called after the add() action is completed. + * Each callback will be passed (RelationList $this, DataObject|int $item, array $extraFields). + * If a relation methods is manually defined, this can be called to adjust the behaviour + * when adding records to this list. + * + * Needs to be defined through an overloaded relationship getter + * to ensure it is set consistently. These getters return a new object + * every time they're called. + * + * Note that subclasses of RelationList must implement the callback for it to function + * + * @return this + */ + public function addCallbacks(): CallbackList + { + if (!$this->addCallbacks) { + $this->addCallbacks = new CallbackList(); + } + + return $this->addCallbacks; + } + + /** + * Manage callbacks which are called after the remove() action is completed. + * Each Callback will be passed (RelationList $this, [int] $removedIds). + * + * Needs to be defined through an overloaded relationship getter + * to ensure it is set consistently. These getters return a new object + * every time they're called. Example: + * + * ```php + * class MyObject extends DataObject() + * { + * private static $many_many = [ + * 'MyRelationship' => '...', + * ]; + * public function MyRelationship() + * { + * $list = $this->getManyManyComponents('MyRelationship'); + * $list->removeCallbacks()->add(function ($removedIds) { + * // ... + * }); + * return $list; + * } + * } + * ``` + * + * If a relation methods is manually defined, this can be called to adjust the behaviour + * when adding records to this list. + * + * Subclasses of RelationList must implement the callback for it to function + * + * @return this + */ + public function removeCallbacks(): CallbackList + { + if (!$this->removeCallbacks) { + $this->removeCallbacks = new CallbackList(); + } + + return $this->removeCallbacks; + } /** * Any number of foreign keys to apply to this list diff --git a/tests/php/ORM/HasManyListTest.php b/tests/php/ORM/HasManyListTest.php index 61d42178e..83714ad02 100644 --- a/tests/php/ORM/HasManyListTest.php +++ b/tests/php/ORM/HasManyListTest.php @@ -3,6 +3,7 @@ namespace SilverStripe\ORM\Tests; use SilverStripe\Dev\SapphireTest; +use SilverStripe\ORM\Tests\DataObjectTest\Player; use SilverStripe\ORM\Tests\DataObjectTest\Team; use SilverStripe\ORM\Tests\HasManyListTest\Company; use SilverStripe\ORM\Tests\HasManyListTest\CompanyCar; @@ -113,4 +114,79 @@ class HasManyListTest extends SapphireTest ['Model' => 'F40'], ], $company->CompanyCars()->sort('"Model" ASC')); } + + public function testCallbackOnSetById() + { + $addedIds = []; + $removedIds = []; + + $base = $this->objFromFixture(Company::class, 'silverstripe'); + $relation = $base->Employees(); + $remove = $relation->First(); + $add = new Employee(); + $add->write(); + + $relation->addCallbacks()->add(function ($list, $item) use (&$addedIds) { + $addedIds[] = $item; + }); + + $relation->removeCallbacks()->add(function ($list, $ids) use (&$removedIds) { + $removedIds = $ids; + }); + + $relation->setByIDList(array_merge( + $base->Employees()->exclude('ID', $remove->ID)->column('ID'), + [$add->ID] + )); + $this->assertEquals([$remove->ID], $removedIds); + } + + public function testAddCallback() + { + $added = []; + + $base = $this->objFromFixture(Company::class, 'silverstripe'); + $relation = $base->Employees(); + $add = new Employee(); + $add->write(); + + $relation->addCallbacks()->add(function ($list, $item) use (&$added) { + $added[] = $item; + }); + + $relation->add($add); + $this->assertEquals([$add], $added); + } + + public function testRemoveCallbackOnRemove() + { + $removedIds = []; + + $base = $this->objFromFixture(Company::class, 'silverstripe'); + $relation = $base->Employees(); + $remove = $relation->First(); + + $relation->removeCallbacks()->add(function ($list, $ids) use (&$removedIds) { + $removedIds = $ids; + }); + + $relation->remove($remove); + $this->assertEquals([$remove->ID], $removedIds); + } + + public function testRemoveCallbackOnRemoveById() + { + $removedIds = []; + + $base = $this->objFromFixture(Company::class, 'silverstripe'); + $relation = $base->Employees(); + $remove = $relation->First(); + + $relation->removeCallbacks()->add(function ($list, $ids) use (&$removedIds) { + $removedIds = $ids; + }); + + $relation->removeByID($remove->ID); + $this->assertEquals([$remove->ID], $removedIds); + } } diff --git a/tests/php/ORM/ManyManyListTest.php b/tests/php/ORM/ManyManyListTest.php index 75babb340..373105289 100644 --- a/tests/php/ORM/ManyManyListTest.php +++ b/tests/php/ORM/ManyManyListTest.php @@ -457,4 +457,95 @@ class ManyManyListTest extends SapphireTest 'ManyManyDynamicField' => false, ]); } + + public function testCallbackOnSetById() + { + $addedIds = []; + $removedIds = []; + + $base = $this->objFromFixture(Team::class, 'team1'); + $relation = $base->Players(); + $remove = $relation->First(); + $add = new Player(); + $add->write(); + + $relation->addCallbacks()->add(function ($list, $item, $extraFields) use (&$removedIds) { + $addedIds[] = $item; + }); + + $relation->removeCallbacks()->add(function ($list, $ids) use (&$removedIds) { + $removedIds = $ids; + }); + + $relation->setByIDList(array_merge( + $base->Players()->exclude('ID', $remove->ID)->column('ID'), + [$add->ID] + )); + $this->assertEquals([$remove->ID], $removedIds); + } + + public function testAddCallbackWithExtraFields() + { + $added = []; + + $base = $this->objFromFixture(Team::class, 'team1'); + $relation = $base->Players(); + $add = new Player(); + $add->write(); + + $relation->addCallbacks()->add(function ($list, $item, $extraFields) use (&$added) { + $added[] = [$item, $extraFields]; + }); + + $relation->add($add, ['Position' => 'Quarterback']); + $this->assertEquals([[$add, ['Position' => 'Quarterback']]], $added); + } + + public function testRemoveCallbackOnRemove() + { + $removedIds = []; + + $base = $this->objFromFixture(Team::class, 'team1'); + $relation = $base->Players(); + $remove = $relation->First(); + + $relation->removeCallbacks()->add(function ($list, $ids) use (&$removedIds) { + $removedIds = $ids; + }); + + $relation->remove($remove); + $this->assertEquals([$remove->ID], $removedIds); + } + + public function testRemoveCallbackOnRemoveById() + { + $removedIds = []; + + $base = $this->objFromFixture(Team::class, 'team1'); + $relation = $base->Players(); + $remove = $relation->First(); + + $relation->removeCallbacks()->add(function ($list, $ids) use (&$removedIds) { + $removedIds = $ids; + }); + + $relation->removeByID($remove->ID); + $this->assertEquals([$remove->ID], $removedIds); + } + + public function testRemoveCallbackOnRemoveAll() + { + $removedIds = []; + + $base = $this->objFromFixture(Team::class, 'team1'); + $relation = $base->Players(); + $remove = $relation->column('ID'); + + $relation->removeCallbacks()->add(function ($list, $ids) use (&$removedIds) { + $removedIds = $ids; + }); + + $relation->removeAll(); + $this->assertEquals(sort($remove), sort($removedIds)); + } } diff --git a/tests/php/ORM/ManyManyThroughListTest.php b/tests/php/ORM/ManyManyThroughListTest.php index 6a6b83318..04a3616b2 100644 --- a/tests/php/ORM/ManyManyThroughListTest.php +++ b/tests/php/ORM/ManyManyThroughListTest.php @@ -6,11 +6,14 @@ use SilverStripe\Core\Config\Config; use SilverStripe\Dev\SapphireTest; use SilverStripe\ORM\DataObject; use SilverStripe\ORM\ManyManyThroughList; +use SilverStripe\ORM\Tests\DataObjectTest\Player; +use SilverStripe\ORM\Tests\DataObjectTest\Team; use SilverStripe\ORM\Tests\ManyManyThroughListTest\Item; use SilverStripe\ORM\Tests\ManyManyThroughListTest\PolyItem; use SilverStripe\ORM\Tests\ManyManyThroughListTest\PolyJoinObject; use SilverStripe\ORM\Tests\ManyManyThroughListTest\Locale; use SilverStripe\ORM\Tests\ManyManyThroughListTest\FallbackLocale; +use SilverStripe\ORM\Tests\ManyManyThroughListTest\TestObject; class ManyManyThroughListTest extends SapphireTest { @@ -362,4 +365,95 @@ class ManyManyThroughListTest extends SapphireTest $this->assertSame('International', $firstReverse->Title); $this->assertSame('Argentina', $secondReverse->Title); } + + public function testCallbackOnSetById() + { + $addedIds = []; + $removedIds = []; + + $base = $this->objFromFixture(ManyManyThroughListTest\TestObject::class, 'parent1'); + $relation = $base->Items(); + $remove = $relation->First(); + $add = new Item(); + $add->write(); + + $relation->addCallbacks()->add(function ($list, $item, $extraFields) use (&$removedIds) { + $addedIds[] = $item; + }); + + $relation->removeCallbacks()->add(function ($list, $ids) use (&$removedIds) { + $removedIds = $ids; + }); + + $relation->setByIDList(array_merge( + $base->Items()->exclude('ID', $remove->ID)->column('ID'), + [$add->ID] + )); + $this->assertEquals([$remove->ID], $removedIds); + } + + public function testAddCallbackWithExtraFields() + { + $added = []; + + $base = $this->objFromFixture(ManyManyThroughListTest\TestObject::class, 'parent1'); + $relation = $base->Items(); + $add = new Item(); + $add->write(); + + $relation->addCallbacks()->add(function ($list, $item, $extraFields) use (&$added) { + $added[] = [$item, $extraFields]; + }); + + $relation->add($add, ['Sort' => '99']); + $this->assertEquals([[$add, ['Sort' => '99']]], $added); + } + + public function testRemoveCallbackOnRemove() + { + $removedIds = []; + + $base = $this->objFromFixture(ManyManyThroughListTest\TestObject::class, 'parent1'); + $relation = $base->Items(); + $remove = $relation->First(); + + $relation->removeCallbacks()->add(function ($list, $ids) use (&$removedIds) { + $removedIds = $ids; + }); + + $relation->remove($remove); + $this->assertEquals([$remove->ID], $removedIds); + } + + public function testRemoveCallbackOnRemoveById() + { + $removedIds = []; + + $base = $this->objFromFixture(ManyManyThroughListTest\TestObject::class, 'parent1'); + $relation = $base->Items(); + $remove = $relation->First(); + + $relation->removeCallbacks()->add(function ($list, $ids) use (&$removedIds) { + $removedIds = $ids; + }); + + $relation->removeByID($remove->ID); + $this->assertEquals([$remove->ID], $removedIds); + } + + public function testRemoveCallbackOnRemoveAll() + { + $removedIds = []; + + $base = $this->objFromFixture(ManyManyThroughListTest\TestObject::class, 'parent1'); + $relation = $base->Items(); + $remove = $relation->column('ID'); + + $relation->removeCallbacks()->add(function ($list, $ids) use (&$removedIds) { + $removedIds = $ids; + }); + + $relation->removeAll(); + $this->assertEquals(sort($remove), sort($removedIds)); + } }