Merge pull request #7257 from open-sausages/pulls/4.0/cascade-deletes

API Implement cascade_deletes
This commit is contained in:
Chris Joe 2017-08-09 16:07:02 +12:00 committed by GitHub
commit b0af45231a
8 changed files with 421 additions and 0 deletions

View File

@ -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

View File

@ -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);
}
}
}

View 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()
);
}
}

View 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

View 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,
];
}

View 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,
];
}

View 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,
];
}

View 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,
];
}