Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NEW: Stages differ recursive #328

Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
1 change: 1 addition & 0 deletions _config/versionedstate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ SilverStripe\Control\RequestHandler:
SilverStripe\ORM\DataObject:
extensions:
- SilverStripe\Versioned\VersionedStateExtension
- SilverStripe\Versioned\RecursiveStagesExtension
---
Name: versionedstate-test
---
Expand Down
34 changes: 34 additions & 0 deletions src/RecursiveStagesExtension.php
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

namespace SilverStripe\Versioned;

use Psr\Container\NotFoundExceptionInterface;
use SilverStripe\Core\Extension;
use SilverStripe\ORM\DataObject;

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

/**
* Make sure to flush cached data in case the data changes
* Extension point in @see DataObject::onAfterDelete()
*
* @return void
* @throws NotFoundExceptionInterface
*/
public function onAfterDelete(): void
{
RecursiveStagesService::reset();
}
}
22 changes: 22 additions & 0 deletions src/RecursiveStagesInterface.php
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?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
*
* @param DataObject $object
* @return bool
*/
public function stagesDifferRecursive(DataObject $object): bool;
}
187 changes: 187 additions & 0 deletions src/RecursiveStagesService.php
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
<?php

namespace SilverStripe\Versioned;

use Exception;
use Psr\Container\NotFoundExceptionInterface;
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 = [];
}

/**
* @return void
* @throws NotFoundExceptionInterface
*/
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
*
* @param DataObject $object
* @return bool
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved
* @throws Exception
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved
*/
public function stagesDifferRecursive(DataObject $object): bool
{
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved
$cacheKey = $object->getUniqueKey();

if (!array_key_exists($cacheKey, $this->stagesDifferCache)) {
$this->stagesDifferCache[$cacheKey] = $this->determineStagesDifferRecursive($object);
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved
}

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

/**
* Execution ownership hierarchy traversal and inspect individual models
*
* @param DataObject $object
* @return bool
* @throws Exception
*/
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;
}
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved

$models = [$object];

// Compare existing content (perform full ownership traversal)
while ($model = array_shift($models)) {
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved
if ($this->isModelDirty($model)) {
// 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
*
* @param DataObject $object
* @param string $stage
* @return array
* @throws Exception
*/
protected function getOwnedIdentifiers(DataObject $object, string $stage): array
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved
{
$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)) {
// Compose a unique identifier, so we can easily compare models
// Note that we intentionally use base class here, so we can cover the situation where model class changes
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved
$identifiers[] = implode('_', [$model->baseClass(), $model->ID]);
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved
$relatedObjects = $this->getOwnedObjects($model);
$models = array_merge($models, $relatedObjects);
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved
}

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()
*
* @param DataObject|RecursivePublishable $object
* @return array
* @throws Exception
*/
protected function getOwnedObjects(DataObject $object): array
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved
{
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 = sprintf('%s-%s', $object->getUniqueKey(), Versioned::get_stage());
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved

if (!array_key_exists($cacheKey, $this->ownedObjectsCache)) {
$this->ownedObjectsCache[$cacheKey] = $object
// We intentionally avoid recursive traversal here as it's not memory efficient,
// stack based approach is used instead for better performance
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved
->findOwned(false)
->toArray();
}

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

/**
* Determine if model is dirty (has draft changes that need publishing)
* Non-versioned models are supported
*
* @param DataObject $object
* @return bool
*/
protected function isModelDirty(DataObject $object): bool
{
if ($object->hasExtension(Versioned::class)) {
/** @var $object Versioned */
return !$object->isPublished() || $object->stagesDiffer();
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved
}

// Non-versioned models are never dirty
return false;
}
}
17 changes: 17 additions & 0 deletions src/Versioned.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@

use InvalidArgumentException;
use LogicException;
use Psr\Container\NotFoundExceptionInterface;
use SilverStripe\Control\Controller;
use SilverStripe\Control\Cookie;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPRequest;
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 +1999,21 @@ public function stagesDiffer()
return (bool) $stagesDiffer;
}

/**
* Determine if content differs on stages including nested objects
* 'owns' configuration drives the relationship traversal
*
* @return bool
* @throws NotFoundExceptionInterface
*/
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