diff --git a/src/PublishStateHelper.php b/src/PublishStateHelper.php new file mode 100644 index 00000000..f2922510 --- /dev/null +++ b/src/PublishStateHelper.php @@ -0,0 +1,84 @@ +exists()) { + return false; + } + + if ($item->hasExtension(Versioned::class)) { + /** @var $item Versioned */ + return !$item->isPublished() || $item->stagesDiffer(); + } + + return false; + } + + /** + * @param SS_List $list + * @return bool + */ + public static function checkNeedPublishingList(SS_List $list): bool + { + /** @var $item Versioned */ + foreach ($list as $item) { + if (static::checkNeedPublishingItem($item)) { + return true; + } + } + + return false; + } + + /** + * @param DataList $items + * @param int $parentId + * @return bool + */ + public static function checkNeedPublishVersionedItems(DataList $items, int $parentId): bool + { + // check for differences in models + foreach ($items as $item) { + if (PublishStateHelper::checkNeedPublishingItem($item)) { + return true; + } + } + + // check for deletion of a model + $draftCount = $items->count(); + + // we need to fetch live records and compare amount because if a record was deleted from stage + // the above draft items loop will not cover the missing item + $liveCount = Versioned::get_by_stage( + $items->dataClass(), + Versioned::LIVE, + ['ParentID' => $parentId] + )->count(); + + if ($draftCount != $liveCount) { + return true; + } + + return false; + } +} diff --git a/src/Versioned.php b/src/Versioned.php index 948dce41..7eeb6ff9 100644 --- a/src/Versioned.php +++ b/src/Versioned.php @@ -17,6 +17,7 @@ use SilverStripe\ORM\ArrayList; use SilverStripe\ORM\DataExtension; use SilverStripe\ORM\DataList; +use SilverStripe\ORM\SS_List; use SilverStripe\ORM\DataObject; use SilverStripe\ORM\DataQuery; use SilverStripe\ORM\DB; @@ -75,6 +76,16 @@ class Versioned extends DataExtension implements TemplateGlobalProvider, Resetta */ const DRAFT = 'Stage'; + /** + * Strong ownership uses 'owns' configuration to determine relationships + */ + public const OWNERSHIP_STRONG = 'strong'; + + /** + * Strong ownership uses 'cascade_duplicates' configuration to determine relationships + */ + public const OWNERSHIP_WEAK = 'weak'; + /** * A cache used by get_versionnumber_by_stage(). * Clear through {@link flushCache()}. @@ -2060,6 +2071,141 @@ public function stagesDiffer() return (bool) $stagesDiffer; } + /** + * Determine if content differs on stages including nested objects + * + * @param string $mode + * @return bool + */ + public function stagesDifferRecursive(string $mode = self::OWNERSHIP_STRONG): bool + { + $owner = $this->owner; + + if ($owner === null || !$owner->exists()) { + return false; + } + + $records = [$owner]; + + // compare existing content + while ($record = array_shift($records)) { + if (PublishStateHelper::checkNeedPublishingItem($record)) { + return true; + } + + $relatedRecords = $this->findOwnedObjects($record, $mode); + + foreach ($relatedRecords as $relatedRecord) { + $records[] = $relatedRecord; + } + } + + // compare deleted content + $draftIdentifiers = $this->findOwnedIdentifiers($owner, $mode, Versioned::DRAFT); + $liveIdentifiers = $this->findOwnedIdentifiers($owner, $mode, Versioned::LIVE); + + return $draftIdentifiers !== $liveIdentifiers; + } + + + /** + * Find all identifiers for owned objects + * + * @param DataObject $record + * @param string $mode + * @param string $stage + * @return array + */ + protected function findOwnedIdentifiers(DataObject $record, string $mode, string $stage): array + { + $ids = Versioned::withVersionedMode(function () use ($record, $mode, $stage): array { + Versioned::set_stage($stage); + + $record = DataObject::get_by_id($record->ClassName, $record->ID); + + if ($record === null) { + return []; + } + + $records = [$record]; + $ids = []; + + while ($record = array_shift($records)) { + $ids[] = implode('_', [$record->baseClass(), $record->ID]); + $relatedRecords = $this->findOwnedObjects($record, $mode); + + foreach ($relatedRecords as $relatedRecord) { + $records[] = $relatedRecord; + } + } + + 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 $record + * @param string $mode + * @return array + */ + protected function findOwnedObjects(DataObject $record, string $mode): array + { + $ownershipType = $mode === self::OWNERSHIP_WEAK + ? 'owns' + : 'cascade_duplicates'; + + $relations = (array) $record->config()->get($ownershipType); + $relations = array_unique($relations); + $result = []; + + foreach ($relations as $relation) { + $relation = (string) $relation; + + if (!$relation) { + continue; + } + + $relationData = $record->$relation(); + + if ($relationData instanceof DataObject) { + if (!$relationData->exists()) { + continue; + } + + $result[] = $relationData; + + continue; + } + + if (!$relationData instanceof SS_List) { + continue; + } + + foreach ($relationData as $relatedRecord) { + if (!$relatedRecord instanceof DataObject || !$relatedRecord->exists()) { + continue; + } + + $result[] = $relatedRecord; + } + } + + return $result; + } + /** * @param string $filter * @param string $sort diff --git a/tests/php/VersionedNestedTest.php b/tests/php/VersionedNestedTest.php new file mode 100644 index 00000000..09657cb7 --- /dev/null +++ b/tests/php/VersionedNestedTest.php @@ -0,0 +1,84 @@ + [ + 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 $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 objectsProvider(): array + { + return [ + [PrimaryObject::class, 'primary-object-1', false], + [ColumnObject::class, 'column-1', false], + [GroupObject::class, 'group-1', false], + [ChildObject::class, 'child-object-1', false], + [ColumnObject::class, 'column-1', true], + [GroupObject::class, 'group-1', true], + [ChildObject::class, 'child-object-1', true], + ]; + } +} diff --git a/tests/php/VersionedNestedTest.yml b/tests/php/VersionedNestedTest.yml new file mode 100644 index 00000000..de66af9c --- /dev/null +++ b/tests/php/VersionedNestedTest.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\VersionedNestedTest\PrimaryObject: + primary-object-1: + Title: PrimaryObject1 + +SilverStripe\Versioned\Tests\VersionedNestedTest\ColumnObject: + column-1: + Title: Column1 + PrimaryObject: =>SilverStripe\Versioned\Tests\VersionedNestedTest\PrimaryObject.primary-object-1 + +SilverStripe\Versioned\Tests\VersionedNestedTest\GroupObject: + group-1: + Title: Group1 + Column: =>SilverStripe\Versioned\Tests\VersionedNestedTest\ColumnObject.column-1 + +SilverStripe\Versioned\Tests\VersionedNestedTest\ChildObject: + child-object-1: + Title: Item1 + Group: =>SilverStripe\Versioned\Tests\VersionedNestedTest\GroupObject.group-1 diff --git a/tests/php/VersionedNestedTest/ChildObject.php b/tests/php/VersionedNestedTest/ChildObject.php new file mode 100644 index 00000000..741856de --- /dev/null +++ b/tests/php/VersionedNestedTest/ChildObject.php @@ -0,0 +1,30 @@ + 'Varchar(255)', + ]; + + /** + * @var array + */ + private static $has_one = [ + 'Group' => GroupObject::class, + ]; +} diff --git a/tests/php/VersionedNestedTest/ColumnObject.php b/tests/php/VersionedNestedTest/ColumnObject.php new file mode 100644 index 00000000..bb698f7b --- /dev/null +++ b/tests/php/VersionedNestedTest/ColumnObject.php @@ -0,0 +1,58 @@ + '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/VersionedNestedTest/GroupObject.php b/tests/php/VersionedNestedTest/GroupObject.php new file mode 100644 index 00000000..6c36ed86 --- /dev/null +++ b/tests/php/VersionedNestedTest/GroupObject.php @@ -0,0 +1,58 @@ + '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/VersionedNestedTest/PrimaryObject.php b/tests/php/VersionedNestedTest/PrimaryObject.php new file mode 100644 index 00000000..2c36ffc6 --- /dev/null +++ b/tests/php/VersionedNestedTest/PrimaryObject.php @@ -0,0 +1,57 @@ + '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', + ]; +}