mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 14:05:37 +02:00
API Implement cascade_deletes
This commit is contained in:
parent
4572b01068
commit
323644c7bb
@ -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
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
126
tests/php/ORM/CascadeDeleteTest.php
Normal file
126
tests/php/ORM/CascadeDeleteTest.php
Normal file
@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace SilverStripe\ORM\Tests;
|
||||
|
||||
use SilverStripe\Dev\SapphireTest;
|
||||
|
||||
/**
|
||||
* Test cascade delete objects
|
||||
*/
|
||||
class CascadeDeleteTest extends SapphireTest
|
||||
{
|
||||
protected static $fixture_file = 'CascadeDeleteTest.yml';
|
||||
|
||||
protected static $extra_dataobjects = [
|
||||
CascadeDeleteTest\ParentObject::class,
|
||||
CascadeDeleteTest\ChildObject::class,
|
||||
CascadeDeleteTest\GrandChildObject::class,
|
||||
CascadeDeleteTest\RelatedObject::class,
|
||||
];
|
||||
|
||||
public function testFindCascadeDeletes()
|
||||
{
|
||||
/** @var CascadeDeleteTest\ChildObject $child1 */
|
||||
$child1 = $this->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()
|
||||
);
|
||||
}
|
||||
}
|
39
tests/php/ORM/CascadeDeleteTest.yml
Normal file
39
tests/php/ORM/CascadeDeleteTest.yml
Normal file
@ -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
|
34
tests/php/ORM/CascadeDeleteTest/ChildObject.php
Normal file
34
tests/php/ORM/CascadeDeleteTest/ChildObject.php
Normal file
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace SilverStripe\ORM\Tests\CascadeDeleteTest;
|
||||
|
||||
use SilverStripe\Dev\TestOnly;
|
||||
use SilverStripe\ORM\DataObject;
|
||||
use SilverStripe\ORM\ManyManyList;
|
||||
|
||||
/**
|
||||
* @method ParentObject Parent()
|
||||
* @method RelatedObject Related()
|
||||
* @method ManyManyList Children()
|
||||
*/
|
||||
class ChildObject extends DataObject implements TestOnly
|
||||
{
|
||||
private static $table_name = 'CascadeDeleteTest_ChildObject';
|
||||
|
||||
private static $db = [
|
||||
'Title' => 'Varchar',
|
||||
];
|
||||
|
||||
private static $cascade_deletes = [
|
||||
'Children'
|
||||
];
|
||||
|
||||
private static $has_one = [
|
||||
'Parent' => ParentObject::class,
|
||||
'Related' => RelatedObject::class,
|
||||
];
|
||||
|
||||
private static $many_many = [
|
||||
'Children' => GrandChildObject::class,
|
||||
];
|
||||
}
|
19
tests/php/ORM/CascadeDeleteTest/GrandChildObject.php
Normal file
19
tests/php/ORM/CascadeDeleteTest/GrandChildObject.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace SilverStripe\ORM\Tests\CascadeDeleteTest;
|
||||
|
||||
use SilverStripe\Dev\TestOnly;
|
||||
use SilverStripe\ORM\DataObject;
|
||||
|
||||
class GrandChildObject extends DataObject implements TestOnly
|
||||
{
|
||||
private static $table_name = 'CascadeDeleteTest_GrandChildObject';
|
||||
|
||||
private static $db = [
|
||||
'Title' => 'Varchar',
|
||||
];
|
||||
|
||||
private static $belongs_many_many = [
|
||||
'Parents' => ChildObject::class,
|
||||
];
|
||||
}
|
23
tests/php/ORM/CascadeDeleteTest/ParentObject.php
Normal file
23
tests/php/ORM/CascadeDeleteTest/ParentObject.php
Normal file
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace SilverStripe\ORM\Tests\CascadeDeleteTest;
|
||||
|
||||
use SilverStripe\Dev\TestOnly;
|
||||
use SilverStripe\ORM\DataObject;
|
||||
|
||||
class ParentObject extends DataObject implements TestOnly
|
||||
{
|
||||
private static $table_name = 'CascadeDeleteTest_ParentObject';
|
||||
|
||||
private static $db = [
|
||||
'Title' => 'Varchar',
|
||||
];
|
||||
|
||||
private static $cascade_deletes = [
|
||||
'Children'
|
||||
];
|
||||
|
||||
private static $has_many = [
|
||||
'Children' => ChildObject::class,
|
||||
];
|
||||
}
|
19
tests/php/ORM/CascadeDeleteTest/RelatedObject.php
Normal file
19
tests/php/ORM/CascadeDeleteTest/RelatedObject.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace SilverStripe\ORM\Tests\CascadeDeleteTest;
|
||||
|
||||
use SilverStripe\Dev\TestOnly;
|
||||
use SilverStripe\ORM\DataObject;
|
||||
|
||||
class RelatedObject extends DataObject implements TestOnly
|
||||
{
|
||||
private static $table_name = 'CascadeDeleteTest_RelatedObject';
|
||||
|
||||
private static $db = [
|
||||
'Title' => 'Varchar',
|
||||
];
|
||||
|
||||
private static $belongs_to = [
|
||||
'Parent' => ChildObject::class,
|
||||
];
|
||||
}
|
Loading…
Reference in New Issue
Block a user