Skip to content

Commit

Permalink
Merge pull request #328 from silverstripe-terraformers/feature/stages…
Browse files Browse the repository at this point in the history
…-differ-recursive

NEW: Stages differ recursive
  • Loading branch information
GuySartorelli authored Oct 30, 2023
2 parents f5cd4ed + 7eddb6b commit 79bfcc4
Show file tree
Hide file tree
Showing 11 changed files with 540 additions and 1 deletion.
4 changes: 4 additions & 0 deletions _config/versionedownership.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ Name: versionedownership
SilverStripe\ORM\DataObject:
extensions:
RecursivePublishable: SilverStripe\Versioned\RecursivePublishable

SilverStripe\Core\Injector\Injector:
SilverStripe\Versioned\RecursiveStagesInterface:
class: SilverStripe\Versioned\RecursiveStagesService
---
Name: versionedownership-admin
OnlyIf:
Expand Down
19 changes: 18 additions & 1 deletion src/RecursivePublishable.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
use SilverStripe\ORM\FieldType\DBDatetime;
use SilverStripe\ORM\Queries\SQLUpdate;
use SilverStripe\ORM\SS_List;
use SilverStripe\ORM\Tests\MySQLDatabaseTest\Data;

/**
* Provides owns / owned_by and recursive publishing API for all objects.
Expand Down Expand Up @@ -448,4 +447,22 @@ public function onBeforeDuplicate($original, &$doWrite, &$relations)
$owns = $this->owner->config()->get('owns');
$relations = array_intersect($allowed ?? [], $owns);
}

/**
* Make sure to flush cached data in case the data changes
* Extension point in @see DataObject::onAfterWrite()
*/
public function onAfterWrite(): void
{
RecursiveStagesService::reset();
}

/**
* Make sure to flush cached data in case the data changes
* Extension point in @see DataObject::onAfterDelete()
*/
public function onAfterDelete(): void
{
RecursiveStagesService::reset();
}
}
19 changes: 19 additions & 0 deletions src/RecursiveStagesInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace SilverStripe\Versioned;

use SilverStripe\ORM\DataObject;

/**
* Interface RecursiveStagesInterface
*
* Interface for @see RecursiveStagesService to provide the capability to for "smart" dirty model state
* which can cover nested models
*/
interface RecursiveStagesInterface
{
/**
* Determine if content differs on stages including nested objects
*/
public function stagesDifferRecursive(DataObject $object): bool;
}
188 changes: 188 additions & 0 deletions src/RecursiveStagesService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
<?php

namespace SilverStripe\Versioned;

use SilverStripe\Core\Injector\Injectable;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Resettable;
use SilverStripe\ORM\DataObject;

