diff --git a/src/RecursiveStagesService.php b/src/RecursiveStagesService.php new file mode 100644 index 00000000..bd691b93 --- /dev/null +++ b/src/RecursiveStagesService.php @@ -0,0 +1,171 @@ +exists()) { + return false; + } + + $items = [$object]; + + // compare existing content + while ($item = array_shift($items)) { + if ($this->checkNeedPublishingItem($item)) { + return true; + } + + $relatedObjects = $this->findOwnedObjects($item, $mode); + $items = array_merge($items, $relatedObjects); + } + + // compare deleted content + $draftIdentifiers = $this->findOwnedIdentifiers($object, $mode, Versioned::DRAFT); + $liveIdentifiers = $this->findOwnedIdentifiers($object, $mode, Versioned::LIVE); + + return $draftIdentifiers !== $liveIdentifiers; + } + + /** + * Find all identifiers for owned objects + * + * @param DataObject $object + * @param string $mode + * @param string $stage + * @return array + */ + protected function findOwnedIdentifiers(DataObject $object, string $mode, string $stage): array + { + $ids = Versioned::withVersionedMode(function () use ($object, $mode, $stage): array { + Versioned::set_stage($stage); + + $object = DataObject::get_by_id($object->ClassName, $object->ID); + + if ($object === null) { + return []; + } + + $items = [$object]; + $ids = []; + + while ($object = array_shift($items)) { + $ids[] = implode('_', [$object->baseClass(), $object->ID]); + $relatedObjects = $this->findOwnedObjects($object, $mode); + $items = array_merge($items, $relatedObjects); + } + + return $ids; + }); + + sort($ids, SORT_STRING); + + return array_values($ids); + } + + /** + * This lookup will attempt to find "Strongly owned" objects + * such objects are unable to exist without the current object + * We will use "cascade_duplicates" setting for this purpose as we can assume that if an object needs to be + * duplicated along with the owner object, it uses the strong ownership relation + * + * "Weakly owned" objects could be looked up via "owns" setting + * Such objects can exist even without the owner objects as they are often used as shared objects + * managed independently of their owners + * + * @param DataObject $object + * @param string $mode + * @return array + */ + protected function findOwnedObjects(DataObject $object, string $mode): array + { + $ownershipType = $mode === self::OWNERSHIP_WEAK + ? 'owns' + : 'cascade_duplicates'; + + $relations = (array) $object->config()->get($ownershipType); + $relations = array_unique($relations); + $result = []; + + foreach ($relations as $relation) { + $relation = (string) $relation; + + if (!$relation) { + continue; + } + + $relationData = $object->{$relation}(); + + if ($relationData instanceof DataObject) { + if (!$relationData->exists()) { + continue; + } + + $result[] = $relationData; + + continue; + } + + if (!$relationData instanceof SS_List) { + continue; + } + + foreach ($relationData as $relatedObject) { + if (!$relatedObject instanceof DataObject || !$relatedObject->exists()) { + continue; + } + + $result[] = $relatedObject; + } + } + + return $result; + } + + /** + * @param DataObject|Versioned $item + * @return bool + */ + protected function checkNeedPublishingItem(DataObject $item): bool + { + if ($item->hasExtension(Versioned::class)) { + /** @var $item Versioned */ + return !$item->isPublished() || $item->stagesDiffer(); + } + + return false; + } +} diff --git a/src/Versioned.php b/src/Versioned.php index 948dce41..3695203e 100644 --- a/src/Versioned.php +++ b/src/Versioned.php @@ -2060,6 +2060,20 @@ public function stagesDiffer() return (bool) $stagesDiffer; } + /** + * Determine if content differs on stages including nested objects + * $mode determines which relation will be used to traverse the ownership tree + * "strong" will use "cascade_duplicates" + * "weak" will use "owns" + * + * @param string $mode "strong" or "weak" + * @return bool + */ + public function stagesDifferRecursive(string $mode = RecursiveStagesService::OWNERSHIP_STRONG): bool + { + return RecursiveStagesService::singleton()->stagesDifferRecursive($this->owner, $mode); + } + /** * @param string $filter * @param string $sort diff --git a/tests/php/RecursiveStagesServiceTest.php b/tests/php/RecursiveStagesServiceTest.php new file mode 100644 index 00000000..b9cc43cf --- /dev/null +++ b/tests/php/RecursiveStagesServiceTest.php @@ -0,0 +1,92 @@ + [ + Versioned::class, + ], + ColumnObject::class => [ + Versioned::class, + ], + GroupObject::class => [ + Versioned::class, + ], + ChildObject::class => [ + Versioned::class, + ], + ]; + + /** + * @param string $class + * @param string $identifier + * @param bool $delete + * @throws ValidationException + * @dataProvider objectsProvider + */ + public function testStageDiffersRecursive(string $class, string $identifier, bool $delete): void + { + /** @var PrimaryObject|Versioned $primaryItem */ + $primaryItem = $this->objFromFixture(PrimaryObject::class, 'primary-object-1'); + $primaryItem->publishRecursive(); + + $this->assertFalse($primaryItem->stagesDifferRecursive()); + + $record = $this->objFromFixture($class, $identifier); + + if ($delete) { + $record->delete(); + } else { + $record->Title = 'New Title'; + $record->write(); + } + + $this->assertTrue($primaryItem->stagesDifferRecursive()); + } + + public function testStageDiffersRecursiveWithInvalidObject(): void + { + /** @var PrimaryObject|Versioned $primaryItem */ + $primaryItem = PrimaryObject::create(); + + $this->assertFalse($primaryItem->stagesDifferRecursive()); + } + + public function objectsProvider(): array + { + return [ + [PrimaryObject::class, 'primary-object-1', false], + [ColumnObject::class, 'column-1', false], + [ColumnObject::class, 'column-1', true], + [GroupObject::class, 'group-1', false], + [GroupObject::class, 'group-1', true], + [ChildObject::class, 'child-object-1', false], + [ChildObject::class, 'child-object-1', true], + ]; + } +} diff --git a/tests/php/RecursiveStagesServiceTest.yml b/tests/php/RecursiveStagesServiceTest.yml new file mode 100644 index 00000000..6e35a701 --- /dev/null +++ b/tests/php/RecursiveStagesServiceTest.yml @@ -0,0 +1,24 @@ +# site-config-1 +# -> primary-object-1 (top level publish object) +# --> column-1 +# ---> group-1 +# ----> child-object-1 + +SilverStripe\Versioned\Tests\RecursiveStagesServiceTest\PrimaryObject: + primary-object-1: + Title: PrimaryObject1 + +SilverStripe\Versioned\Tests\RecursiveStagesServiceTest\ColumnObject: + column-1: + Title: Column1 + PrimaryObject: =>SilverStripe\Versioned\Tests\RecursiveStagesServiceTest\PrimaryObject.primary-object-1 + +SilverStripe\Versioned\Tests\RecursiveStagesServiceTest\GroupObject: + group-1: + Title: Group1 + Column: =>SilverStripe\Versioned\Tests\RecursiveStagesServiceTest\ColumnObject.column-1 + +SilverStripe\Versioned\Tests\RecursiveStagesServiceTest\ChildObject: + child-object-1: + Title: Item1 + Group: =>SilverStripe\Versioned\Tests\RecursiveStagesServiceTest\GroupObject.group-1 diff --git a/tests/php/RecursiveStagesServiceTest/ChildObject.php b/tests/php/RecursiveStagesServiceTest/ChildObject.php new file mode 100644 index 00000000..6eabe6d6 --- /dev/null +++ b/tests/php/RecursiveStagesServiceTest/ChildObject.php @@ -0,0 +1,36 @@ + 'Varchar(255)', + ]; + + /** + * @var array + */ + private static $has_one = [ + 'Group' => GroupObject::class, + ]; +} diff --git a/tests/php/RecursiveStagesServiceTest/ColumnObject.php b/tests/php/RecursiveStagesServiceTest/ColumnObject.php new file mode 100644 index 00000000..ce6c2878 --- /dev/null +++ b/tests/php/RecursiveStagesServiceTest/ColumnObject.php @@ -0,0 +1,66 @@ + 'Varchar(255)', + ]; + + /** + * @var array + */ + private static $has_one = [ + 'PrimaryObject' => PrimaryObject::class, + ]; + + /** + * @var array + */ + private static $has_many = [ + 'Groups' => GroupObject::class, + ]; + + /** + * @var array + */ + private static $owns = [ + 'Groups', + ]; + + /** + * @var array + */ + private static $cascade_duplicates = [ + 'Groups', + ]; + + /** + * @var array + */ + private static $cascade_deletes = [ + 'Groups', + ]; +} diff --git a/tests/php/RecursiveStagesServiceTest/GroupObject.php b/tests/php/RecursiveStagesServiceTest/GroupObject.php new file mode 100644 index 00000000..4bac1eee --- /dev/null +++ b/tests/php/RecursiveStagesServiceTest/GroupObject.php @@ -0,0 +1,66 @@ + 'Varchar(255)', + ]; + + /** + * @var array + */ + private static $has_one = [ + 'Column' => ColumnObject::class, + ]; + + /** + * @var array + */ + private static $has_many = [ + 'Children' => ChildObject::class, + ]; + + /** + * @var array + */ + private static $owns = [ + 'Children', + ]; + + /** + * @var array + */ + private static $cascade_duplicates = [ + 'Children', + ]; + + /** + * @var array + */ + private static $cascade_deletes = [ + 'Children', + ]; +} diff --git a/tests/php/RecursiveStagesServiceTest/PrimaryObject.php b/tests/php/RecursiveStagesServiceTest/PrimaryObject.php new file mode 100644 index 00000000..741a756c --- /dev/null +++ b/tests/php/RecursiveStagesServiceTest/PrimaryObject.php @@ -0,0 +1,56 @@ + 'Varchar(255)', + ]; + + /** + * @var array + */ + private static $has_many = [ + 'Columns' => ColumnObject::class, + ]; + + /** + * @var array + */ + private static $owns = [ + 'Columns', + ]; + + /** + * @var array + */ + private static $cascade_duplicates = [ + 'Columns', + ]; + + /** + * @var array + */ + private static $cascade_deletes = [ + 'Columns', + ]; +}