diff --git a/docs/en/02_Developer_Guides/00_Model/02_Relations.md b/docs/en/02_Developer_Guides/00_Model/02_Relations.md index 47304c52d..7d7e573f4 100644 --- a/docs/en/02_Developer_Guides/00_Model/02_Relations.md +++ b/docs/en/02_Developer_Guides/00_Model/02_Relations.md @@ -406,6 +406,32 @@ the best way to think about it is that the object where the relationship will be Product => Categories, the `Product` should contain the `many_many`, because it is much more likely that the user will select Categories for a Product than vice-versa. +## Cascading deletions + +Relationships between objects can cause cascading deletions, if necessary, through configuration of the +`cascade_deletes` config on the parent class. + +```php +class ParentObject extends DataObject { + private static $has_one = [ + 'Child' => ChildObject::class, + ]; + private static $cascade_deletes = [ + 'Child', + ]; +} +class ChildObject extends DataObject { +} +``` + +In this example, when the Parent object is deleted, the Child specified by the has_one relation will also +be deleted. Note that all relation types (has_many, many_many, belongs_many_many, belongs_to, and has_one) +are supported, as are methods that return lists of objects but do not correspond to a physical database relation. + +If your object is versioned, cascade_deletes will also act as "cascade unpublish", such that any unpublish +on a parent object will trigger unpublish on the child, similarly to how `owns` causes triggered publishing. +See the [versioning docs](/developer_guides/versioning) for more information on ownership. + ## Adding relations Adding new items to a relations works the same, regardless if you're editing a **has_many** or a **many_many**. They are diff --git a/src/ORM/DataObject.php b/src/ORM/DataObject.php index 2edd30ebc..0e1c7b7aa 100644 --- a/src/ORM/DataObject.php +++ b/src/ORM/DataObject.php @@ -266,6 +266,17 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity */ protected $unsavedRelations; + /** + * List of relations that should be cascade deleted, similar to `owns` + * Note: This will trigger delete on many_many objects, not only the mapping table. + * For many_many through you can specify the components you want to delete separately + * (many_many or has_many sub-component) + * + * @config + * @var array + */ + private static $cascade_deletes = []; + /** * Get schema object * @@ -1043,6 +1054,23 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity $this->extend('onAfterWrite', $dummy); } + /** + * Find all objects that will be cascade deleted if this object is deleted + * + * Notes: + * - If this object is versioned, objects will only be searched in the same stage as the given record. + * - This will only be useful prior to deletion, as post-deletion this record will no longer exist. + * + * @param bool $recursive True if recursive + * @param ArrayList $list Optional list to add items to + * @return ArrayList list of objects + */ + public function findCascadeDeletes($recursive = true, $list = null) + { + // Find objects in these relationships + return $this->findRelatedObjects('cascade_deletes', $recursive, $list); + } + /** * Event handler called before deleting from the database. * You can overload this to clean up or otherwise process data before delete this @@ -1056,6 +1084,12 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity $dummy = null; $this->extend('onBeforeDelete', $dummy); + + // Cascade deletes + $deletes = $this->findCascadeDeletes(false); + foreach ($deletes as $delete) { + $delete->delete(); + } } protected function onAfterDelete() @@ -3680,4 +3714,105 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } return $this; } + + /** + * Find objects in the given relationships, merging them into the given list + * + * @param string $source Config property to extract relationships from + * @param bool $recursive True if recursive + * @param ArrayList $list If specified, items will be added to this list. If not, a new + * instance of ArrayList will be constructed and returned + * @return ArrayList The list of related objects + */ + public function findRelatedObjects($source, $recursive = true, $list = null) + { + if (!$list) { + $list = new ArrayList(); + } + + // Skip search for unsaved records + if (!$this->isInDB()) { + return $list; + } + + $relationships = $this->config()->get($source) ?: []; + foreach ($relationships as $relationship) { + // Warn if invalid config + if (!$this->hasMethod($relationship)) { + trigger_error(sprintf( + "Invalid %s config value \"%s\" on object on class \"%s\"", + $source, + $relationship, + get_class($this) + ), E_USER_WARNING); + continue; + } + + // Inspect value of this relationship + $items = $this->{$relationship}(); + + // Merge any new item + $newItems = $this->mergeRelatedObjects($list, $items); + + // Recurse if necessary + if ($recursive) { + foreach ($newItems as $item) { + /** @var DataObject $item */ + $item->findRelatedObjects($source, true, $list); + } + } + } + return $list; + } + + /** + * Helper method to merge owned/owning items into a list. + * Items already present in the list will be skipped. + * + * @param ArrayList $list Items to merge into + * @param mixed $items List of new items to merge + * @return ArrayList List of all newly added items that did not already exist in $list + */ + public function mergeRelatedObjects($list, $items) + { + $added = new ArrayList(); + if (!$items) { + return $added; + } + if ($items instanceof DataObject) { + $items = [$items]; + } + + /** @var DataObject $item */ + foreach ($items as $item) { + $this->mergeRelatedObject($list, $added, $item); + } + return $added; + } + + /** + * Merge single object into a list, but ensures that existing objects are not + * re-added. + * + * @param ArrayList $list Global list + * @param ArrayList $added Additional list to insert into + * @param DataObject $item Item to add + */ + protected function mergeRelatedObject($list, $added, $item) + { + // Identify item + $itemKey = get_class($item) . '/' . $item->ID; + + // Write if saved, versioned, and not already added + if ($item->isInDB() && !isset($list[$itemKey])) { + $list[$itemKey] = $item; + $added[$itemKey] = $item; + } + + // Add joined record (from many_many through) automatically + $joined = $item->getJoin(); + if ($joined) { + $this->mergeRelatedObject($list, $added, $joined); + } + } } diff --git a/tests/php/ORM/CascadeDeleteTest.php b/tests/php/ORM/CascadeDeleteTest.php new file mode 100644 index 000000000..303d74bb8 --- /dev/null +++ b/tests/php/ORM/CascadeDeleteTest.php @@ -0,0 +1,126 @@ +objFromFixture(CascadeDeleteTest\ChildObject::class, 'child1'); + $this->assertDOSEquals( + [ + [ 'Title' => 'Grandchild 1'], + [ 'Title' => 'Grandchild 2'], + ], + $child1->findCascadeDeletes(true) + ); + $this->assertDOSEquals( + [ + [ 'Title' => 'Grandchild 1'], + [ 'Title' => 'Grandchild 2'], + ], + $child1->findCascadeDeletes(false) + ); + + /** @var CascadeDeleteTest\ParentObject $parent1 */ + $parent1 = $this->objFromFixture(CascadeDeleteTest\ParentObject::class, 'parent1'); + $this->assertDOSEquals( + [ + [ 'Title' => 'Child 1'], + [ 'Title' => 'Grandchild 1'], + [ 'Title' => 'Grandchild 2'], + ], + $parent1->findCascadeDeletes(true) + ); + $this->assertDOSEquals( + [ + [ 'Title' => 'Child 1'], + ], + $parent1->findCascadeDeletes(false) + ); + } + + public function testRecursiveDelete() + { + /** @var CascadeDeleteTest\ChildObject $child1 */ + $child1 = $this->objFromFixture(CascadeDeleteTest\ChildObject::class, 'child1'); + $child1->delete(); + + // Parent, related, and non-relational objects undeleted + $this->assertNotEmpty($this->objFromFixture(CascadeDeleteTest\ParentObject::class, 'parent1')); + + // Related objects never deleted + $this->assertDOSEquals( + [ + ['Title' => 'Related 1'], + ['Title' => 'Related 2'], + ['Title' => 'Related 3'], + ], + CascadeDeleteTest\RelatedObject::get() + ); + + // Ensure only remaining grandchild are those outside the relation + $this->assertDOSEquals( + [ + ['Title' => 'Grandchild 3'], + ], + CascadeDeleteTest\GrandChildObject::get() + ); + } + + public function testDeepRecursiveDelete() + { + /** @var CascadeDeleteTest\ParentObject $parent1 */ + $parent1 = $this->objFromFixture(CascadeDeleteTest\ParentObject::class, 'parent1'); + $parent1->delete(); + + // Ensure affected cascading tables have expected content + $this->assertDOSEquals( + [ + ['Title' => 'Child 2'], + ], + CascadeDeleteTest\ChildObject::get() + ); + $this->assertDOSEquals( + [ + ['Title' => 'Grandchild 3'], + ], + CascadeDeleteTest\GrandChildObject::get() + ); + + // Related objects never deleted + $this->assertDOSEquals( + [ + ['Title' => 'Related 1'], + ['Title' => 'Related 2'], + ['Title' => 'Related 3'], + ], + CascadeDeleteTest\RelatedObject::get() + ); + + // Ensure that other parents which share cascade deleted objects have the correct result + /** @var CascadeDeleteTest\ChildObject $child2 */ + $child2 = $this->objFromFixture(CascadeDeleteTest\ChildObject::class, 'child2'); + $this->assertDOSEquals( + [ + ['Title' => 'Grandchild 3'], + ], + $child2->Children() + ); + } +} diff --git a/tests/php/ORM/CascadeDeleteTest.yml b/tests/php/ORM/CascadeDeleteTest.yml new file mode 100644 index 000000000..73af653bc --- /dev/null +++ b/tests/php/ORM/CascadeDeleteTest.yml @@ -0,0 +1,39 @@ +SilverStripe\ORM\Tests\CascadeDeleteTest\GrandChildObject: + grandchild1: + Title: 'Grandchild 1' + grandchild2: + Title: 'Grandchild 2' + grandchild3: + Title: 'Grandchild 3' + +SilverStripe\ORM\Tests\CascadeDeleteTest\RelatedObject: + related1: + Title: 'Related 1' + related2: + Title: 'Related 2' + related3: + Title: 'Related 3' + +SilverStripe\ORM\Tests\CascadeDeleteTest\ParentObject: + parent1: + Title: 'Parent 1' + parent2: + Title: 'Parent 2' + parent3: + Title: 'Parent 3' + +SilverStripe\ORM\Tests\CascadeDeleteTest\ChildObject: + child1: + Title: 'Child 1' + Parent: =>SilverStripe\ORM\Tests\CascadeDeleteTest\ParentObject.parent1 + Related: =>SilverStripe\ORM\Tests\CascadeDeleteTest\RelatedObject.related1 + Children: + - =>SilverStripe\ORM\Tests\CascadeDeleteTest\GrandChildObject.grandchild1 + - =>SilverStripe\ORM\Tests\CascadeDeleteTest\GrandChildObject.grandchild2 + child2: + Title: 'Child 2' + Parent: =>SilverStripe\ORM\Tests\CascadeDeleteTest\ParentObject.parent2 + Related: =>SilverStripe\ORM\Tests\CascadeDeleteTest\RelatedObject.related2 + Children: + - =>SilverStripe\ORM\Tests\CascadeDeleteTest\GrandChildObject.grandchild2 + - =>SilverStripe\ORM\Tests\CascadeDeleteTest\GrandChildObject.grandchild3 diff --git a/tests/php/ORM/CascadeDeleteTest/ChildObject.php b/tests/php/ORM/CascadeDeleteTest/ChildObject.php new file mode 100644 index 000000000..b4e899c7e --- /dev/null +++ b/tests/php/ORM/CascadeDeleteTest/ChildObject.php @@ -0,0 +1,34 @@ + 'Varchar', + ]; + + private static $cascade_deletes = [ + 'Children' + ]; + + private static $has_one = [ + 'Parent' => ParentObject::class, + 'Related' => RelatedObject::class, + ]; + + private static $many_many = [ + 'Children' => GrandChildObject::class, + ]; +} diff --git a/tests/php/ORM/CascadeDeleteTest/GrandChildObject.php b/tests/php/ORM/CascadeDeleteTest/GrandChildObject.php new file mode 100644 index 000000000..ae7c0c278 --- /dev/null +++ b/tests/php/ORM/CascadeDeleteTest/GrandChildObject.php @@ -0,0 +1,19 @@ + 'Varchar', + ]; + + private static $belongs_many_many = [ + 'Parents' => ChildObject::class, + ]; +} diff --git a/tests/php/ORM/CascadeDeleteTest/ParentObject.php b/tests/php/ORM/CascadeDeleteTest/ParentObject.php new file mode 100644 index 000000000..922318b92 --- /dev/null +++ b/tests/php/ORM/CascadeDeleteTest/ParentObject.php @@ -0,0 +1,23 @@ + 'Varchar', + ]; + + private static $cascade_deletes = [ + 'Children' + ]; + + private static $has_many = [ + 'Children' => ChildObject::class, + ]; +} diff --git a/tests/php/ORM/CascadeDeleteTest/RelatedObject.php b/tests/php/ORM/CascadeDeleteTest/RelatedObject.php new file mode 100644 index 000000000..13367e05d --- /dev/null +++ b/tests/php/ORM/CascadeDeleteTest/RelatedObject.php @@ -0,0 +1,19 @@ + 'Varchar', + ]; + + private static $belongs_to = [ + 'Parent' => ChildObject::class, + ]; +}