/**
* Functionality for detecting the need of publishing nested objects owned by common parent / ancestor object
*/
class RecursiveStagesService implements RecursiveStagesInterface, Resettable
{
use Injectable;

private array $stagesDifferCache = [];
private array $ownedObjectsCache = [];

public function flushCachedData(): void
{
$this->stagesDifferCache = [];
$this->ownedObjectsCache = [];
}

public static function reset(): void
{
/** @var RecursiveStagesInterface $service */
$service = Injector::inst()->get(RecursiveStagesInterface::class);

if (!$service instanceof RecursiveStagesService) {
// This covers the case where the service is overridden
return;
}

$service->flushCachedData();
}

/**
* Determine if content differs on stages including nested objects
* This method also supports non-versioned models to allow traversal of hierarchy
* which includes both versioned and non-versioned models
* In-memory cache is included and optimised for the most likely lookup profile:
* Non-shared models can have deep ownership hierarchy (i.e. content blocks)
* Shared models are unlikely to have deep ownership hierarchy (i.e Assets)
* This means that we provide in-memory cache only for top level models as we do not expect to query
* the nested models multiple times
*/
public function stagesDifferRecursive(DataObject $object): bool
{
$cacheKey = $object->getUniqueKey();

if (!array_key_exists($cacheKey, $this->stagesDifferCache)) {
$this->stagesDifferCache[$cacheKey] = $this->determineStagesDifferRecursive($object);
}

return $this->stagesDifferCache[$cacheKey];
}

/**
* Execution ownership hierarchy traversal and inspect individual models
* This method use "stack based" recursive traversal as opposed to "true" recursive traversal
* for performance reasons (avoid memory spikes and long execution times due to deeper stack)
*/
protected function determineStagesDifferRecursive(DataObject $object): bool
{
if (!$object->exists()) {
// Model hasn't been saved to DB, so we can just bail out as there is nothing to inspect
return false;
}

// Compare existing content (perform full ownership traversal)
$models = [$object];

// We will keep track of inspected models so we don;t inspect the same model multiple times
// This prevents some edge cases like infinite loops
$identifiers = [];

/** @var DataObject|Versioned $model */
while ($model = array_shift($models)) {
$identifier = $this->getObjectIdentifier($model);

if (in_array($identifier, $identifiers)) {
// We've already inspected this model, so we can skip processing it
// This is to prevent potential infinite loops
continue;
}

// Mark model as inspected
$identifiers[] = $identifier;

if ($model->hasExtension(Versioned::class) && $model->isModifiedOnDraft()) {
// Model is dirty,
// we can return here as there is no need to check the rest of the hierarchy
return true;
}

// Discover and add owned objects for inspection
$relatedObjects = $this->getOwnedObjects($model);
$models = array_merge($models, $relatedObjects);
}

// Compare deleted content,
// this wouldn't be covered in hierarchy traversal as deleted models are no longer present
$draftIdentifiers = $this->getOwnedIdentifiers($object, Versioned::DRAFT);
$liveIdentifiers = $this->getOwnedIdentifiers($object, Versioned::LIVE);

return $draftIdentifiers !== $liveIdentifiers;
}

/**
* Get unique identifiers for all owned objects, so we can easily compare them
*/
protected function getOwnedIdentifiers(DataObject $object, string $stage): array
{
$identifiers = Versioned::withVersionedMode(function () use ($object, $stage): array {
Versioned::set_stage($stage);

/** @var DataObject $stagedObject */
$stagedObject = DataObject::get_by_id($object->ClassName, $object->ID);

if ($stagedObject === null) {
return [];
}

$models = [$stagedObject];
$identifiers = [];

while ($model = array_shift($models)) {
$identifier = $this->getObjectIdentifier($model);

if (in_array($identifier, $identifiers)) {
// We've already inspected this model, so we can skip processing it
// This is to prevent potential infinite loops
continue;
}

$identifiers[] = $identifier;
$relatedObjects = $this->getOwnedObjects($model);
$models = array_merge($models, $relatedObjects);
}

return $identifiers;
});

sort($identifiers, SORT_STRING);

return array_values($identifiers);
}

/**
* This lookup will attempt to find "owned" objects
* This method uses the 'owns' relation, same as @see RecursivePublishable::publishRecursive()
*/
protected function getOwnedObjects(DataObject $object): array
{
/** @var DataObject|Versioned $object */
if (!$object->hasExtension(RecursivePublishable::class)) {
return [];
}

// Add versioned stage to cache key to cover the case where non-versioned model owns versioned models
// In this situation the versioned models can have different cached state which we need to cover
$cacheKey = $object->getUniqueKey() . '-' . Versioned::get_stage();

if (!array_key_exists($cacheKey, $this->ownedObjectsCache)) {
$this->ownedObjectsCache[$cacheKey] = $object
// We intentionally avoid "true" recursive traversal here as it's not performant
// (often the cause of memory usage spikes and longer exeuction time due to deeper stack depth)
// Instead we use "stack based" recursive traversal approach for better performance
// which avoids the nested method calls
->findOwned(false)
->toArray();
}

return $this->ownedObjectsCache[$cacheKey];
}

/**
* This method covers cases where @see DataObject::getUniqueKey() can't be used
* For example when we want to compare models across stages we can't use getUniqueKey()
* as that one contains stage fragments which prevents us from making cross-stage comparison
*/
private function getObjectIdentifier(DataObject $object): string
{
return $object->ClassName . '-' . $object->ID;
}
}
13 changes: 13 additions & 0 deletions src/Versioned.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use SilverStripe\Core\ClassInfo;
use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Extension;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Resettable;
use SilverStripe\Forms\FieldList;
use SilverStripe\ORM\ArrayList;
Expand Down Expand Up @@ -1997,6 +1998,18 @@ public function stagesDiffer()
return (bool) $stagesDiffer;
}

/**
* Determine if content differs on stages including nested objects
* 'owns' configuration drives the relationship traversal
*/
public function stagesDifferRecursive(): bool
{
/** @var RecursiveStagesInterface $service */
$service = Injector::inst()->get(RecursiveStagesInterface::class);

return $service->stagesDifferRecursive($this->owner);
}

/**
* @param string $filter
* @param string $sort
Expand Down
Loading

0 comments on commit 79bfcc4

Please sign in to comment.