From 3cab6b41346cf302156dbb42fe53be2724d6427c Mon Sep 17 00:00:00 2001 From: Steve Boyd Date: Tue, 29 Oct 2024 18:56:11 +1300 Subject: [PATCH] API Split apart Versioned and Staged --- _config/versionedcache.yml | 4 +- _config/versionedextension.yml | 12 +- _config/versionedfiles.yml | 2 +- _config/versionedgridfield.yml | 12 +- _config/versionedownership.yml | 8 +- _config/versionedrequestprocessors.yml | 2 +- _config/versionedstate.yml | 6 +- _config/versionedtests.yml | 2 +- src/{ => Mode}/Caching/ProxyCacheAdapter.php | 4 +- src/{ => Mode}/Caching/ProxyCacheFactory.php | 2 +- .../Caching/VersionedCacheAdapter.php | 4 +- src/{ => Mode}/DataDifferencer.php | 2 +- .../Dev/VersionedTestSessionExtension.php | 6 +- src/{ => Mode}/Dev/VersionedTestState.php | 4 +- src/{ => Mode}/ReadingMode.php | 2 +- .../Traits/VersionedAugmentSomethingTrait.php | 464 +++ src/Mode/Traits/VersionedAugmentSqlTrait.php | 558 +++ src/Mode/Traits/VersionedCanChecksTrait.php | 285 ++ .../VersionedHookImplementationsTrait.php | 93 + src/Mode/Traits/VersionedIsMethodsTrait.php | 155 + .../Traits/VersionedPublicMethodsTrait.php | 598 ++++ .../Traits/VersionedStaticMethodsTrait.php | 562 ++++ src/Mode/Versioned.php | 374 +++ .../VersionedGridFieldState.php | 4 +- src/{ => Mode}/VersionedStateExtension.php | 2 +- src/{ => Staged}/ChangeSet.php | 2 +- src/{ => Staged}/ChangeSetItem.php | 3 +- src/{ => Staged}/RecursivePublishable.php | 6 +- .../RecursivePublishableHandler.php | 3 +- src/{ => Staged}/RecursiveStagesInterface.php | 2 +- src/{ => Staged}/RecursiveStagesService.php | 3 +- src/{ => Staged}/VersionedHTTPMiddleware.php | 3 +- .../VersionedTableDataQueryExtension.php | 3 +- src/Versioned.php | 2984 ----------------- .../GridFieldArchiveAction.php | 3 +- .../GridFieldRestoreAction.php | 4 +- src/{ => Versioned}/RestoreAction.php | 4 +- src/{ => Versioned}/VersionableExtension.php | 2 +- .../VersionedGridFieldArchiveExtension.php | 2 +- .../VersionedGridFieldDetailForm.php | 4 +- .../VersionedGridFieldItemRequest.php | 4 +- .../VersionedGridFieldStateExtension.php | 4 +- src/{ => Versioned}/Versioned_Version.php | 2 +- tests/php/Caching/ProxyCacheAdapterFake.php | 2 +- tests/php/Caching/ProxyCacheFactoryFake.php | 2 +- tests/php/Caching/VersionedCacheTest.php | 2 +- tests/php/ChangeSetItemTest.php | 2 +- .../php/ChangeSetItemTest/UnstagedObject.php | 2 +- .../php/ChangeSetItemTest/VersionedObject.php | 2 +- tests/php/ChangeSetTest.php | 6 +- tests/php/ChangeSetTest/BaseObject.php | 2 +- tests/php/ChangeSetTest/ChangeSetSyncStub.php | 2 +- tests/php/ChangeSetTest/EndObject.php | 2 +- tests/php/ChangeSetTest/EndObjectChild.php | 2 +- tests/php/ChangeSetTest/MidObject.php | 2 +- tests/php/ChangeSetTest/Permissions.php | 2 +- tests/php/ChangeSetTest/UnstagedObject.php | 2 +- tests/php/DataDifferencerTest.php | 2 +- tests/php/DataDifferencerTest/TestObject.php | 2 +- tests/php/GridFieldArchiveActionTest.php | 4 +- tests/php/GridFieldRestoreActionTest.php | 4 +- .../PolymorphicIntermediary.php | 4 +- .../PolymorphicOwned.php | 4 +- .../PolymorphicOwner.php | 4 +- .../PublishRecursive/PublishRecursiveTest.php | 4 +- .../php/PublishRecursive/SlowDummyObject.php | 2 +- .../php/PublishRecursive/SlowDummyParent.php | 2 +- tests/php/RecursiveStagesServiceTest.php | 2 +- tests/php/UnstagedStagedRelationTest.php | 2 +- .../StagedObject.php | 2 +- .../UnstagedObject.php | 2 +- .../UnstagedStagedThroughObject.php | 2 +- .../TestExtension.php | 2 +- .../VersionableExtensionsTest/TestObject.php | 2 +- tests/php/VersionedCascadeDeletesTest.php | 2 +- .../ChildObject.php | 2 +- .../GrandChildObject.php | 2 +- .../ParentObject.php | 2 +- .../RelatedObject.php | 2 +- tests/php/VersionedDeletedVersionsTest.php | 4 +- .../php/VersionedGridFieldItemRequestTest.php | 2 +- .../UnversionedObject.php | 2 +- .../UnversionedOwner.php | 2 +- .../VersionedObject.php | 4 +- .../VersionedOwner.php | 4 +- tests/php/VersionedGridFieldTest.php | 2 +- .../VersionedGridFieldTest/TestController.php | 2 +- tests/php/VersionedLazyLoadingTest.php | 2 +- .../VersionedObject.php | 2 +- .../VersionedSubObject.php | 2 +- .../php/VersionedManyManyThroughListTest.php | 2 +- .../VersionedItem.php | 2 +- .../VersionedJoinObject.php | 2 +- .../VersionedObject.php | 4 +- tests/php/VersionedNumberCacheTest.php | 2 +- tests/php/VersionedOwnershipTest.php | 6 +- .../php/VersionedOwnershipTest/Attachment.php | 4 +- tests/php/VersionedOwnershipTest/Banner.php | 4 +- .../VersionedOwnershipTest/CustomRelation.php | 4 +- tests/php/VersionedOwnershipTest/Image.php | 4 +- .../OwnedByUnversioned.php | 4 +- tests/php/VersionedOwnershipTest/Related.php | 4 +- .../VersionedOwnershipTest/RelatedMany.php | 4 +- .../php/VersionedOwnershipTest/TestObject.php | 4 +- tests/php/VersionedOwnershipTest/TestPage.php | 4 +- .../UnversionedOwner.php | 2 +- tests/php/VersionedStateExtensionTest.php | 2 +- tests/php/VersionedTableTest/House.php | 2 +- tests/php/VersionedTableTest/HouseVisit.php | 2 +- tests/php/VersionedTableTest/Roof.php | 2 +- .../VersionedTableTest/VersionedTableTest.php | 2 +- tests/php/VersionedTableTest/Visitor.php | 2 +- tests/php/VersionedTest.php | 4 +- .../php/VersionedTest/ChangeSetTestObject.php | 4 +- tests/php/VersionedTest/CustomTable.php | 2 +- tests/php/VersionedTest/PublicStage.php | 2 +- .../php/VersionedTest/PublicViaExtension.php | 2 +- tests/php/VersionedTest/SingleStage.php | 2 +- tests/php/VersionedTest/TestObject.php | 4 +- tests/php/VersionedTest/WithIndexes.php | 2 +- .../CompanyOfficeLocation.php | 4 +- .../CompanyPage.php | 4 +- .../GalleryBlock.php | 4 +- .../GalleryBlockItem.php | 4 +- .../GalleryBlockPage.php | 4 +- .../OfficeLocation.php | 4 +- 126 files changed, 3276 insertions(+), 3159 deletions(-) rename src/{ => Mode}/Caching/ProxyCacheAdapter.php (98%) rename src/{ => Mode}/Caching/ProxyCacheFactory.php (96%) rename src/{ => Mode}/Caching/VersionedCacheAdapter.php (81%) rename src/{ => Mode}/DataDifferencer.php (99%) rename src/{ => Mode}/Dev/VersionedTestSessionExtension.php (91%) rename src/{ => Mode}/Dev/VersionedTestState.php (94%) rename src/{ => Mode}/ReadingMode.php (99%) create mode 100644 src/Mode/Traits/VersionedAugmentSomethingTrait.php create mode 100644 src/Mode/Traits/VersionedAugmentSqlTrait.php create mode 100644 src/Mode/Traits/VersionedCanChecksTrait.php create mode 100644 src/Mode/Traits/VersionedHookImplementationsTrait.php create mode 100644 src/Mode/Traits/VersionedIsMethodsTrait.php create mode 100644 src/Mode/Traits/VersionedPublicMethodsTrait.php create mode 100644 src/Mode/Traits/VersionedStaticMethodsTrait.php create mode 100644 src/Mode/Versioned.php rename src/{ => Mode}/VersionedGridFieldState/VersionedGridFieldState.php (98%) rename src/{ => Mode}/VersionedStateExtension.php (98%) rename src/{ => Staged}/ChangeSet.php (99%) rename src/{ => Staged}/ChangeSetItem.php (99%) rename src/{ => Staged}/RecursivePublishable.php (98%) rename src/{ => Staged}/RecursivePublishableHandler.php (89%) rename src/{ => Staged}/RecursiveStagesInterface.php (91%) rename src/{ => Staged}/RecursiveStagesService.php (98%) rename src/{ => Staged}/VersionedHTTPMiddleware.php (96%) rename src/{ => Staged}/VersionedTableDataQueryExtension.php (91%) delete mode 100644 src/Versioned.php rename src/{ => Versioned}/GridFieldArchiveAction.php (98%) rename src/{ => Versioned}/GridFieldRestoreAction.php (98%) rename src/{ => Versioned}/RestoreAction.php (98%) rename src/{ => Versioned}/VersionableExtension.php (95%) rename src/{ => Versioned}/VersionedGridFieldArchiveExtension.php (91%) rename src/{ => Versioned}/VersionedGridFieldDetailForm.php (90%) rename src/{ => Versioned}/VersionedGridFieldItemRequest.php (99%) rename src/{ => Versioned}/VersionedGridFieldStateExtension.php (80%) rename src/{ => Versioned}/Versioned_Version.php (98%) diff --git a/_config/versionedcache.yml b/_config/versionedcache.yml index 5683aff3..81cc454e 100644 --- a/_config/versionedcache.yml +++ b/_config/versionedcache.yml @@ -5,7 +5,7 @@ After: --- SilverStripe\Core\Injector\Injector: SilverStripe\Core\Cache\CacheFactory: - class: 'SilverStripe\Versioned\Caching\ProxyCacheFactory' + class: 'SilverStripe\Versioned\Mode\Caching\ProxyCacheFactory' constructor: args: - container: 'SilverStripe\Versioned\Caching\VersionedCacheAdapter' + container: 'SilverStripe\Versioned\Mode\Caching\VersionedCacheAdapter' diff --git a/_config/versionedextension.yml b/_config/versionedextension.yml index 40a99720..8e82f955 100644 --- a/_config/versionedextension.yml +++ b/_config/versionedextension.yml @@ -3,20 +3,20 @@ Name: versionedextension --- SilverStripe\Core\Injector\Injector: # Versioning only - SilverStripe\Versioned\Versioned.versioned: - class: SilverStripe\Versioned\Versioned + SilverStripe\Versioned\Mode\Versioned.versioned: + class: SilverStripe\Versioned\Mode\Versioned constructor: mode: Versioned # Staging and Versioning - SilverStripe\Versioned\Versioned.stagedversioned: - class: SilverStripe\Versioned\Versioned + SilverStripe\Versioned\Mode\Versioned.stagedversioned: + class: SilverStripe\Versioned\Mode\Versioned constructor: mode: StagedVersioned # Default is alias for .stagedversioned - SilverStripe\Versioned\Versioned: '%$SilverStripe\Versioned\Versioned.stagedversioned' + SilverStripe\Versioned\Mode\Versioned: '%$SilverStripe\Versioned\Mode\Versioned.stagedversioned' --- Name: versioned-table --- SilverStripe\ORM\DataQuery: extensions: - - SilverStripe\Versioned\VersionedTableDataQueryExtension + - SilverStripe\Versioned\Staged\VersionedTableDataQueryExtension diff --git a/_config/versionedfiles.yml b/_config/versionedfiles.yml index 3b7a38d2..68a7dc41 100644 --- a/_config/versionedfiles.yml +++ b/_config/versionedfiles.yml @@ -3,4 +3,4 @@ Name: versionedfiles --- SilverStripe\Assets\File: extensions: - Versioned: SilverStripe\Versioned\Versioned + Versioned: SilverStripe\Versioned\Mode\Versioned diff --git a/_config/versionedgridfield.yml b/_config/versionedgridfield.yml index 23c08919..dd786dd4 100644 --- a/_config/versionedgridfield.yml +++ b/_config/versionedgridfield.yml @@ -3,19 +3,19 @@ Name: versionedgridfield --- SilverStripe\Forms\GridField\GridFieldDetailForm: extensions: - - SilverStripe\Versioned\VersionedGridFieldDetailForm + - SilverStripe\Versioned\Versioned\VersionedGridFieldDetailForm # Add status row to gridfields by default SilverStripe\Forms\GridField\GridFieldConfig_RecordEditor: extensions: - - SilverStripe\Versioned\VersionedGridFieldStateExtension - - SilverStripe\Versioned\VersionedGridFieldArchiveExtension + - SilverStripe\Versioned\Versioned\VersionedGridFieldStateExtension + - SilverStripe\Versioned\Versioned\VersionedGridFieldArchiveExtension SilverStripe\Forms\GridField\GridFieldConfig_Base: extensions: - - SilverStripe\Versioned\VersionedGridFieldStateExtension + - SilverStripe\Versioned\Versioned\VersionedGridFieldStateExtension SilverStripe\Forms\GridField\GridFieldConfig_RelationEditor: extensions: - - SilverStripe\Versioned\VersionedGridFieldStateExtension - - SilverStripe\Versioned\VersionedGridFieldArchiveExtension + - SilverStripe\Versioned\Versioned\VersionedGridFieldStateExtension + - SilverStripe\Versioned\Versioned\VersionedGridFieldArchiveExtension # Enable gridfield extensions for dataobjects by default SilverStripe\ORM\DataObject: versioned_gridfield_extensions: true diff --git a/_config/versionedownership.yml b/_config/versionedownership.yml index a6b43386..26833e2f 100644 --- a/_config/versionedownership.yml +++ b/_config/versionedownership.yml @@ -3,11 +3,11 @@ Name: versionedownership --- SilverStripe\ORM\DataObject: extensions: - RecursivePublishable: SilverStripe\Versioned\RecursivePublishable + RecursivePublishable: SilverStripe\Versioned\Staged\RecursivePublishable SilverStripe\Core\Injector\Injector: - SilverStripe\Versioned\RecursiveStagesInterface: - class: SilverStripe\Versioned\RecursiveStagesService + SilverStripe\Versioned\Staged\RecursiveStagesInterface: + class: SilverStripe\Versioned\Staged\RecursiveStagesService --- Name: versionedownership-admin OnlyIf: @@ -15,4 +15,4 @@ OnlyIf: --- SilverStripe\Admin\LeftAndMain: extensions: - RecursivePublishableHandler: SilverStripe\Versioned\RecursivePublishableHandler + RecursivePublishableHandler: SilverStripe\Versioned\Staged\RecursivePublishableHandler diff --git a/_config/versionedrequestprocessors.yml b/_config/versionedrequestprocessors.yml index 7161f7b6..8cefbd00 100644 --- a/_config/versionedrequestprocessors.yml +++ b/_config/versionedrequestprocessors.yml @@ -8,4 +8,4 @@ SilverStripe\Core\Injector\Injector: SilverStripe\Control\Director: properties: Middlewares: - VersionedMiddleware: '%$SilverStripe\Versioned\VersionedHTTPMiddleware' + VersionedMiddleware: '%$SilverStripe\Versioned\Staged\VersionedHTTPMiddleware' diff --git a/_config/versionedstate.yml b/_config/versionedstate.yml index 1813c8e5..772d4f54 100644 --- a/_config/versionedstate.yml +++ b/_config/versionedstate.yml @@ -3,13 +3,13 @@ Name: versionedstate --- SilverStripe\Control\RequestHandler: extensions: - - SilverStripe\Versioned\VersionedStateExtension + - SilverStripe\Versioned\Mode\VersionedStateExtension SilverStripe\ORM\DataObject: extensions: - - SilverStripe\Versioned\VersionedStateExtension + - SilverStripe\Versioned\Mode\VersionedStateExtension --- Name: versionedstate-test --- SilverStripe\Dev\TestSession: extensions: - - SilverStripe\Versioned\Dev\VersionedTestSessionExtension + - SilverStripe\Versioned\Mode\Dev\VersionedTestSessionExtension diff --git a/_config/versionedtests.yml b/_config/versionedtests.yml index 83cfab94..48882662 100644 --- a/_config/versionedtests.yml +++ b/_config/versionedtests.yml @@ -7,4 +7,4 @@ SilverStripe\Core\Injector\Injector: SilverStripe\Dev\State\SapphireTestState: properties: States: - versioned: '%$SilverStripe\Versioned\Dev\VersionedTestState' + versioned: '%$SilverStripe\Versioned\Mode\Dev\VersionedTestState' diff --git a/src/Caching/ProxyCacheAdapter.php b/src/Mode/Caching/ProxyCacheAdapter.php similarity index 98% rename from src/Caching/ProxyCacheAdapter.php rename to src/Mode/Caching/ProxyCacheAdapter.php index 78b023f6..3a1a2270 100644 --- a/src/Caching/ProxyCacheAdapter.php +++ b/src/Mode/Caching/ProxyCacheAdapter.php @@ -1,10 +1,10 @@ owner; + $baseDataTable = DataObject::getSchema()->baseDataTable($owner); + $migratingVersion = $this->getMigratingVersion(); + if (isset($manipulation[$baseDataTable]['fields'])) { + if ($migratingVersion) { + $manipulation[$baseDataTable]['fields']['Version'] = $migratingVersion; + } + if (isset($manipulation[$baseDataTable]['fields']['Version'])) { + $version = $manipulation[$baseDataTable]['fields']['Version']; + } + } + + // Update all tables + $thisVersion = null; + $tables = array_keys($manipulation ?? []); + foreach ($tables as $table) { + // Make sure that the augmented write is being applied to a table that can be versioned + $class = isset($manipulation[$table]['class']) ? $manipulation[$table]['class'] : null; + if (!$class || !$this->canBeVersioned($class)) { + unset($manipulation[$table]); + continue; + } + + // Get ID field + $id = $manipulation[$table]['id'] + ? $manipulation[$table]['id'] + : $manipulation[$table]['fields']['ID']; + if (!$id) { + throw new InvalidArgumentException( + "Couldn't find ID in " . var_export($manipulation[$table], true) + ); + } + + if ($version < 0 || $this->getNextWriteWithoutVersion()) { + // Putting a Version of -1 is a signal to leave the version table alone, despite their being no version + unset($manipulation[$table]['fields']['Version']); + } else { + // All writes are to draft, only live affect both + $stages = !$this->hasStages() || static::get_stage() === Versioned::LIVE + ? [Versioned::DRAFT, Versioned::LIVE] + : [Versioned::DRAFT]; + $this->augmentWriteVersioned($manipulation, $class, $table, $id, $stages, false); + } + + // Remove "Version" column from subclasses of baseDataClass + if (!$this->hasVersionField($table)) { + unset($manipulation[$table]['fields']['Version']); + } + + // Grab a version number - it should be the same across all tables. + if (isset($manipulation[$table]['fields']['Version'])) { + $thisVersion = $manipulation[$table]['fields']['Version']; + } + + // If we're editing Live, then write to (table)_Live as well as (table) + if ($this->hasStages() && static::get_stage() === Versioned::LIVE) { + $this->augmentWriteStaged($manipulation, $table, $id); + } + } + + // Clear the migration flag + if ($migratingVersion) { + $this->setMigratingVersion(null); + } + + // Add the new version # back into the data object, for accessing + // after this write + if ($thisVersion !== null) { + $owner->Version = str_replace("'", "", $thisVersion ?? ''); + } + } + + /** + * For lazy loaded fields requiring extra sql manipulation, ie versioning. + * + * @param SQLSelect $query + * @param DataQuery $dataQuery + * @param DataObject $dataObject + */ + protected function augmentLoadLazyFields(SQLSelect &$query, DataQuery &$dataQuery = null, $dataObject) + { + // The VersionedMode local variable ensures that this decorator only applies to + // queries that have originated from the Versioned object, and have the Versioned + // metadata set on the query object. This prevents regular queries from + // accidentally querying the *_Versions tables. + $versionedMode = $dataObject->getSourceQueryParam('Versioned.mode'); + $modesToAllowVersioning = ['all_versions', 'latest_versions', 'archive', 'version']; + if (!empty($dataObject->Version) && + (!empty($versionedMode) && in_array($versionedMode, $modesToAllowVersioning ?? [])) + ) { + // This will ensure that augmentSQL will select only the same version as the owner, + // regardless of how this object was initially selected + $versionColumn = $this->owner->getSchema()->sqlColumnForField($this->owner, 'Version'); + $dataQuery->where([ + $versionColumn => $dataObject->Version + ]); + $dataQuery->setQueryParam('Versioned.mode', 'all_versions'); + } + } + + protected function augmentDatabase() + { + $owner = $this->owner; + $class = get_class($owner); + $schema = $owner->getSchema(); + $baseTable = $this->baseTable(); + $classTable = $schema->tableName($owner); + + $isRootClass = $class === $owner->baseClass(); + + // Build a list of suffixes whose tables need versioning + $allSuffixes = []; + $versionableExtensions = (array)$owner->config()->get('versionableExtensions'); + if (count($versionableExtensions ?? [])) { + foreach ($versionableExtensions as $versionableExtension => $suffixes) { + if ($owner->hasExtension($versionableExtension)) { + foreach ((array)$suffixes as $suffix) { + $allSuffixes[$suffix] = $versionableExtension; + } + } + } + } + + // Add the default table with an empty suffix to the list (table name = class name) + $allSuffixes[''] = null; + + foreach ($allSuffixes as $suffix => $extension) { + // Check tables for this build + if ($suffix) { + $suffixBaseTable = "{$baseTable}_{$suffix}"; + $suffixTable = "{$classTable}_{$suffix}"; + } else { + $suffixBaseTable = $baseTable; + $suffixTable = $classTable; + } + + $fields = $schema->databaseFields($class, false); + unset($fields['ID']); + if ($fields) { + $options = Config::inst()->get($class, 'create_table_options'); + $indexes = $schema->databaseIndexes($class, false); + $extensionClass = $allSuffixes[$suffix]; + if ($suffix && ($extension = $owner->getExtensionInstance($extensionClass))) { + if (!$extension instanceof VersionableExtension) { + throw new LogicException( + "Extension {$extensionClass} must implement VersionableExtension" + ); + } + /** @var Extension&VersionableExtension $extension */ + // Allow versionable extension to customise table fields and indexes + try { + $extension->setOwner($owner); + if ($extension->isVersionedTable($suffixTable)) { + $extension->updateVersionableFields($suffix, $fields, $indexes); + } + } finally { + $extension->clearOwner(); + } + } + + // Build _Live table + if ($this->hasStages()) { + $liveTable = $this->stageTable($suffixTable, Versioned::LIVE); + DB::require_table($liveTable, $fields, $indexes, false, $options); + } + + // Build _Versions table + //Unique indexes will not work on versioned tables, so we'll convert them to standard indexes: + $nonUniqueIndexes = $this->uniqueToIndex($indexes); + if ($isRootClass) { + // Create table for all versions + $versionFields = array_merge( + Config::inst()->get(static::class, 'db_for_versions_table'), + (array)$fields + ); + $versionIndexes = array_merge( + Config::inst()->get(static::class, 'indexes_for_versions_table'), + (array)$nonUniqueIndexes + ); + } else { + // Create fields for any tables of subclasses + $versionFields = array_merge( + [ + "RecordID" => "Int", + "Version" => "Int", + ], + (array)$fields + ); + $versionIndexes = array_merge( + [ + 'RecordID_Version' => [ + 'type' => 'unique', + 'columns' => ['RecordID', 'Version'] + ], + 'RecordID' => [ + 'type' => 'index', + 'columns' => ['RecordID'], + ], + 'Version' => [ + 'type' => 'index', + 'columns' => ['Version'], + ], + ], + (array)$nonUniqueIndexes + ); + } + + // Cleanup any orphans + $this->cleanupVersionedOrphans("{$suffixBaseTable}_Versions", "{$suffixTable}_Versions"); + + // Build versions table + DB::require_table("{$suffixTable}_Versions", $versionFields, $versionIndexes, true, $options); + } else { + DB::dont_require_table("{$suffixTable}_Versions"); + if ($this->hasStages()) { + $liveTable = $this->stageTable($suffixTable, Versioned::LIVE); + DB::dont_require_table($liveTable); + } + } + } + } + + /** + * Cleanup orphaned records in the _Versions table + * + * @param string $baseTable base table to use as authoritative source of records + * @param string $childTable Sub-table to clean orphans from + */ + protected function cleanupVersionedOrphans($baseTable, $childTable) + { + // Avoid if disabled + if ($this->owner->config()->get('versioned_orphans_disabled')) { + return; + } + + // Skip if tables are the same (ignore case) + if (strcasecmp($childTable ?? '', $baseTable ?? '') === 0) { + return; + } + + // Skip if child table doesn't exist + // If it does, ensure query case matches found case + $tables = DB::get_schema()->tableList(); + if (!array_key_exists(strtolower($childTable ?? ''), $tables ?? [])) { + return; + } + $childTable = $tables[strtolower($childTable)]; + + // Select all orphaned version records + $orphanedQuery = SQLSelect::create() + ->selectField("\"{$childTable}\".\"ID\"") + ->setFrom("\"{$childTable}\""); + + // If we have a parent table limit orphaned records + // to only those that exist in this + if (array_key_exists(strtolower($baseTable ?? ''), $tables ?? [])) { + // Ensure we match db table case + $baseTable = $tables[strtolower($baseTable)]; + $orphanedQuery + ->addLeftJoin( + $baseTable, + "\"{$childTable}\".\"RecordID\" = \"{$baseTable}\".\"RecordID\" + AND \"{$childTable}\".\"Version\" = \"{$baseTable}\".\"Version\"" + ) + ->addWhere("\"{$baseTable}\".\"ID\" IS NULL"); + } + + $count = $orphanedQuery->count(); + if ($count > 0) { + DB::alteration_message("Removing {$count} orphaned versioned records", "deleted"); + $ids = $orphanedQuery->execute()->column(); + foreach ($ids as $id) { + DB::prepared_query("DELETE FROM \"{$childTable}\" WHERE \"ID\" = ?", [$id]); + } + } + } + + /** + * Helper for augmentDatabase() to find unique indexes and convert them to non-unique + * + * @param array $indexes The indexes to convert + * @return array $indexes + */ + private function uniqueToIndex($indexes) + { + foreach ($indexes as &$spec) { + if ($spec['type'] === 'unique') { + $spec['type'] = 'index'; + } + } + return $indexes; + } + + /** + * Generates a ($table)_version DB manipulation and injects it into the current $manipulation + * + * @param array $manipulation Source manipulation data + * @param string $class Class + * @param string $table Table Table for this class + * @param int $recordID ID of record to version + * @param array|string $stages Stage or array of affected stages + * @param bool $isDelete Set to true of version is created from a deletion + */ + protected function augmentWriteVersioned(&$manipulation, $class, $table, $recordID, $stages, $isDelete = false) + { + $schema = DataObject::getSchema(); + $baseDataClass = $schema->baseDataClass($class); + $baseDataTable = $schema->tableName($baseDataClass); + + // Set up a new entry in (table)_Versions + $newManipulation = [ + "command" => "insert", + "fields" => isset($manipulation[$table]['fields']) ? $manipulation[$table]['fields'] : [], + "class" => $class, + ]; + + // Add any extra, unchanged fields to the version record. + $data = DB::prepared_query("SELECT * FROM \"{$table}\" WHERE \"ID\" = ?", [$recordID])->record(); + if ($data) { + $fields = $schema->databaseFields($class, false); + if (is_array($fields)) { + $data = array_intersect_key($data ?? [], $fields); + + foreach ($data as $k => $v) { + // If the value is not set at all in the manipulation currently, use the existing value from the database + if (!array_key_exists($k, $newManipulation['fields'] ?? [])) { + $newManipulation['fields'][$k] = $v; + } + } + } + } + + // Ensure that the ID is instead written to the RecordID field + $newManipulation['fields']['RecordID'] = $recordID; + unset($newManipulation['fields']['ID']); + + // Generate next version ID to use + $nextVersion = 0; + if ($recordID) { + $nextVersion = DB::prepared_query( + "SELECT MAX(\"Version\") + 1 + FROM \"{$baseDataTable}_Versions\" WHERE \"RecordID\" = ?", + [$recordID] + )->value(); + } + $nextVersion = $nextVersion ?: 1; + + if ($class === $baseDataClass) { + // Write AuthorID for baseclass + if ((Security::getCurrentUser())) { + $userID = Security::getCurrentUser()->ID; + } else { + $userID = 0; + } + $wasPublished = (int)in_array(Versioned::LIVE, (array)$stages); + $wasDraft = (int)in_array(Versioned::DRAFT, (array)$stages); + $newManipulation['fields'] = array_merge( + $newManipulation['fields'], + [ + 'AuthorID' => $userID, + 'PublisherID' => $wasPublished ? $userID : 0, + 'WasPublished' => $wasPublished, + 'WasDraft' => $wasDraft, + 'WasDeleted' => (int)$isDelete, + ] + ); + + // Update main table version if not previously known + if (isset($manipulation[$table]['fields'])) { + $manipulation[$table]['fields']['Version'] = $nextVersion; + } + } + + // Update _Versions table manipulation + $newManipulation['fields']['Version'] = $nextVersion; + $manipulation["{$table}_Versions"] = $newManipulation; + } + + /** + * Rewrite the given manipulation to update the selected (non-default) stage + * + * @param array $manipulation Source manipulation data + * @param string $table Name of table + * @param int $recordID ID of record to version + */ + protected function augmentWriteStaged(&$manipulation, $table, $recordID) + { + // If the record has already been inserted in the (table), get rid of it. + if ($manipulation[$table]['command'] == 'insert') { + DB::prepared_query( + "DELETE FROM \"{$table}\" WHERE \"ID\" = ?", + [$recordID] + ); + } + + $newTable = $this->stageTable($table, Versioned::get_stage()); + $manipulation[$newTable] = $manipulation[$table]; + } + + /** + * Adds a WasDeleted=1 version entry for this record, and records any stages + * the deletion applies to + * + * @param string[]|string $stages Stage or array of affected stages + */ + protected function createDeletedVersion($stages = []) + { + // Skip if suppressed by parent delete + if (!$this->getDeleteWritesVersion()) { + return; + } + // Prepare manipulation + $baseTable = $this->owner->baseTable(); + $now = DBDatetime::now()->Rfc2822(); + // Ensure all fixed_fields are specified + $manipulation = [ + $baseTable => [ + 'fields' => [ + 'ID' => $this->owner->ID, + 'LastEdited' => $now, + 'Created' => $this->owner->Created ?: $now, + 'ClassName' => $this->owner->ClassName, + ], + ], + ]; + // Prepare "deleted" augment write + $this->augmentWriteVersioned( + $manipulation, + $this->owner->baseClass(), + $baseTable, + $this->owner->ID, + $stages, + true + ); + unset($manipulation[$baseTable]); + $this->owner->extend('augmentWriteDeletedVersion', $manipulation, $stages); + DB::manipulate($manipulation); + $this->owner->Version = $manipulation["{$baseTable}_Versions"]['fields']['Version']; + $this->owner->extend('onAfterVersionDelete'); + } +} diff --git a/src/Mode/Traits/VersionedAugmentSqlTrait.php b/src/Mode/Traits/VersionedAugmentSqlTrait.php new file mode 100644 index 00000000..88c6f73e --- /dev/null +++ b/src/Mode/Traits/VersionedAugmentSqlTrait.php @@ -0,0 +1,558 @@ + $value) { + $dataQuery->setQueryParam($key, $value); + } + } + } + + /** + * Updates query parameters of relations attached to versioned dataobjects + * + * @param array $params + */ + protected function updateInheritableQueryParams(&$params) + { + // Skip if versioned isn't set + if (!isset($params['Versioned.mode'])) { + return; + } + + // Adjust query based on original selection criterea + switch ($params['Versioned.mode']) { + case 'all_versions': + { + // Versioned.mode === all_versions doesn't inherit very well, so default to stage + $params['Versioned.mode'] = 'stage'; + $params['Versioned.stage'] = Versioned::DRAFT; + break; + } + case 'version': + { + // If we selected this object from a specific version, we need + // to find the date this version was published, and ensure + // inherited queries select from that date. + $version = $params['Versioned.version']; + $dateAndStage = $this->getLastEditedAndStageForVersion($version); + + // Filter related objects at the same date as this version + unset($params['Versioned.version']); + if ($dateAndStage) { + list($date, $stage) = $dateAndStage; + $params['Versioned.mode'] = 'archive'; + $params['Versioned.date'] = $date; + $params['Versioned.stage'] = $stage; + } else { + // Fallback to default + $params['Versioned.mode'] = 'stage'; + $params['Versioned.stage'] = Versioned::DRAFT; + } + break; + } + } + } + + /** + * Augment the the SQLSelect that is created by the DataQuery + * + * See {@see augmentLazyLoadFields} for lazy-loading applied prior to this. + * + * @param SQLSelect $query + * @param DataQuery|null $dataQuery + * @throws InvalidArgumentException + */ + protected function augmentSQL(SQLSelect $query, DataQuery $dataQuery = null) + { + if (!$dataQuery) { + return; + } + + // Ensure query mode exists + $versionedMode = $dataQuery->getQueryParam('Versioned.mode'); + if (!$versionedMode) { + return; + } + switch ($versionedMode) { + case 'stage': + $this->augmentSQLStage($query, $dataQuery); + break; + case 'stage_unique': + $this->augmentSQLStageUnique($query, $dataQuery); + break; + case 'archive': + $this->augmentSQLVersionedArchive($query, $dataQuery); + break; + case 'latest_version_single': + $this->augmentSQLVersionedLatestSingle($query, $dataQuery); + break; + case 'latest_versions': + $this->augmentSQLVersionedLatest($query, $dataQuery); + break; + case 'version': + $this->augmentSQLVersionedVersion($query, $dataQuery); + break; + case 'all_versions': + $this->augmentSQLVersionedAll($query); + break; + default: + throw new InvalidArgumentException("Bad value for query parameter Versioned.mode: {$versionedMode}"); + } + } + + /** + * Get modified date and stage for the given version + * + * @param int $version + * @return array A list containing 0 => LastEdited, 1 => Stage + */ + protected function getLastEditedAndStageForVersion($version) + { + // Cache key + $baseTable = $this->baseTable(); + $id = $this->owner->ID; + $key = "{$baseTable}#{$id}/{$version}"; + + // Check cache + if (isset($this->versionModifiedCache[$key])) { + return $this->versionModifiedCache[$key]; + } + + // Build query + $table = "\"{$baseTable}_Versions\""; + $query = SQLSelect::create(['"LastEdited"', '"WasPublished"'], $table) + ->addWhere([ + "{$table}.\"RecordID\"" => $id, + "{$table}.\"Version\"" => $version + ]); + $result = $query->execute()->record(); + if (!$result) { + return null; + } + $list = [ + $result['LastEdited'], + $result['WasPublished'] ? Versioned::LIVE : Versioned::DRAFT, + ]; + $this->versionModifiedCache[$key] = $list; + return $list; + } + + /** + * Reading a specific stage (Stage or Live) + * + * @param SQLSelect $query + * @param DataQuery $dataQuery + */ + protected function augmentSQLStage(SQLSelect $query, DataQuery $dataQuery) + { + if (!$this->hasStages()) { + return; + } + $stage = $dataQuery->getQueryParam('Versioned.stage'); + ReadingMode::validateStage($stage); + if ($stage === Versioned::DRAFT) { + return; + } + // Rewrite all tables to select from the live version + foreach ($query->getFrom() as $table => $dummy) { + if (!$this->isTableVersioned($table)) { + continue; + } + $stageTable = $this->stageTable($table, $stage); + $query->renameTable($table, $stageTable); + } + } + + /** + * Reading a specific stage, but only return items that aren't in any other stage + * + * @param SQLSelect $query + * @param DataQuery $dataQuery + */ + protected function augmentSQLStageUnique(SQLSelect $query, DataQuery $dataQuery) + { + if (!$this->hasStages()) { + return; + } + // Set stage first + $this->augmentSQLStage($query, $dataQuery); + + // Now exclude any ID from any other stage. + $stage = $dataQuery->getQueryParam('Versioned.stage'); + $excludingStage = $stage === Versioned::DRAFT ? Versioned::LIVE : Versioned::DRAFT; + + // Note that we double rename to avoid the regular stage rename + // renaming all subquery references to be Versioned.stage + $tempName = 'ExclusionarySource_' . $excludingStage; + $excludingTable = $this->baseTable($excludingStage); + $baseTable = $this->baseTable($stage); + $query->addWhere("\"{$baseTable}\".\"ID\" NOT IN (SELECT \"ID\" FROM \"{$tempName}\")"); + $query->renameTable($tempName, $excludingTable); + } + + /** + * Augment SQL to select from `_Versions` table instead. + * + * @param SQLSelect $query + * @param bool $filterDeleted Whether to exclude deleted entries or not + */ + protected function augmentSQLVersioned(SQLSelect $query, bool $filterDeleted = true) + { + $baseTable = $this->baseTable(); + foreach ($query->getFrom() as $alias => $join) { + if (!$this->isTableVersioned($alias)) { + continue; + } + + if ($alias != $baseTable) { + // Make sure join includes version as well + $query->setJoinFilter( + $alias, + "\"{$alias}_Versions\".\"RecordID\" = \"{$baseTable}_Versions\".\"RecordID\"" + . " AND \"{$alias}_Versions\".\"Version\" = \"{$baseTable}_Versions\".\"Version\"" + ); + } + + // Rewrite all usages of `Table` to `Table_Versions` + $query->renameTable($alias, $alias . '_Versions'); + // However, add an alias back to the base table in case this must later be joined. + // See ApplyVersionFilters for example which joins _Versions back onto draft table. + $query->renameTable($alias . '_Draft', $alias); + } + + // Add all _Versions columns + foreach (Config::inst()->get(static::class, 'db_for_versions_table') as $name => $type) { + $query->selectField(sprintf('"%s_Versions"."%s"', $baseTable, $name), $name); + } + + // Alias the record ID as the row ID, and ensure ID filters are aliased correctly + $query->selectField("\"{$baseTable}_Versions\".\"RecordID\"", "ID"); + $query->replaceText("\"{$baseTable}_Versions\".\"ID\"", "\"{$baseTable}_Versions\".\"RecordID\""); + + // However, if doing count, undo rewrite of "ID" column + $query->replaceText( + "count(DISTINCT \"{$baseTable}_Versions\".\"RecordID\")", + "count(DISTINCT \"{$baseTable}_Versions\".\"ID\")" + ); + + // Filter deleted versions, which are all unqueryable + if ($filterDeleted) { + $query->addWhere(["\"{$baseTable}_Versions\".\"WasDeleted\"" => 0]); + } + } + + /** + * Prepare a sub-select for determining latest versions of records on the base table. This is used as either an + * inner join or sub-select on the base query + * + * @param SQLSelect $baseQuery + * @param DataQuery $dataQuery + * @return SQLSelect + */ + protected function prepareMaxVersionSubSelect(SQLSelect $baseQuery, DataQuery $dataQuery) + { + $baseTable = $this->baseTable(); + + // Create a sub-select that we determine latest versions + $subSelect = SQLSelect::create( + ['LatestVersion' => "MAX(\"{$baseTable}_Versions_Latest\".\"Version\")"], + [$baseTable . '_Versions_Latest' => "\"{$baseTable}_Versions\""] + ); + + $subSelect->renameTable($baseTable, "{$baseTable}_Versions"); + + // Determine the base table of the existing query + $baseFrom = $baseQuery->getFrom(); + $baseTable = trim(reset($baseFrom) ?? '', '"'); + + // And then the name of the base table in the new query + $newFrom = $subSelect->getFrom(); + $newTable = trim(key($newFrom ?? []) ?? '', '"'); + + // Parse "where" conditions to find those appropriate to be "promoted" into an inner join + // We can ONLY promote a filter on the primary key of the base table. Any other conditions will make the + // version returned incorrect, as we are filtering out version that may be the latest (and correct) version + foreach ($baseQuery->getWhere() as $condition) { + if (is_object($condition)) { + continue; + } + $conditionClause = key($condition ?? []); + // Pull out the table and field for this condition. We'll skip anything we can't parse + if (preg_match('/^"([^"]+)"\."([^"]+)"/', $conditionClause ?? '', $matches) !== 1) { + continue; + } + + $table = $matches[1]; + $field = $matches[2]; + + if ($table !== $baseTable || $field !== 'RecordID') { + continue; + } + + // Rename conditions on the base table to the new alias + $conditionClause = preg_replace( + '/^"([^"]+)"\./', + "\"{$newTable}\".", + $conditionClause ?? '' + ); + + $subSelect->addWhere([$conditionClause => reset($condition)]); + } + + $shouldApplySubSelectAsCondition = $this->shouldApplySubSelectAsCondition($baseQuery); + + $this->owner->extend( + 'augmentMaxVersionSubSelect', + $subSelect, + $dataQuery, + $shouldApplySubSelectAsCondition + ); + + return $subSelect; + } + + /** + * Indicates if a subquery filtering versioned records should apply as a condition instead of an inner join + * + * @param SQLSelect $baseQuery + */ + protected function shouldApplySubSelectAsCondition(SQLSelect $baseQuery) + { + $baseTable = $this->baseTable(); + + $shouldApply = + $baseQuery->getLimit() === 1 || Config::inst()->get(static::class, 'use_conditions_over_inner_joins'); + + $this->owner->extend('updateApplyVersionedFiltersAsConditions', $shouldApply, $baseQuery, $baseTable); + + return $shouldApply; + } + + /** + * Filter the versioned history by a specific date and archive stage + * + * @param SQLSelect $query + * @param DataQuery $dataQuery + */ + protected function augmentSQLVersionedArchive(SQLSelect $query, DataQuery $dataQuery) + { + $baseTable = $this->baseTable(); + $date = $dataQuery->getQueryParam('Versioned.date'); + if (!$date) { + throw new InvalidArgumentException("Invalid archive date"); + } + + // Query against _Versions table first + $this->augmentSQLVersioned($query); + + // Validate stage + $stage = $dataQuery->getQueryParam('Versioned.stage'); + ReadingMode::validateStage($stage); + + $subSelect = $this->prepareMaxVersionSubSelect($query, $dataQuery); + + $subSelect->addWhere(["\"{$baseTable}_Versions_Latest\".\"LastEdited\" <= ?" => $date]); + + // Filter on appropriate stage column in addition to date + if ($this->hasStages()) { + $stageColumn = $stage === Versioned::LIVE + ? 'WasPublished' + : 'WasDraft'; + $subSelect->addWhere("\"{$baseTable}_Versions_Latest\".\"{$stageColumn}\" = 1"); + } + + if ($this->shouldApplySubSelectAsCondition($query)) { + $subSelect->addWhere( + "\"{$baseTable}_Versions_Latest\".\"RecordID\" = \"{$baseTable}_Versions\".\"RecordID\"" + ); + + $query->addWhere([ + "\"{$baseTable}_Versions\".\"Version\" = ({$subSelect->sql($params)})" => $params, + ]); + + return; + } + + $subSelect->addSelect("\"{$baseTable}_Versions_Latest\".\"RecordID\""); + $subSelect->addGroupBy("\"{$baseTable}_Versions_Latest\".\"RecordID\""); + + // Join on latest version filtered by date + $query->addInnerJoin( + '(' . $subSelect->sql($params) . ')', + <<getQueryParam('Versioned.id'); + if (!$id) { + throw new InvalidArgumentException("Invalid id"); + } + + // Query against _Versions table first + $this->augmentSQLVersioned($query); + + $baseTable = $this->baseTable(); + + $query->addWhere(["\"$baseTable\".\"RecordID\"" => $id]); + $query->setOrderBy("Version DESC"); + $query->setLimit(1); + } + + /** + * Return latest version instances, regardless of whether they are on a particular stage. + * This provides "show all, including deleted" functionality. + * + * Note: latest_version ignores deleted versions, and will select the latest non-deleted + * version. + * + * @param SQLSelect $query + * @param DataQuery $dataQuery + */ + protected function augmentSQLVersionedLatest(SQLSelect $query, DataQuery $dataQuery) + { + // Query against _Versions table first + $this->augmentSQLVersioned($query); + + // Join and select only latest version + $baseTable = $this->baseTable(); + $subSelect = $this->prepareMaxVersionSubSelect($query, $dataQuery); + + $subSelect->addWhere("\"{$baseTable}_Versions_Latest\".\"WasDeleted\" = 0"); + + if ($this->shouldApplySubSelectAsCondition($query)) { + $subSelect->addWhere( + "\"{$baseTable}_Versions_Latest\".\"RecordID\" = \"{$baseTable}_Versions\".\"RecordID\"" + ); + + $query->addWhere([ + "\"{$baseTable}_Versions\".\"Version\" = ({$subSelect->sql($params)})" => $params, + ]); + + return; + } + + $subSelect->addSelect("\"{$baseTable}_Versions_Latest\".\"RecordID\""); + $subSelect->addGroupBy("\"{$baseTable}_Versions_Latest\".\"RecordID\""); + + // Join on latest version filtered by date + $query->addInnerJoin( + '(' . $subSelect->sql($params) . ')', + <<getQueryParam('Versioned.version'); + if (!$version) { + throw new InvalidArgumentException("Invalid version"); + } + + // Query against _Versions table first + $this->augmentSQLVersioned($query); + + // Add filter on version field + $baseTable = $this->baseTable(); + $query->addWhere([ + "\"{$baseTable}_Versions\".\"Version\"" => $version, + ]); + } + + /** + * If all versions are requested, ensure that records are sorted by this field + * + * @param SQLSelect $query + */ + protected function augmentSQLVersionedAll(SQLSelect $query) + { + // Query against _Versions table first + $this->augmentSQLVersioned($query, false); + + $baseTable = $this->baseTable(); + $query->addOrderBy("\"{$baseTable}_Versions\".\"Version\""); + } + + /** + * Determine if the given versioned table is a part of the sub-tree of the current dataobject + * This helps prevent rewriting of other tables that get joined in, in particular, many_many tables + * + * @param string $table + * @return bool True if this table should be versioned + */ + protected function isTableVersioned($table) + { + $schema = DataObject::getSchema(); + $tableClass = $schema->tableClass($table); + if (empty($tableClass)) { + return false; + } + + // Check that this class belongs to the same tree + $baseClass = $schema->baseDataClass($this->owner); + if (!is_a($tableClass, $baseClass ?? '', true)) { + return false; + } + + // Check that this isn't a derived table + // (e.g. _Live, or a many_many table) + $mainTable = $schema->tableName($tableClass); + if ($mainTable !== $table) { + return false; + } + + return true; + } +} diff --git a/src/Mode/Traits/VersionedCanChecksTrait.php b/src/Mode/Traits/VersionedCanChecksTrait.php new file mode 100644 index 00000000..ae25ef95 --- /dev/null +++ b/src/Mode/Traits/VersionedCanChecksTrait.php @@ -0,0 +1,285 @@ +owner; + $extended = $owner->extendedCan('canPublish', $member); + if ($extended !== null) { + return $extended; + } + + // Default to relying on edit permission + return $owner->canEdit($member); + } + + protected function extendCanPublish() + { + // prevent canPublish() from extending itself + return null; + } + + /** + * Check if the current user can delete this record from live + * + * @param null $member + * @return mixed + */ + public function canUnpublish($member = null) + { + if (!$member) { + $member = Security::getCurrentUser(); + } + + if (Permission::checkMember($member, "ADMIN")) { + return true; + } + + // Standard mechanism for accepting permission changes from extensions + $owner = $this->owner; + $extended = $owner->extendedCan('canUnpublish', $member); + if ($extended !== null) { + return $extended; + } + + // Default to relying on canPublish + return $owner->canPublish($member); + } + + protected function extendCanUnpublish() + { + // prevent canUnpublish() extending itself + return null; + } + + /** + * Check if the current user is allowed to archive this record. + * + * We're intentionally using the canDelete check for archiving, + * since there's no concept of "deleting" a versioned record + * and having separate permission checks was confusing and easy + * to forget. + */ + public function canDelete($member = null): ?bool + { + // If the user isn't allowed to unpublish, they're definitely + // not allowed to archive live content. + if ($this->hasStages() && $this->isPublished() && !$this->getOwner()->canUnpublish($member)) { + return false; + } + return null; + } + + /** + * Check if the user can revert this record to live + * + * @param Member $member + * @return bool + */ + public function canRevertToLive($member = null) + { + $owner = $this->owner; + + // Can't revert if not on live + if (!$owner->isPublished()) { + return false; + } + + if (!$member) { + $member = Security::getCurrentUser(); + } + + if (Permission::checkMember($member, "ADMIN")) { + return true; + } + + // Standard mechanism for accepting permission changes from extensions + $extended = $owner->extendedCan('canRevertToLive', $member); + if ($extended !== null) { + return $extended; + } + + // Default to canEdit + return $owner->canEdit($member); + } + + protected function extendCanRevertToLive() + { + // Prevent canRevertToLive() extending itself + return null; + } + + /** + * Check if the user can restore this record to draft + * + * @param Member $member + * @return bool + */ + public function canRestoreToDraft($member = null) + { + $owner = $this->owner; + + if (!$member) { + $member = Security::getCurrentUser(); + } + + if (Permission::checkMember($member, "ADMIN")) { + return true; + } + + // Standard mechanism for accepting permission changes from extensions + $extended = $owner->extendedCan('canRestoreToDraft', $member); + if ($extended !== null) { + return $extended; + } + + // Default to canEdit + return $owner->canEdit($member); + } + + protected function extendcanRestoreToDraft() + { + // Prevent canRestoreToDraft() extending itself + return null; + } + + /** + * Extend permissions to include additional security for objects that are not published to live. + * + * @param Member $member + * @return bool|null + */ + protected function canView($member = null) + { + // Invoke default version-gnostic canView + if ($this->owner->canViewVersioned($member) === false) { + return false; + } + return null; + } + + /** + * Determine if there are any additional restrictions on this object for the given reading version. + * + * Override this in a subclass to customise any additional effect that Versioned applies to canView. + * + * This is expected to be called by canView, and thus is only responsible for denying access if + * the default canView would otherwise ALLOW access. Thus it should not be called in isolation + * as an authoritative permission check. + * + * This has the following extension points: + * - canViewDraft is invoked if Mode = stage and Stage = stage + * - canViewArchived is invoked if Mode = archive + * + * @param Member $member + * @return bool False is returned if the current viewing mode denies visibility + */ + public function canViewVersioned($member = null) + { + // Bypass when live stage + $owner = $this->owner; + + // Bypass if site is unsecured + if (!Versioned::get_draft_site_secured()) { + return true; + } + + // Get reading mode from source query (or current mode) + $readingParams = $owner->getSourceQueryParams() + // Guess record mode from current reading mode instead + ?: ReadingMode::toDataQueryParams(static::get_reading_mode()); + + // If this is the live record we can view it + if (isset($readingParams["Versioned.mode"]) + && $readingParams["Versioned.mode"] === 'stage' + && $readingParams["Versioned.stage"] === Versioned::LIVE + ) { + return true; + } + + // Bypass if record doesn't have a live stage + if (!$this->hasStages()) { + return true; + } + + // If we weren't definitely loaded from live, and we can't view non-live content, we need to + // check to make sure this version is the live version and so can be viewed + $latestVersion = Versioned::get_versionnumber_by_stage($this->owner->baseClass(), Versioned::LIVE, $owner->ID); + if ($latestVersion == $owner->Version) { + // Even if this is loaded from a non-live stage, this is the live version + return true; + } + + // If stages are synchronised treat this as the live stage + if (!$this->stagesDiffer()) { + return true; + } + + // Extend versioned behaviour + $extended = $owner->extendedCan('canViewNonLive', $member); + if ($extended !== null) { + return (bool)$extended; + } + + // Fall back to default permission check + $permissions = Config::inst()->get(get_class($owner), 'non_live_permissions'); + $check = Permission::checkMember($member, $permissions); + return (bool)$check; + } + + /** + * Determines canView permissions for the latest version of this object on a specific stage. + * Usually the stage is read from {@link Versioned::current_stage()}. + * + * This method should be invoked by user code to check if a record is visible in the given stage. + * + * This method should not be called via ->extend('canViewStage'), but rather should be + * overridden in the extended class. + * + * @param string $stage + * @param Member $member + * @return bool + */ + public function canViewStage($stage = Versioned::LIVE, $member = null) + { + return static::withVersionedMode(function () use ($stage, $member) { + Versioned::set_stage($stage); + + $owner = $this->owner; + $baseClass = DataObject::getSchema()->baseDataClass($owner); + $versionFromStage = DataObject::get($baseClass)->byID($owner->ID); + + return $versionFromStage ? $versionFromStage->canView($member) : false; + }); + } +} diff --git a/src/Mode/Traits/VersionedHookImplementationsTrait.php b/src/Mode/Traits/VersionedHookImplementationsTrait.php new file mode 100644 index 00000000..230a13e9 --- /dev/null +++ b/src/Mode/Traits/VersionedHookImplementationsTrait.php @@ -0,0 +1,93 @@ +setNextWriteWithoutVersion(false); + } + + /** + * If a write was skipped, then we need to ensure that we don't leave a + * migrateVersion() value lying around for the next write. + */ + protected function onAfterSkippedWrite() + { + $this->setMigratingVersion(null); + } + + protected function onAfterDelete() + { + // Create deleted record for current stage + $this->createDeletedVersion(static::get_stage()); + } + + /** + * Hook into {@link Hierarchy::prepopulateTreeDataCache}. + * + * @param DataList|array $recordList The list of records to prepopulate caches for. Null for all records. + * @param array $options A map of hints about what should be cached. "numChildrenMethod" and + * "childrenMethod" are allowed keys. + */ + protected function onPrepopulateTreeDataCache($recordList = null, array $options = []) + { + $idList = is_array($recordList) ? $recordList : + ($recordList instanceof DataList ? $recordList->column('ID') : null); + Versioned::prepopulate_versionnumber_cache($this->owner->baseClass(), Versioned::DRAFT, $idList); + Versioned::prepopulate_versionnumber_cache($this->owner->baseClass(), Versioned::LIVE, $idList); + } + + /** + * @param array $labels + */ + protected function updateFieldLabels(&$labels) + { + $labels['Versions'] = _t(__CLASS__ . '.has_many_Versions', 'Versions', 'Past Versions of this record'); + } + + /** + * @param FieldList $fields + */ + protected function updateCMSFields(FieldList $fields) + { + // remove the version field from the CMS as this should be left + // entirely up to the extension (not the cms user). + $fields->removeByName('Version'); + } + + /** + * Ensure version ID is reset to 0 on duplicate + * + * @param DataObject $source Record this was duplicated from + * @param bool $doWrite + */ + protected function onBeforeDuplicate($source, $doWrite) + { + $this->owner->Version = 0; + } + + protected function onFlushCache() + { + Versioned::$cache_versionnumber = []; + $this->versionModifiedCache = []; + } + + /** + * Return a piece of text to keep DataObject cache keys appropriately specific. + * + * @return string + */ + protected function cacheKeyComponent() + { + return 'versionedmode-' . static::get_reading_mode(); + } +} diff --git a/src/Mode/Traits/VersionedIsMethodsTrait.php b/src/Mode/Traits/VersionedIsMethodsTrait.php new file mode 100644 index 00000000..0a634ff1 --- /dev/null +++ b/src/Mode/Traits/VersionedIsMethodsTrait.php @@ -0,0 +1,155 @@ +owner; + if (!$owner->isInDB()) { + return false; + } + + $version = static::get_latest_version($this->owner->baseClass(), $owner->ID); + return ($version->Version == $owner->Version); + } + + /** + * Returns whether the current record's version is the current live/published version + * + * @return bool + */ + public function isLiveVersion() + { + $id = $this->owner->ID ?: $this->owner->OldID; + if (!$id || !$this->isPublished()) { + return false; + } + + $liveVersionNumber = static::get_versionnumber_by_stage($this->owner, Versioned::LIVE, $id); + return $liveVersionNumber == $this->owner->Version; + } + + /** + * Returns whether the current record's version is the current draft/modified version + * + * @return bool + */ + public function isLatestDraftVersion() + { + $id = $this->owner->ID ?: $this->owner->OldID; + if (!$id || !$this->isOnDraft()) { + return false; + } + + $draftVersionNumber = static::get_versionnumber_by_stage($this->owner, Versioned::DRAFT, $id); + return $draftVersionNumber == $this->owner->Version; + } + + /** + * Check if this record exists on live + * On objects with only 1 stage, check if the record exists on that stage. + * + * @return bool + */ + public function isPublished() + { + $id = $this->owner->ID ?: $this->owner->OldID; + if (!$id) { + return false; + } + + // Non-staged objects are considered "published" if saved + if (!$this->hasStages()) { + return $this->isOnDraft(); + } + + $liveVersion = static::get_versionnumber_by_stage($this->owner, Versioned::LIVE, $id); + $isPublished = (bool) $liveVersion; + + $this->owner->extend('updateIsPublished', $isPublished); + + return (bool) $isPublished; + } + + /** + * Check if page doesn't exist on any stage, but used to be + * + * @return bool + */ + public function isArchived() + { + $owner = $this->owner; + $id = $owner->ID ?: $owner->OldID; + $isArchived = $id && !$this->isOnDraft() && !$this->isPublished(); + + $owner->invokeWithExtensions('updateIsArchived', $isArchived); + + return (bool) $isArchived; + } + + /** + * Check if this record exists on the draft stage. + * On objects with only 1 stage, check if the record exists on that stage. + * + * @return bool + */ + public function isOnDraft() + { + $id = $this->owner->ID ?: $this->owner->OldID; + if (!$id) { + return false; + } + + $draftVersion = static::get_versionnumber_by_stage($this->owner, Versioned::DRAFT, $id); + $isOnDraft = (bool) $draftVersion; + + $this->owner->extend('updateIsOnDraft', $isOnDraft); + + return (bool) $isOnDraft; + } + + /** + * Compares current draft with live version, and returns true if no draft version of this page exists but the page + * is still published (eg, after triggering "Delete from draft site" in the CMS). + * + * @return bool + */ + public function isOnLiveOnly() + { + return $this->isPublished() && !$this->isOnDraft(); + } + + /** + * Compares current draft with live version, and returns true if no live version exists, meaning the page was never + * published. + * + * @return bool + */ + public function isOnDraftOnly() + { + return $this->isOnDraft() && !$this->isPublished(); + } + + /** + * Compares current draft with live version, and returns true if these versions differ, meaning there have been + * unpublished changes to the draft site. + * + * @return bool + */ + public function isModifiedOnDraft() + { + return $this->isOnDraft() && $this->stagesDiffer(); + } +} diff --git a/src/Mode/Traits/VersionedPublicMethodsTrait.php b/src/Mode/Traits/VersionedPublicMethodsTrait.php new file mode 100644 index 00000000..a1d95da0 --- /dev/null +++ b/src/Mode/Traits/VersionedPublicMethodsTrait.php @@ -0,0 +1,598 @@ +owner; + } + + $baseClass = $this->owner->baseClass(); + $id = $this->owner->ID ?: $this->owner->OldID; + + // By version number + if (is_numeric($from)) { + return Versioned::get_version($baseClass, $id, $from); + } + + // By stage + return Versioned::get_by_stage($baseClass, $from)->byID($id); + } + + /** + * Perform a write without affecting the version table. + * + * @return int The ID of the record + */ + public function writeWithoutVersion() + { + $this->setNextWriteWithoutVersion(true); + + return $this->owner->write(); + } + + /** + * Check if next write is without version + * + * @return bool + */ + public function getNextWriteWithoutVersion() + { + return $this->owner->getField(Versioned::NEXT_WRITE_WITHOUT_VERSIONED); + } + + /** + * Set if next write should be without version or not + * + * @param bool $flag + * @return DataObject owner + */ + public function setNextWriteWithoutVersion($flag) + { + return $this->owner->setField(Versioned::NEXT_WRITE_WITHOUT_VERSIONED, $flag); + } + + /** + * Check if delete() should write _Version rows or not + * + * @return bool + */ + public function getDeleteWritesVersion() + { + return !$this->owner->getField(Versioned::DELETE_WRITES_VERSION_DISABLED); + } + + /** + * Set if delete() should write _Version rows + * + * @param bool $flag + * @return DataObject owner + */ + public function setDeleteWritesVersion($flag) + { + return $this->owner->setField(Versioned::DELETE_WRITES_VERSION_DISABLED, !$flag); + } + + /** + * Get version migrated to + * + * @return int|null + */ + public function getMigratingVersion() + { + return $this->owner->getField(Versioned::MIGRATING_VERSION); + } + + /** + * Set the migrating version. + * + * @param string $version The version. + * @return DataObject Owner + */ + public function setMigratingVersion($version) + { + return $this->owner->setField(Versioned::MIGRATING_VERSION, $version); + } + + /** + * Helper method to safely suppress delete callback + * + * @param callable $callback + * @return mixed Result of $callback() + */ + protected function suppressDeletedVersion($callback) + { + $original = $this->getDeleteWritesVersion(); + try { + $this->setDeleteWritesVersion(false); + return $callback(); + } finally { + $this->setDeleteWritesVersion($original); + } + } + + /** + * Determine if a class is supporting the Versioned extensions (e.g. + * $table_Versions does exists). + * + * @param string $class Class name + * @return boolean + */ + public function canBeVersioned($class) + { + return ClassInfo::exists($class) + && is_subclass_of($class, DataObject::class) + && DataObject::getSchema()->classHasTable($class); + } + + /** + * Check if a certain table has the 'Version' field. + * + * @param string $table Table name + * + * @return boolean Returns false if the field isn't in the table, true otherwise + */ + public function hasVersionField($table) + { + // Base table has version field + $class = DataObject::getSchema()->tableClass($table); + return $class === DataObject::getSchema()->baseDataClass($class); + } + + /** + * @param string $table + * + * @return string + */ + public function extendWithSuffix($table) + { + $owner = $this->owner; + $versionableExtensions = (array)$owner->config()->get('versionableExtensions'); + + if (count($versionableExtensions ?? [])) { + foreach ($versionableExtensions as $versionableExtension => $suffixes) { + if ($owner->hasExtension($versionableExtension)) { + /** @var VersionableExtension|Extension $ext */ + $ext = $owner->getExtensionInstance($versionableExtension); + try { + $ext->setOwner($owner); + $table = $ext->extendWithSuffix($table); + } finally { + $ext->clearOwner(); + } + } + } + } + + return $table; + } + + /** + * Determines if the current draft version is the same as live or rather, that there are no outstanding draft changes + * + * @return bool + */ + public function latestPublished() + { + $id = $this->owner->ID ?: $this->owner->OldID; + if (!$id) { + return false; + } + if (!$this->hasStages()) { + return true; + } + $draftVersion = Versioned::get_versionnumber_by_stage($this->owner, Versioned::DRAFT, $id); + $liveVersion = Versioned::get_versionnumber_by_stage($this->owner, Versioned::LIVE, $id); + return $draftVersion === $liveVersion; + } + + /** + * Publishes this object to Live, but doesn't publish owned objects. + * + * User code should call {@see canPublish()} prior to invoking this method. + * + * @return bool True if publish was successful + */ + public function publishSingle() + { + $owner = $this->owner; + // get the last published version + $original = null; + if ($this->isPublished()) { + $original = Versioned::get_by_stage($owner->baseClass(), Versioned::LIVE) + ->byID($owner->ID); + } + + // Publish it + $owner->invokeWithExtensions('onBeforePublish', $original); + $owner->writeToStage(Versioned::LIVE); + $owner->invokeWithExtensions('onAfterPublish', $original); + return true; + } + + /** + * Removes the record from both live and stage + * + * User code should call {@see canDelete()} prior to invoking this method. + * + * @return bool Success + */ + public function doArchive() + { + $owner = $this->owner; + $owner->invokeWithExtensions('onBeforeArchive', $this); + $owner->deleteFromChangeSets(); + // Unpublish without creating deleted version + $this->suppressDeletedVersion(function () use ($owner) { + $owner->doUnpublish(); + }); + // Create deleted version in both stages + $this->createDeletedVersion([ + Versioned::LIVE, + Versioned::DRAFT, + ]); + $this->suppressDeletedVersion(function () use ($owner) { + $owner->deleteFromStage(Versioned::DRAFT); + }); + $owner->invokeWithExtensions('onAfterArchive', $this); + return true; + } + + /** + * Removes this record from the live site + * + * User code should call {@see canUnpublish()} prior to invoking this method. + * + * @return bool Flag whether the unpublish was successful + */ + public function doUnpublish() + { + $owner = $this->owner; + // Skip if this record isn't saved + if (!$owner->isInDB()) { + return false; + } + + // Skip if this record isn't on live + if (!$owner->isPublished()) { + return false; + } + + $owner->invokeWithExtensions('onBeforeUnpublish'); + + // Modify in isolated mode + Versioned::withVersionedMode(function () use ($owner) { + Versioned::set_stage(Versioned::LIVE); + + // Re-fetch the current DataObject to ensure we have data from the LIVE stage + // This is particularly relevant for DataObject's in a modified state so that + // any delete extensions have the correct database record values + $obj = $owner::get()->byID($owner->ID); + if (!$obj) { + return; + } + $obj->setDeleteWritesVersion($owner->getDeleteWritesVersion()); + $obj->delete(); + }); + + $owner->invokeWithExtensions('onAfterUnpublish'); + return true; + } + + /** + * Determine if this object is published, and has any published owners. + * If this is true, a warning should be shown before this is published. + * + * Note: This method returns false if the object itself is unpublished, + * since owners are only considered on the same stage as the record itself. + * + * @return bool + */ + public function hasPublishedOwners() + { + if (!$this->isPublished()) { + return false; + } + // Count live owners + $baseClass = $this->owner->baseClass(); + + /** @var Versioned|RecursivePublishable|DataObject $liveRecord */ + $liveRecord = Versioned::get_by_stage($baseClass, Versioned::LIVE)->byID($this->owner->ID); + return $liveRecord->findOwners(false)->count() > 0; + } + + /** + * Revert the draft changes: replace the draft content with the content on live + * + * User code should call {@see canRevertToLive()} prior to invoking this method. + * + * @return bool True if the revert was successful + */ + public function doRevertToLive() + { + $owner = $this->owner; + $owner->invokeWithExtensions('onBeforeRevertToLive'); + $owner->rollbackRecursive(Versioned::LIVE); + $owner->invokeWithExtensions('onAfterRevertToLive'); + return true; + } + + /** + * Move a database record from one stage to the other. + * + * @param int|string|null $fromStage Place to copy from. Can be either a stage name or a version number. + * Null copies current object to stage + * @param string $toStage Place to copy to. Must be a stage name. + */ + public function copyVersionToStage($fromStage, $toStage) + { + $owner = $this->owner; + $owner->invokeWithExtensions('onBeforeVersionedPublish', $fromStage, $toStage); + + // Get at specific version + $from = $this->getAtVersion($fromStage); + if (!$from) { + $baseClass = $owner->baseClass(); + throw new InvalidArgumentException("Can't find {$baseClass}#{$owner->ID} in stage {$fromStage}"); + } + + $from->writeToStage($toStage); + $owner->invokeWithExtensions('onAfterVersionedPublish', $fromStage, $toStage); + } + + /** + * Compare two stages to see if they're different. + * + * Only checks the version numbers, not the actual content. + * + * @return bool + */ + public function stagesDiffer() + { + $id = $this->owner->ID ?: $this->owner->OldID; + if (!$id || !$this->hasStages()) { + return false; + } + + $draftVersion = Versioned::get_versionnumber_by_stage($this->owner, Versioned::DRAFT, $id); + $liveVersion = Versioned::get_versionnumber_by_stage($this->owner, Versioned::LIVE, $id); + $stagesDiffer = $draftVersion !== $liveVersion; + + $this->owner->extend('updateStagesDiffer', $stagesDiffer); + + return (bool) $stagesDiffer; + } + + /** + * Determine if content differs on stages including nested objects + * 'owns' configuration drives the relationship traversal + */ + public function stagesDifferRecursive(): bool + { + $service = Injector::inst()->get(RecursiveStagesInterface::class); + + return $service->stagesDifferRecursive($this->owner); + } + + /** + * @param string $filter + * @param string $sort + * @param string $limit + * @param string $join Deprecated, use leftJoin($table, $joinClause) instead + * @param string $having @deprecated 2.2.0 The $having parameter does nothing and will be removed without + * equivalent functionality to replace it + * @return ArrayList + */ + public function Versions($filter = "", $sort = "", $limit = "", $join = "", $having = "") + { + if ($having) { + Deprecation::withSuppressedNotice(function () { + $message = 'The $having parameter does nothing and will be removed without equivalent' + . ' functionality to replace it'; + Deprecation::notice('2.2.0', $message); + }); + } + + $owner = $this->owner; + + // When an object is not yet in the Database, we can't get its versions + if (!$owner->isInDB()) { + return ArrayList::create(); + } + + // Make sure the table names are not postfixed (e.g. _Live) + $oldMode = Versioned::get_reading_mode(); + Versioned::set_stage(Versioned::DRAFT); + + $list = DataObject::get(DataObject::getSchema()->baseDataClass($owner), $filter, $sort, $join, $limit); + + $query = $list->dataQuery()->query(); + + $baseTable = null; + foreach ($query->getFrom() as $table => $tableJoin) { + if (is_string($tableJoin) && $tableJoin[0] == '"') { + $baseTable = str_replace('"', '', $tableJoin ?? ''); + } elseif (is_string($tableJoin) && substr($tableJoin ?? '', 0, 5) != 'INNER') { + $query->setFrom([ + $table => "LEFT JOIN \"$table\" ON \"$table\".\"RecordID\"=\"{$baseTable}_Versions\".\"RecordID\"" + . " AND \"$table\".\"Version\" = \"{$baseTable}_Versions\".\"Version\"" + ]); + } + $query->renameTable($table, $table . '_Versions'); + } + + // Add all _Versions columns + foreach (Config::inst()->get(Versioned::class, 'db_for_versions_table') as $name => $type) { + $query->selectField(sprintf('"%s_Versions"."%s"', $baseTable, $name), $name); + } + + $query->addWhere([ + "\"{$baseTable}_Versions\".\"RecordID\" = ?" => $owner->ID + ]); + $query->setOrderBy(($sort) ? $sort + : "\"{$baseTable}_Versions\".\"LastEdited\" DESC, \"{$baseTable}_Versions\".\"Version\" DESC"); + + $records = $query->execute(); + $versions = new ArrayList(); + + foreach ($records as $record) { + $versions->push(new Versioned_Version($record)); + } + + Versioned::set_reading_mode($oldMode); + return $versions; + } + + /** + * Compare two version, and return the diff between them. + * + * @param string $from The version to compare from. + * @param string $to The version to compare to. + * + * @return DataObject + */ + public function compareVersions($from, $to) + { + $owner = $this->owner; + $baseClass = $this->owner->baseClass(); + + $fromRecord = Versioned::get_version($baseClass, $owner->ID, $from); + $toRecord = Versioned::get_version($baseClass, $owner->ID, $to); + + $diff = new DataDifferencer($fromRecord, $toRecord); + + return $diff->diffedData(); + } + + + /** + * Delete this record from the given stage + * + * @param string $stage + */ + public function deleteFromStage($stage) + { + ReadingMode::validateStage($stage); + $owner = $this->owner; + Versioned::withVersionedMode(function () use ($stage, $owner) { + Versioned::set_stage($stage); + $clone = clone $owner; + $clone->delete(); + }); + + // Fix the version number cache (in case you go delete from stage and then check ExistsOnLive) + $baseClass = $owner->baseClass(); + Versioned::$cache_versionnumber[$baseClass][$stage][$owner->ID] = null; + } + + /** + * Write the given record to the given stage. + * Note: If writing to live, this will write to stage as well. + * + * @param string $stage + * @param boolean $forceInsert + * @return int The ID of the record + */ + public function writeToStage($stage, $forceInsert = false) + { + ReadingMode::validateStage($stage); + $owner = $this->owner; + return Versioned::withVersionedMode(function () use ($stage, $forceInsert, $owner) { + $oldParams = $owner->getSourceQueryParams(); + try { + // Lazy load and reset version in current stage prior to resetting write stage + $owner->forceChange(); + $owner->Version = null; + + // Migrate stage prior to write + Versioned::set_stage($stage); + $owner->setSourceQueryParam('Versioned.mode', 'stage'); + $owner->setSourceQueryParam('Versioned.stage', $stage); + + // Write + $owner->invokeWithExtensions('onBeforeWriteToStage', $stage, $forceInsert); + return $owner->write(false, $forceInsert); + } finally { + // Revert global state + $owner->invokeWithExtensions('onAfterWriteToStage', $stage, $forceInsert); + $owner->setSourceQueryParams($oldParams); + } + }); + } + + /** + * Recursively rollback draft to the given version. This will also rollback any owned objects + * at that point in time to the same date. Objects which didn't exist (or weren't attached) + * to the record at the target point in time will be "unlinked", which dis-associates + * the record without requiring a hard deletion. + * + * @param int|string|null $version Version ID or Versioned::LIVE to rollback from live. + * Pass in null to rollback to the current object + * @return DataObject|Versioned The object rolled back + */ + public function rollbackRecursive($version = null) + { + $owner = $this->owner; + $owner->invokeWithExtensions('onBeforeRollbackRecursive', $version); + $owner->rollbackSingle($version); + + // Rollback relations on this item (works on unversioned records too) + $rolledBackOwner = $this->getAtVersion($version); + if ($rolledBackOwner) { + $rolledBackOwner->rollbackRelations($version); + } + + // Unlink any objects disowned as a result of this action + // I.e. objects which aren't owned anymore by this record, but are by the old draft record + $rolledBackOwner->unlinkDisownedObjects($rolledBackOwner, Versioned::DRAFT); + $rolledBackOwner->invokeWithExtensions('onAfterRollbackRecursive', $version); + + // Get rolled back version on draft + return $this->getAtVersion(Versioned::DRAFT); + } + + /** + * Rollback draft to a given version + * + * @param int|string|null $version Version ID or Versioned::LIVE to rollback from live. + * Null to rollback current owner object. + */ + public function rollbackSingle($version) + { + // Validate $version and safely cast + if (isset($version) && !is_numeric($version) && $version !== Versioned::LIVE) { + throw new InvalidArgumentException("Invalid rollback source version $version"); + } + if (isset($version) && is_numeric($version)) { + $version = (int)$version; + } + // Copy version between stage + $owner = $this->owner; + $owner->invokeWithExtensions('onBeforeRollbackSingle', $version); + $owner->copyVersionToStage($version, Versioned::DRAFT); + $owner->invokeWithExtensions('onAfterRollbackSingle', $version); + } +} diff --git a/src/Mode/Traits/VersionedStaticMethodsTrait.php b/src/Mode/Traits/VersionedStaticMethodsTrait.php new file mode 100644 index 00000000..e6f542d8 --- /dev/null +++ b/src/Mode/Traits/VersionedStaticMethodsTrait.php @@ -0,0 +1,562 @@ +getRequest()->getSession()->clear('readingMode'); + } + + /** + * Determine if the current user is able to set the given site stage / archive + * + * @param HTTPRequest $request + * @return bool + */ + public static function can_choose_site_stage($request) + { + // Request is allowed if stage isn't being modified + if ((!$request->getVar('stage') || $request->getVar('stage') === Versioned::LIVE) + && !$request->getVar('archiveDate') + ) { + return true; + } + + // Request is allowed if unsecuredDraftSite is enabled + if (!Versioned::get_draft_site_secured()) { + return true; + } + + // Predict if choose_site_stage() will allow unsecured draft assignment by session + if (Config::inst()->get(Versioned::class, 'use_session') && $request->getSession()->get('unsecuredDraftSite')) { + return true; + } + + // Check permissions with member ID in session. + $member = Security::getCurrentUser(); + $permissions = Config::inst()->get(get_called_class(), 'non_live_permissions'); + return $member && Permission::checkMember($member, $permissions); + } + + /** + * Choose the stage the site is currently on. + * + * If $_GET['stage'] is set, then it will use that stage, and store it in + * the session. + * + * if $_GET['archiveDate'] is set, it will use that date, and store it in + * the session. + * + * If neither of these are set, it checks the session, otherwise the stage + * is set to 'Live'. + * @param HTTPRequest $request + */ + public static function choose_site_stage(HTTPRequest $request) + { + $mode = Versioned::get_default_reading_mode(); + + // Check any pre-existing session mode + $useSession = Config::inst()->get(Versioned::class, 'use_session'); + $updateSession = false; + if ($useSession) { + // Boot reading mode from session + $mode = $request->getSession()->get('readingMode') ?: $mode; + + // Set draft site security if disabled for this session + if ($request->getSession()->get('unsecuredDraftSite')) { + Versioned::set_draft_site_secured(false); + } + } + + // Verify if querystring contains valid reading mode + $queryMode = ReadingMode::fromQueryString($request->getVars()); + if ($queryMode) { + $mode = $queryMode; + $updateSession = true; + } + + // Save reading mode + Versioned::set_reading_mode($mode); + + // Set mode if session enabled + if ($useSession && $updateSession) { + $request->getSession()->set('readingMode', $mode); + } + + if (!headers_sent() && !Director::is_cli()) { + if (Versioned::get_stage() === Versioned::LIVE) { + // clear the cookie if it's set + if (Cookie::get('bypassStaticCache')) { + Cookie::force_expiry('bypassStaticCache', null, null, false, true /* httponly */); + } + } else { + // set the cookie if it's cleared + if (!Cookie::get('bypassStaticCache')) { + Cookie::set('bypassStaticCache', '1', 0, null, null, false, true /* httponly */); + } + } + } + } + + /** + * Set the current reading mode. + * + * @param string $mode + */ + public static function set_reading_mode($mode) + { + Versioned::$reading_mode = $mode; + } + + /** + * Get the current reading mode. + * + * @return string + */ + public static function get_reading_mode() + { + return Versioned::$reading_mode; + } + + /** + * Get the current reading stage. + * + * @return string + */ + public static function get_stage() + { + $parts = explode('.', Versioned::get_reading_mode() ?? ''); + + if ($parts[0] == 'Stage') { + return $parts[1]; + } + return null; + } + + /** + * Get the current archive date. + * + * @return string + */ + public static function current_archived_date() + { + $parts = explode('.', Versioned::get_reading_mode() ?? ''); + if ($parts[0] == 'Archive') { + return $parts[1]; + } + return null; + } + + /** + * Get the current archive stage. + * + * @return string + */ + public static function current_archived_stage() + { + $parts = explode('.', Versioned::get_reading_mode() ?? ''); + if (sizeof($parts ?? []) === 3 && $parts[0] == 'Archive') { + return $parts[2]; + } + return Versioned::DRAFT; + } + + /** + * Set the reading stage. + * + * @param string $stage New reading stage. + * @throws InvalidArgumentException + */ + public static function set_stage($stage) + { + ReadingMode::validateStage($stage); + Versioned::set_reading_mode('Stage.' . $stage); + } + + /** + * Replace default mode. + * An non-default mode should be specified via querystring arguments. + * + * @param string $mode + */ + public static function set_default_reading_mode($mode) + { + Versioned::$default_reading_mode = $mode; + } + + /** + * Get default reading mode + * + * @return string + */ + public static function get_default_reading_mode() + { + return Versioned::$default_reading_mode ?: Versioned::DEFAULT_MODE; + } + + /** + * Check if draft site should be secured. + * Can be turned off if draft site unauthenticated + * + * @return bool + */ + public static function get_draft_site_secured() + { + if (isset(Versioned::$is_draft_site_secured)) { + return (bool)Versioned::$is_draft_site_secured; + } + // Config default + return (bool)Config::inst()->get(Versioned::class, 'draft_site_secured'); + } + + /** + * Set if the draft site should be secured or not + * + * @param bool $secured + */ + public static function set_draft_site_secured($secured) + { + Versioned::$is_draft_site_secured = $secured; + } + + /** + * Set the reading archive date. + * + * @param string $date New reading archived date. + * @param string $stage Set stage + */ + public static function reading_archived_date($date, $stage = Versioned::DRAFT) + { + ReadingMode::validateStage($stage); + Versioned::set_reading_mode('Archive.' . $date . '.' . $stage); + } + + /** + * Get a singleton instance of a class in the given stage. + * + * @template T of DataObject + * @param class-string $class The name of the class. + * @param string $stage The name of the stage. + * @param string $filter A filter to be inserted into the WHERE clause. + * @param boolean $cache Use caching. + * @param string $sort A sort expression to be inserted into the ORDER BY clause. + * @return T&static + */ + public static function get_one_by_stage($class, $stage, $filter = '', $cache = true, $sort = '') + { + return Versioned::withVersionedMode(function () use ($class, $stage, $filter, $cache, $sort) { + Versioned::set_stage($stage); + return DataObject::get_one($class, $filter, $cache, $sort); + }); + } + + /** + * Gets the current version number of a specific record. + * + * @param string $class Class to search + * @param string $stage Stage name + * @param int $id ID of the record + * @param bool $cache Set to true to turn on cache + * @return int|null Return the version number, or null if not on this stage + */ + public static function get_versionnumber_by_stage($class, $stage, $id, $cache = true) + { + $version = static::determineVersionNumberByStage($class, $stage, $id, $cache); + $className = $class instanceof DataObject ? $class->ClassName : $class; + $object = DataObject::singleton($className); + $object->invokeWithExtensions('updateGetVersionNumberByStage', $version, $class, $stage, $id, $cache); + + return $version; + } + + /** + * @param DataObject|string $class + * @param string $stage + * @param int $id + * @param bool $cache + * @return int|null + */ + private static function determineVersionNumberByStage($class, $stage, $id, $cache) + { + ReadingMode::validateStage($stage); + $baseClass = DataObject::getSchema()->baseDataClass($class); + $stageTable = DataObject::getSchema()->tableName($baseClass); + if ($stage === Versioned::LIVE) { + $stageTable .= "_{$stage}"; + } + + // cached call + if ($cache) { + if (isset(Versioned::$cache_versionnumber[$baseClass][$stage][$id])) { + return Versioned::$cache_versionnumber[$baseClass][$stage][$id] ?: null; + } elseif (isset(Versioned::$cache_versionnumber[$baseClass][$stage]['_complete'])) { + // if the cache was marked as "complete" then we know the record is missing, just return null + // this is used for treeview optimisation to avoid unnecessary re-requests for draft pages + return null; + } + } + + // get version as performance-optimized SQL query (gets called for each record in the sitetree) + $version = DB::prepared_query( + "SELECT \"Version\" FROM \"$stageTable\" WHERE \"ID\" = ?", + [$id] + )->value(); + + // cache value (if required) + if ($cache) { + if (!isset(Versioned::$cache_versionnumber[$baseClass])) { + Versioned::$cache_versionnumber[$baseClass] = []; + } + + if (!isset(Versioned::$cache_versionnumber[$baseClass][$stage])) { + Versioned::$cache_versionnumber[$baseClass][$stage] = []; + } + + // Internally store nulls as 0 + Versioned::$cache_versionnumber[$baseClass][$stage][$id] = $version ?: 0; + } + + return $version ?: null; + } + + /** + * Pre-populate the cache for Versioned::get_versionnumber_by_stage() for + * a list of record IDs, for more efficient database querying. If $idList + * is null, then every record will be pre-cached. + * + * @param string $class + * @param string $stage + * @param array $idList + */ + public static function prepopulate_versionnumber_cache($class, $stage, $idList = null) + { + ReadingMode::validateStage($stage); + if (!Config::inst()->get(Versioned::class, 'prepopulate_versionnumber_cache')) { + return; + } + + $singleton = DataObject::singleton($class); + $baseClass = $singleton->baseClass(); + $baseTable = $singleton->baseTable(); + $stageTable = $singleton->stageTable($baseTable, $stage); + + $filter = ""; + $parameters = []; + if ($idList) { + // Validate the ID list + foreach ($idList as $id) { + if (!is_numeric($id)) { + throw new InvalidArgumentException( + "Bad ID passed to Versioned::prepopulate_versionnumber_cache() in \$idList: " . $id + ); + } + } + $filter = 'WHERE "ID" IN (' . DB::placeholders($idList) . ')'; + $parameters = $idList; + + // If we are caching IDs for _all_ records then we can mark this cache as "complete" and in the case of a cache-miss + // no subsequent call is necessary + } else { + Versioned::$cache_versionnumber[$baseClass][$stage] = [ '_complete' => true ]; + } + + $versions = DB::prepared_query("SELECT \"ID\", \"Version\" FROM \"$stageTable\" $filter", $parameters)->map(); + + foreach ($versions as $id => $version) { + Versioned::$cache_versionnumber[$baseClass][$stage][$id] = $version; + } + + $className = $class instanceof DataObject ? $class->ClassName : $class; + $object = DataObject::singleton($className); + $object->invokeWithExtensions('updatePrePopulateVersionNumberCache', $versions, $class, $stage, $idList); + } + + /** + * Get a set of class instances by the given stage. + * + * @template T of DataObject + * @param class-string $class The name of the class. + * @param string $stage The name of the stage. + * @param string $filter A filter to be inserted into the WHERE clause. + * @param string $sort A sort expression to be inserted into the ORDER BY clause. + * @param string $join Deprecated, use leftJoin($table, $joinClause) instead + * @param int $limit A limit on the number of records returned from the database. + * @param string $containerClass The container class for the result set (default is DataList) + * + * @return DataList A modified DataList designated to the specified stage + */ + public static function get_by_stage( + $class, + $stage, + $filter = '', + $sort = '', + $join = '', + $limit = null, + $containerClass = DataList::class + ) { + ReadingMode::validateStage($stage); + $result = DataObject::get($class, $filter, $sort, $join, $limit, $containerClass); + return $result->setDataQueryParam([ + 'Versioned.mode' => 'stage', + 'Versioned.stage' => $stage + ]); + } + + /** + * Return the latest version of the given record. + * + * @template T of DataObject + * @param class-string $class + * @param int $id + * @return T&static + */ + public static function get_latest_version($class, $id) + { + $baseClass = DataObject::getSchema()->baseDataClass($class); + $list = DataList::create($baseClass) + ->setDataQueryParam([ + "Versioned.mode" => 'latest_version_single', + "Versioned.id" => $id + ]); + return $list->first(); + } + + + /** + * Return the equivalent of a DataList::create() call, querying the latest + * version of each record stored in the (class)_Versions tables. + * + * In particular, this will query deleted records as well as active ones. + * + * @template T of DataObject + * @param class-string $class + * @param string $filter + * @param string $sort + * @return DataList + */ + public static function get_including_deleted($class, $filter = "", $sort = "") + { + $list = DataList::create($class); + if (!empty($filter)) { + $list = $list->where($filter); + } + if (!empty($sort)) { + $list = $list->orderBy($sort); + } + $list = $list->setDataQueryParam("Versioned.mode", "latest_versions"); + return $list; + } + + /** + * Return the specific version of the given id. + * + * Caution: The record is retrieved as a DataObject, but saving back + * modifications via write() will create a new version, rather than + * modifying the existing one. + * + * @template T of DataObject + * @param class-string $class + * @param int $id + * @param int $version + * @return T&static + */ + public static function get_version($class, $id, $version) + { + $baseClass = DataObject::getSchema()->baseDataClass($class); + $list = DataList::create($baseClass) + ->setDataQueryParam([ + "Versioned.mode" => 'version', + "Versioned.version" => $version + ]); + + return $list->byID($id); + } + + /** + * Return a list of all versions for a given id. + * + * @template T + * @param class-string $class + * @param int $id + * + * @return DataList + */ + public static function get_all_versions($class, $id) + { + $list = DataList::create($class) + ->filter('ID', $id) + ->setDataQueryParam('Versioned.mode', 'all_versions'); + + return $list; + } + + /** + * Invoke a callback which may modify reading mode, but ensures this mode is restored + * after completion, without modifying global state. + * + * The desired reading mode should be set by the callback directly + * + * @param callable $callback + * @return mixed Result of $callback + */ + public static function withVersionedMode($callback) + { + $origReadingMode = Versioned::get_reading_mode(); + try { + return $callback(); + } finally { + Versioned::set_reading_mode($origReadingMode); + } + } +} diff --git a/src/Mode/Versioned.php b/src/Mode/Versioned.php new file mode 100644 index 00000000..c869971d --- /dev/null +++ b/src/Mode/Versioned.php @@ -0,0 +1,374 @@ + + */ +class Versioned extends Extension implements TemplateGlobalProvider, Resettable +{ + use VersionedAugmentSomethingTrait; + use VersionedAugmentSqlTrait; + use VersionedPublicMethodsTrait; + use VersionedStaticMethodsTrait; + use VersionedCanChecksTrait; + use VersionedIsMethodsTrait; + use VersionedHookImplementationsTrait; + + /** + * Versioning mode for this object. + * Note: Not related to the current versioning mode in the state / session + * Will be one of 'StagedVersioned' or 'Versioned'; + * + * @var string + */ + protected $mode; + + /** + * The default reading mode + */ + const DEFAULT_MODE = 'Stage.Live'; + + /** + * Constructor arg to specify that staging is active on this record. + * 'Staging' implies that 'Versioning' is also enabled. + */ + const STAGEDVERSIONED = 'StagedVersioned'; + + /** + * Constructor arg to specify that versioning only is active on this record. + */ + const VERSIONED = 'Versioned'; + + /** + * The Public stage. + */ + const LIVE = 'Live'; + + /** + * The draft (default) stage + */ + const DRAFT = 'Stage'; + + /** + * Cache of version to modified dates for this object + * + * @var array + */ + protected $versionModifiedCache = []; + + /** + * A cache used by get_versionnumber_by_stage(). + * Clear through {@link flushCache()}. + * version (int)0 means not on this stage. + * + * @var array + */ + protected static $cache_versionnumber; + + /** + * Field used to hold the migrating version + */ + const MIGRATING_VERSION = 'MigratingVersion'; + + /** + * Field used to hold flag indicating the next write should be without a new version + */ + const NEXT_WRITE_WITHOUT_VERSIONED = 'NextWriteWithoutVersioned'; + + /** + * Prevents delete() from creating a _Versions record (in case this must be deferred) + * Best used with suppressDeleteVersion() + */ + const DELETE_WRITES_VERSION_DISABLED = 'DeleteWritesVersionDisabled'; + + /** + * Ensure versioned page doesn't attempt to virtualise these non-db fields + * + * @config + * @var array + */ + private static $non_virtual_fields = [ + Versioned::MIGRATING_VERSION, + Versioned::NEXT_WRITE_WITHOUT_VERSIONED, + Versioned::DELETE_WRITES_VERSION_DISABLED, + ]; + + /** + * Additional database columns for the new + * "_Versions" table. Used in {@link augmentDatabase()} + * and all Versioned calls extending or creating + * SELECT statements. + * + * @var array $db_for_versions_table + */ + private static $db_for_versions_table = [ + "RecordID" => "Int", + "Version" => "Int", + "WasPublished" => "Boolean", + "WasDeleted" => "Boolean", + "WasDraft" => "Boolean(1)", + "AuthorID" => "Int", + "PublisherID" => "Int" + ]; + + /** + * Ensure versioned records cast extra fields properly + * + * @config + * @var array + */ + private static $casting = [ + "RecordID" => "Int", + "WasPublished" => "Boolean", + "WasDeleted" => "Boolean", + "WasDraft" => "Boolean", + "AuthorID" => "Int", + "PublisherID" => "Int" + ]; + + /** + * @var array + * @config + */ + private static $db = [ + 'Version' => 'Int' + ]; + + /** + * Used to enable or disable the prepopulation of the version number cache. + * Defaults to true. + * + * @config + * @var boolean + */ + private static $prepopulate_versionnumber_cache = true; + + /** + * Indicates whether augmentSQL operations should add subselects as WHERE conditions instead of INNER JOIN + * intersections. Performance of the INNER JOIN scales on the size of _Versions tables where as the condition scales + * on the number of records being returned from the base query. + * + * @config + * @var bool + */ + private static $use_conditions_over_inner_joins = false; + + /** + * Additional database indexes for the new + * "_Versions" table. Used in {@link augmentDatabase()}. + * + * @var array $indexes_for_versions_table + */ + private static $indexes_for_versions_table = [ + 'RecordID_Version' => [ + 'type' => 'index', + 'columns' => ['RecordID', 'Version'], + ], + 'RecordID' => [ + 'type' => 'index', + 'columns' => ['RecordID'], + ], + 'Version' => [ + 'type' => 'index', + 'columns' => ['Version'], + ], + 'AuthorID' => [ + 'type' => 'index', + 'columns' => ['AuthorID'], + ], + 'PublisherID' => [ + 'type' => 'index', + 'columns' => ['PublisherID'], + ], + ]; + + /** + * An array of DataObject extensions that may require versioning for extra tables + * The array value is a set of suffixes to form these table names, assuming a preceding '_'. + * E.g. if Extension1 creates a new table 'Class_suffix1' + * and Extension2 the tables 'Class_suffix2' and 'Class_suffix3': + * + * $versionableExtensions = array( + * 'Extension1' => 'suffix1', + * 'Extension2' => array('suffix2', 'suffix3'), + * ); + * + * This can also be manipulated by updating the current loaded config + * + * SiteTree: + * versionableExtensions: + * - Extension1: + * - suffix1 + * - suffix2 + * - Extension2: + * - suffix1 + * - suffix2 + * + * or programatically: + * + * Config::modify()->merge($this->owner->class, 'versionableExtensions', + * array('Extension1' => 'suffix1', 'Extension2' => array('suffix2', 'suffix3'))); + * + * + * Your extension must implement VersionableExtension interface in order to + * apply custom tables for versioned. + * + * @config + * @var array + */ + private static $versionableExtensions = []; + + /** + * Permissions necessary to view records outside of the live stage (e.g. archive / draft stage). + * + * @config + * @var array + */ + private static $non_live_permissions = [ + 'CMS_ACCESS_LeftAndMain', + 'CMS_ACCESS_CMSMain', + 'VIEW_DRAFT_CONTENT', + 'CAN_DEV_BUILD' + ]; + + /** + * Use PHP's session storage for the "reading mode" and "unsecuredDraftSite", + * instead of explicitly relying on the "stage" query parameter. + * This is considered bad practice, since it can cause draft content + * to leak under live URLs to unauthorised users, depending on HTTP cache settings. + * + * @config + * @var bool + */ + private static $use_session = false; + + /** + * Construct a new Versioned object. + * + * @var string $mode One of "StagedVersioned" or "Versioned". + */ + public function __construct($mode = Versioned::STAGEDVERSIONED) + { + if (!in_array($mode, [Versioned::STAGEDVERSIONED, Versioned::VERSIONED])) { + throw new InvalidArgumentException("Invalid mode: {$mode}"); + } + + $this->mode = $mode; + } + + /** + * Return the base table - the class that directly extends DataObject. + * + * Protected so it doesn't conflict with DataObject::baseTable() + * + * @param string $stage + * @return string + */ + protected function baseTable($stage = null) + { + $baseTable = $this->owner->baseTable(); + return $this->stageTable($baseTable, $stage); + } + + /** + * Given a table and stage determine the table name. + * + * Note: Stages this asset does not exist in will default to the draft table. + * + * @param string $table Main table + * @param string $stage + * @return string Staged table name + */ + public function stageTable($table, $stage) + { + if ($this->hasStages() && $stage === Versioned::LIVE) { + return "{$table}_{$stage}"; + } + return $table; + } + + /** + * Returns an array of possible stages. + * + * @return array + */ + public function getVersionedStages() + { + if ($this->hasStages()) { + return [Versioned::DRAFT, Versioned::LIVE]; + } else { + return [Versioned::DRAFT]; + } + } + + public static function get_template_global_variables() + { + return [ + 'CurrentReadingMode' => 'get_reading_mode' + ]; + } + + /** + * Check if this object has stages + * + * @return bool True if this object is staged + */ + public function hasStages() + { + return $this->mode === Versioned::STAGEDVERSIONED; + } + + /** + * Get author of this record. + * Note: Only works on records selected via Versions() + * + * @return Member|null + */ + public function Author() + { + if (!$this->owner->AuthorID) { + return null; + } + $member = DataObject::get_by_id(Member::class, $this->owner->AuthorID); + return $member; + } + /** + * Get publisher of this record. + * Note: Only works on records selected via Versions() + * + * @return Member|null + */ + public function Publisher() + { + if (!$this->owner->PublisherID) { + return null; + } + $member = DataObject::get_by_id(Member::class, $this->owner->PublisherID); + return $member; + } +} diff --git a/src/VersionedGridFieldState/VersionedGridFieldState.php b/src/Mode/VersionedGridFieldState/VersionedGridFieldState.php similarity index 98% rename from src/VersionedGridFieldState/VersionedGridFieldState.php rename to src/Mode/VersionedGridFieldState/VersionedGridFieldState.php index 65b5dda6..3b8b587e 100644 --- a/src/VersionedGridFieldState/VersionedGridFieldState.php +++ b/src/Mode/VersionedGridFieldState/VersionedGridFieldState.php @@ -1,11 +1,11 @@ write(); - $changeset->addObject($owner); + $changeset->addObject($owner); // will add a ChangeSetItem to $changeset->Changes() $result = $changeset->publish(true); if (!$result) { diff --git a/src/RecursivePublishableHandler.php b/src/Staged/RecursivePublishableHandler.php similarity index 89% rename from src/RecursivePublishableHandler.php rename to src/Staged/RecursivePublishableHandler.php index 6432fc49..f9cadfdd 100644 --- a/src/RecursivePublishableHandler.php +++ b/src/Staged/RecursivePublishableHandler.php @@ -1,10 +1,11 @@ - */ -class Versioned extends Extension implements TemplateGlobalProvider, Resettable -{ - /** - * Versioning mode for this object. - * Note: Not related to the current versioning mode in the state / session - * Will be one of 'StagedVersioned' or 'Versioned'; - * - * @var string - */ - protected $mode; - - /** - * The default reading mode - */ - const DEFAULT_MODE = 'Stage.Live'; - - /** - * Constructor arg to specify that staging is active on this record. - * 'Staging' implies that 'Versioning' is also enabled. - */ - const STAGEDVERSIONED = 'StagedVersioned'; - - /** - * Constructor arg to specify that versioning only is active on this record. - */ - const VERSIONED = 'Versioned'; - - /** - * The Public stage. - */ - const LIVE = 'Live'; - - /** - * The draft (default) stage - */ - const DRAFT = 'Stage'; - - /** - * A cache used by get_versionnumber_by_stage(). - * Clear through {@link flushCache()}. - * version (int)0 means not on this stage. - * - * @var array - */ - protected static $cache_versionnumber; - - /** - * Set if draft site is secured or not. Fails over to - * $draft_site_secured if unset - * - * @var bool|null - */ - protected static $is_draft_site_secured = null; - - /** - * Default config for $is_draft_site_secured - * - * @config - * @var bool - */ - private static $draft_site_secured = true; - - /** - * Cache of version to modified dates for this object - * - * @var array - */ - protected $versionModifiedCache = []; - - /** - * Current reading mode. Supports stage / archive modes. - * - * @var string - */ - protected static $reading_mode = null; - - /** - * Default reading mode, if none set. - * Any modes which differ to this value should be assigned via querystring / session (if enabled) - * - * @var null - */ - protected static $default_reading_mode = Versioned::DEFAULT_MODE; - - /** - * Field used to hold the migrating version - */ - const MIGRATING_VERSION = 'MigratingVersion'; - - /** - * Field used to hold flag indicating the next write should be without a new version - */ - const NEXT_WRITE_WITHOUT_VERSIONED = 'NextWriteWithoutVersioned'; - - /** - * Prevents delete() from creating a _Versions record (in case this must be deferred) - * Best used with suppressDeleteVersion() - */ - const DELETE_WRITES_VERSION_DISABLED = 'DeleteWritesVersionDisabled'; - - /** - * Ensure versioned page doesn't attempt to virtualise these non-db fields - * - * @config - * @var array - */ - private static $non_virtual_fields = [ - Versioned::MIGRATING_VERSION, - Versioned::NEXT_WRITE_WITHOUT_VERSIONED, - Versioned::DELETE_WRITES_VERSION_DISABLED, - ]; - - /** - * Additional database columns for the new - * "_Versions" table. Used in {@link augmentDatabase()} - * and all Versioned calls extending or creating - * SELECT statements. - * - * @var array $db_for_versions_table - */ - private static $db_for_versions_table = [ - "RecordID" => "Int", - "Version" => "Int", - "WasPublished" => "Boolean", - "WasDeleted" => "Boolean", - "WasDraft" => "Boolean(1)", - "AuthorID" => "Int", - "PublisherID" => "Int" - ]; - - /** - * Ensure versioned records cast extra fields properly - * - * @config - * @var array - */ - private static $casting = [ - "RecordID" => "Int", - "WasPublished" => "Boolean", - "WasDeleted" => "Boolean", - "WasDraft" => "Boolean", - "AuthorID" => "Int", - "PublisherID" => "Int" - ]; - - /** - * @var array - * @config - */ - private static $db = [ - 'Version' => 'Int' - ]; - - /** - * Used to enable or disable the prepopulation of the version number cache. - * Defaults to true. - * - * @config - * @var boolean - */ - private static $prepopulate_versionnumber_cache = true; - - /** - * Indicates whether augmentSQL operations should add subselects as WHERE conditions instead of INNER JOIN - * intersections. Performance of the INNER JOIN scales on the size of _Versions tables where as the condition scales - * on the number of records being returned from the base query. - * - * @config - * @var bool - */ - private static $use_conditions_over_inner_joins = false; - - /** - * Additional database indexes for the new - * "_Versions" table. Used in {@link augmentDatabase()}. - * - * @var array $indexes_for_versions_table - */ - private static $indexes_for_versions_table = [ - 'RecordID_Version' => [ - 'type' => 'index', - 'columns' => ['RecordID', 'Version'], - ], - 'RecordID' => [ - 'type' => 'index', - 'columns' => ['RecordID'], - ], - 'Version' => [ - 'type' => 'index', - 'columns' => ['Version'], - ], - 'AuthorID' => [ - 'type' => 'index', - 'columns' => ['AuthorID'], - ], - 'PublisherID' => [ - 'type' => 'index', - 'columns' => ['PublisherID'], - ], - ]; - - - /** - * An array of DataObject extensions that may require versioning for extra tables - * The array value is a set of suffixes to form these table names, assuming a preceding '_'. - * E.g. if Extension1 creates a new table 'Class_suffix1' - * and Extension2 the tables 'Class_suffix2' and 'Class_suffix3': - * - * $versionableExtensions = array( - * 'Extension1' => 'suffix1', - * 'Extension2' => array('suffix2', 'suffix3'), - * ); - * - * This can also be manipulated by updating the current loaded config - * - * SiteTree: - * versionableExtensions: - * - Extension1: - * - suffix1 - * - suffix2 - * - Extension2: - * - suffix1 - * - suffix2 - * - * or programatically: - * - * Config::modify()->merge($this->owner->class, 'versionableExtensions', - * array('Extension1' => 'suffix1', 'Extension2' => array('suffix2', 'suffix3'))); - * - * - * Your extension must implement VersionableExtension interface in order to - * apply custom tables for versioned. - * - * @config - * @var array - */ - private static $versionableExtensions = []; - - /** - * Permissions necessary to view records outside of the live stage (e.g. archive / draft stage). - * - * @config - * @var array - */ - private static $non_live_permissions = ['CMS_ACCESS_LeftAndMain', 'CMS_ACCESS_CMSMain', 'VIEW_DRAFT_CONTENT', 'CAN_DEV_BUILD']; - - /** - * Use PHP's session storage for the "reading mode" and "unsecuredDraftSite", - * instead of explicitly relying on the "stage" query parameter. - * This is considered bad practice, since it can cause draft content - * to leak under live URLs to unauthorised users, depending on HTTP cache settings. - * - * @config - * @var bool - */ - private static $use_session = false; - - /** - * Reset static configuration variables to their default values. - */ - public static function reset() - { - Versioned::$reading_mode = ''; - Controller::curr()->getRequest()->getSession()->clear('readingMode'); - } - - /** - * Amend freshly created DataQuery objects with versioned-specific - * information. - * - * @param SQLSelect $query - * @param DataQuery $dataQuery - */ - protected function augmentDataQueryCreation(SQLSelect &$query, DataQuery &$dataQuery) - { - // Convert reading mode to dataquery params and assign - $args = ReadingMode::toDataQueryParams(Versioned::get_reading_mode()); - if ($args) { - foreach ($args as $key => $value) { - $dataQuery->setQueryParam($key, $value); - } - } - } - - /** - * Construct a new Versioned object. - * - * @var string $mode One of "StagedVersioned" or "Versioned". - */ - public function __construct($mode = Versioned::STAGEDVERSIONED) - { - if (!in_array($mode, [static::STAGEDVERSIONED, static::VERSIONED])) { - throw new InvalidArgumentException("Invalid mode: {$mode}"); - } - - $this->mode = $mode; - } - - /** - * Get this record at a specific version - * - * @param int|string|null $from Version or stage to get at. Null mean returns self object - * @return Versioned|DataObject - */ - public function getAtVersion($from) - { - // Null implies return current version - if (is_null($from)) { - return $this->owner; - } - - $baseClass = $this->owner->baseClass(); - $id = $this->owner->ID ?: $this->owner->OldID; - - // By version number - if (is_numeric($from)) { - return Versioned::get_version($baseClass, $id, $from); - } - - // By stage - return Versioned::get_by_stage($baseClass, $from)->byID($id); - } - - /** - * Get modified date and stage for the given version - * - * @param int $version - * @return array A list containing 0 => LastEdited, 1 => Stage - */ - protected function getLastEditedAndStageForVersion($version) - { - // Cache key - $baseTable = $this->baseTable(); - $id = $this->owner->ID; - $key = "{$baseTable}#{$id}/{$version}"; - - // Check cache - if (isset($this->versionModifiedCache[$key])) { - return $this->versionModifiedCache[$key]; - } - - // Build query - $table = "\"{$baseTable}_Versions\""; - $query = SQLSelect::create(['"LastEdited"', '"WasPublished"'], $table) - ->addWhere([ - "{$table}.\"RecordID\"" => $id, - "{$table}.\"Version\"" => $version - ]); - $result = $query->execute()->record(); - if (!$result) { - return null; - } - $list = [ - $result['LastEdited'], - $result['WasPublished'] ? static::LIVE : static::DRAFT, - ]; - $this->versionModifiedCache[$key] = $list; - return $list; - } - - /** - * Updates query parameters of relations attached to versioned dataobjects - * - * @param array $params - */ - protected function updateInheritableQueryParams(&$params) - { - // Skip if versioned isn't set - if (!isset($params['Versioned.mode'])) { - return; - } - - // Adjust query based on original selection criterea - switch ($params['Versioned.mode']) { - case 'all_versions': - { - // Versioned.mode === all_versions doesn't inherit very well, so default to stage - $params['Versioned.mode'] = 'stage'; - $params['Versioned.stage'] = static::DRAFT; - break; - } - case 'version': - { - // If we selected this object from a specific version, we need - // to find the date this version was published, and ensure - // inherited queries select from that date. - $version = $params['Versioned.version']; - $dateAndStage = $this->getLastEditedAndStageForVersion($version); - - // Filter related objects at the same date as this version - unset($params['Versioned.version']); - if ($dateAndStage) { - list($date, $stage) = $dateAndStage; - $params['Versioned.mode'] = 'archive'; - $params['Versioned.date'] = $date; - $params['Versioned.stage'] = $stage; - } else { - // Fallback to default - $params['Versioned.mode'] = 'stage'; - $params['Versioned.stage'] = static::DRAFT; - } - break; - } - } - } - - /** - * Augment the the SQLSelect that is created by the DataQuery - * - * See {@see augmentLazyLoadFields} for lazy-loading applied prior to this. - * - * @param SQLSelect $query - * @param DataQuery|null $dataQuery - * @throws InvalidArgumentException - */ - protected function augmentSQL(SQLSelect $query, DataQuery $dataQuery = null) - { - if (!$dataQuery) { - return; - } - - // Ensure query mode exists - $versionedMode = $dataQuery->getQueryParam('Versioned.mode'); - if (!$versionedMode) { - return; - } - switch ($versionedMode) { - case 'stage': - $this->augmentSQLStage($query, $dataQuery); - break; - case 'stage_unique': - $this->augmentSQLStageUnique($query, $dataQuery); - break; - case 'archive': - $this->augmentSQLVersionedArchive($query, $dataQuery); - break; - case 'latest_version_single': - $this->augmentSQLVersionedLatestSingle($query, $dataQuery); - break; - case 'latest_versions': - $this->augmentSQLVersionedLatest($query, $dataQuery); - break; - case 'version': - $this->augmentSQLVersionedVersion($query, $dataQuery); - break; - case 'all_versions': - $this->augmentSQLVersionedAll($query); - break; - default: - throw new InvalidArgumentException("Bad value for query parameter Versioned.mode: {$versionedMode}"); - } - } - - /** - * Reading a specific stage (Stage or Live) - * - * @param SQLSelect $query - * @param DataQuery $dataQuery - */ - protected function augmentSQLStage(SQLSelect $query, DataQuery $dataQuery) - { - if (!$this->hasStages()) { - return; - } - $stage = $dataQuery->getQueryParam('Versioned.stage'); - ReadingMode::validateStage($stage); - if ($stage === static::DRAFT) { - return; - } - // Rewrite all tables to select from the live version - foreach ($query->getFrom() as $table => $dummy) { - if (!$this->isTableVersioned($table)) { - continue; - } - $stageTable = $this->stageTable($table, $stage); - $query->renameTable($table, $stageTable); - } - } - - /** - * Reading a specific stage, but only return items that aren't in any other stage - * - * @param SQLSelect $query - * @param DataQuery $dataQuery - */ - protected function augmentSQLStageUnique(SQLSelect $query, DataQuery $dataQuery) - { - if (!$this->hasStages()) { - return; - } - // Set stage first - $this->augmentSQLStage($query, $dataQuery); - - // Now exclude any ID from any other stage. - $stage = $dataQuery->getQueryParam('Versioned.stage'); - $excludingStage = $stage === static::DRAFT ? static::LIVE : static::DRAFT; - - // Note that we double rename to avoid the regular stage rename - // renaming all subquery references to be Versioned.stage - $tempName = 'ExclusionarySource_' . $excludingStage; - $excludingTable = $this->baseTable($excludingStage); - $baseTable = $this->baseTable($stage); - $query->addWhere("\"{$baseTable}\".\"ID\" NOT IN (SELECT \"ID\" FROM \"{$tempName}\")"); - $query->renameTable($tempName, $excludingTable); - } - - /** - * Augment SQL to select from `_Versions` table instead. - * - * @param SQLSelect $query - * @param bool $filterDeleted Whether to exclude deleted entries or not - */ - protected function augmentSQLVersioned(SQLSelect $query, bool $filterDeleted = true) - { - $baseTable = $this->baseTable(); - foreach ($query->getFrom() as $alias => $join) { - if (!$this->isTableVersioned($alias)) { - continue; - } - - if ($alias != $baseTable) { - // Make sure join includes version as well - $query->setJoinFilter( - $alias, - "\"{$alias}_Versions\".\"RecordID\" = \"{$baseTable}_Versions\".\"RecordID\"" - . " AND \"{$alias}_Versions\".\"Version\" = \"{$baseTable}_Versions\".\"Version\"" - ); - } - - // Rewrite all usages of `Table` to `Table_Versions` - $query->renameTable($alias, $alias . '_Versions'); - // However, add an alias back to the base table in case this must later be joined. - // See ApplyVersionFilters for example which joins _Versions back onto draft table. - $query->renameTable($alias . '_Draft', $alias); - } - - // Add all _Versions columns - foreach (Config::inst()->get(static::class, 'db_for_versions_table') as $name => $type) { - $query->selectField(sprintf('"%s_Versions"."%s"', $baseTable, $name), $name); - } - - // Alias the record ID as the row ID, and ensure ID filters are aliased correctly - $query->selectField("\"{$baseTable}_Versions\".\"RecordID\"", "ID"); - $query->replaceText("\"{$baseTable}_Versions\".\"ID\"", "\"{$baseTable}_Versions\".\"RecordID\""); - - // However, if doing count, undo rewrite of "ID" column - $query->replaceText( - "count(DISTINCT \"{$baseTable}_Versions\".\"RecordID\")", - "count(DISTINCT \"{$baseTable}_Versions\".\"ID\")" - ); - - // Filter deleted versions, which are all unqueryable - if ($filterDeleted) { - $query->addWhere(["\"{$baseTable}_Versions\".\"WasDeleted\"" => 0]); - } - } - - /** - * Prepare a sub-select for determining latest versions of records on the base table. This is used as either an - * inner join or sub-select on the base query - * - * @param SQLSelect $baseQuery - * @param DataQuery $dataQuery - * @return SQLSelect - */ - protected function prepareMaxVersionSubSelect(SQLSelect $baseQuery, DataQuery $dataQuery) - { - $baseTable = $this->baseTable(); - - // Create a sub-select that we determine latest versions - $subSelect = SQLSelect::create( - ['LatestVersion' => "MAX(\"{$baseTable}_Versions_Latest\".\"Version\")"], - [$baseTable . '_Versions_Latest' => "\"{$baseTable}_Versions\""] - ); - - $subSelect->renameTable($baseTable, "{$baseTable}_Versions"); - - // Determine the base table of the existing query - $baseFrom = $baseQuery->getFrom(); - $baseTable = trim(reset($baseFrom) ?? '', '"'); - - // And then the name of the base table in the new query - $newFrom = $subSelect->getFrom(); - $newTable = trim(key($newFrom ?? []) ?? '', '"'); - - // Parse "where" conditions to find those appropriate to be "promoted" into an inner join - // We can ONLY promote a filter on the primary key of the base table. Any other conditions will make the - // version returned incorrect, as we are filtering out version that may be the latest (and correct) version - foreach ($baseQuery->getWhere() as $condition) { - if (is_object($condition)) { - continue; - } - $conditionClause = key($condition ?? []); - // Pull out the table and field for this condition. We'll skip anything we can't parse - if (preg_match('/^"([^"]+)"\."([^"]+)"/', $conditionClause ?? '', $matches) !== 1) { - continue; - } - - $table = $matches[1]; - $field = $matches[2]; - - if ($table !== $baseTable || $field !== 'RecordID') { - continue; - } - - // Rename conditions on the base table to the new alias - $conditionClause = preg_replace( - '/^"([^"]+)"\./', - "\"{$newTable}\".", - $conditionClause ?? '' - ); - - $subSelect->addWhere([$conditionClause => reset($condition)]); - } - - $shouldApplySubSelectAsCondition = $this->shouldApplySubSelectAsCondition($baseQuery); - - $this->owner->extend( - 'augmentMaxVersionSubSelect', - $subSelect, - $dataQuery, - $shouldApplySubSelectAsCondition - ); - - return $subSelect; - } - - /** - * Indicates if a subquery filtering versioned records should apply as a condition instead of an inner join - * - * @param SQLSelect $baseQuery - */ - protected function shouldApplySubSelectAsCondition(SQLSelect $baseQuery) - { - $baseTable = $this->baseTable(); - - $shouldApply = - $baseQuery->getLimit() === 1 || Config::inst()->get(static::class, 'use_conditions_over_inner_joins'); - - $this->owner->extend('updateApplyVersionedFiltersAsConditions', $shouldApply, $baseQuery, $baseTable); - - return $shouldApply; - } - - /** - * Filter the versioned history by a specific date and archive stage - * - * @param SQLSelect $query - * @param DataQuery $dataQuery - */ - protected function augmentSQLVersionedArchive(SQLSelect $query, DataQuery $dataQuery) - { - $baseTable = $this->baseTable(); - $date = $dataQuery->getQueryParam('Versioned.date'); - if (!$date) { - throw new InvalidArgumentException("Invalid archive date"); - } - - // Query against _Versions table first - $this->augmentSQLVersioned($query); - - // Validate stage - $stage = $dataQuery->getQueryParam('Versioned.stage'); - ReadingMode::validateStage($stage); - - $subSelect = $this->prepareMaxVersionSubSelect($query, $dataQuery); - - $subSelect->addWhere(["\"{$baseTable}_Versions_Latest\".\"LastEdited\" <= ?" => $date]); - - // Filter on appropriate stage column in addition to date - if ($this->hasStages()) { - $stageColumn = $stage === static::LIVE - ? 'WasPublished' - : 'WasDraft'; - $subSelect->addWhere("\"{$baseTable}_Versions_Latest\".\"{$stageColumn}\" = 1"); - } - - if ($this->shouldApplySubSelectAsCondition($query)) { - $subSelect->addWhere( - "\"{$baseTable}_Versions_Latest\".\"RecordID\" = \"{$baseTable}_Versions\".\"RecordID\"" - ); - - $query->addWhere([ - "\"{$baseTable}_Versions\".\"Version\" = ({$subSelect->sql($params)})" => $params, - ]); - - return; - } - - $subSelect->addSelect("\"{$baseTable}_Versions_Latest\".\"RecordID\""); - $subSelect->addGroupBy("\"{$baseTable}_Versions_Latest\".\"RecordID\""); - - // Join on latest version filtered by date - $query->addInnerJoin( - '(' . $subSelect->sql($params) . ')', - <<getQueryParam('Versioned.id'); - if (!$id) { - throw new InvalidArgumentException("Invalid id"); - } - - // Query against _Versions table first - $this->augmentSQLVersioned($query); - - $baseTable = $this->baseTable(); - - $query->addWhere(["\"$baseTable\".\"RecordID\"" => $id]); - $query->setOrderBy("Version DESC"); - $query->setLimit(1); - } - - /** - * Return latest version instances, regardless of whether they are on a particular stage. - * This provides "show all, including deleted" functionality. - * - * Note: latest_version ignores deleted versions, and will select the latest non-deleted - * version. - * - * @param SQLSelect $query - * @param DataQuery $dataQuery - */ - protected function augmentSQLVersionedLatest(SQLSelect $query, DataQuery $dataQuery) - { - // Query against _Versions table first - $this->augmentSQLVersioned($query); - - // Join and select only latest version - $baseTable = $this->baseTable(); - $subSelect = $this->prepareMaxVersionSubSelect($query, $dataQuery); - - $subSelect->addWhere("\"{$baseTable}_Versions_Latest\".\"WasDeleted\" = 0"); - - if ($this->shouldApplySubSelectAsCondition($query)) { - $subSelect->addWhere( - "\"{$baseTable}_Versions_Latest\".\"RecordID\" = \"{$baseTable}_Versions\".\"RecordID\"" - ); - - $query->addWhere([ - "\"{$baseTable}_Versions\".\"Version\" = ({$subSelect->sql($params)})" => $params, - ]); - - return; - } - - $subSelect->addSelect("\"{$baseTable}_Versions_Latest\".\"RecordID\""); - $subSelect->addGroupBy("\"{$baseTable}_Versions_Latest\".\"RecordID\""); - - // Join on latest version filtered by date - $query->addInnerJoin( - '(' . $subSelect->sql($params) . ')', - <<getQueryParam('Versioned.version'); - if (!$version) { - throw new InvalidArgumentException("Invalid version"); - } - - // Query against _Versions table first - $this->augmentSQLVersioned($query); - - // Add filter on version field - $baseTable = $this->baseTable(); - $query->addWhere([ - "\"{$baseTable}_Versions\".\"Version\"" => $version, - ]); - } - - /** - * If all versions are requested, ensure that records are sorted by this field - * - * @param SQLSelect $query - */ - protected function augmentSQLVersionedAll(SQLSelect $query) - { - // Query against _Versions table first - $this->augmentSQLVersioned($query, false); - - $baseTable = $this->baseTable(); - $query->addOrderBy("\"{$baseTable}_Versions\".\"Version\""); - } - - /** - * Determine if the given versioned table is a part of the sub-tree of the current dataobject - * This helps prevent rewriting of other tables that get joined in, in particular, many_many tables - * - * @param string $table - * @return bool True if this table should be versioned - */ - protected function isTableVersioned($table) - { - $schema = DataObject::getSchema(); - $tableClass = $schema->tableClass($table); - if (empty($tableClass)) { - return false; - } - - // Check that this class belongs to the same tree - $baseClass = $schema->baseDataClass($this->owner); - if (!is_a($tableClass, $baseClass ?? '', true)) { - return false; - } - - // Check that this isn't a derived table - // (e.g. _Live, or a many_many table) - $mainTable = $schema->tableName($tableClass); - if ($mainTable !== $table) { - return false; - } - - return true; - } - - /** - * For lazy loaded fields requiring extra sql manipulation, ie versioning. - * - * @param SQLSelect $query - * @param DataQuery $dataQuery - * @param DataObject $dataObject - */ - protected function augmentLoadLazyFields(SQLSelect &$query, DataQuery &$dataQuery = null, $dataObject) - { - // The VersionedMode local variable ensures that this decorator only applies to - // queries that have originated from the Versioned object, and have the Versioned - // metadata set on the query object. This prevents regular queries from - // accidentally querying the *_Versions tables. - $versionedMode = $dataObject->getSourceQueryParam('Versioned.mode'); - $modesToAllowVersioning = ['all_versions', 'latest_versions', 'archive', 'version']; - if (!empty($dataObject->Version) && - (!empty($versionedMode) && in_array($versionedMode, $modesToAllowVersioning ?? [])) - ) { - // This will ensure that augmentSQL will select only the same version as the owner, - // regardless of how this object was initially selected - $versionColumn = $this->owner->getSchema()->sqlColumnForField($this->owner, 'Version'); - $dataQuery->where([ - $versionColumn => $dataObject->Version - ]); - $dataQuery->setQueryParam('Versioned.mode', 'all_versions'); - } - } - - protected function augmentDatabase() - { - $owner = $this->owner; - $class = get_class($owner); - $schema = $owner->getSchema(); - $baseTable = $this->baseTable(); - $classTable = $schema->tableName($owner); - - $isRootClass = $class === $owner->baseClass(); - - // Build a list of suffixes whose tables need versioning - $allSuffixes = []; - $versionableExtensions = (array)$owner->config()->get('versionableExtensions'); - if (count($versionableExtensions ?? [])) { - foreach ($versionableExtensions as $versionableExtension => $suffixes) { - if ($owner->hasExtension($versionableExtension)) { - foreach ((array)$suffixes as $suffix) { - $allSuffixes[$suffix] = $versionableExtension; - } - } - } - } - - // Add the default table with an empty suffix to the list (table name = class name) - $allSuffixes[''] = null; - - foreach ($allSuffixes as $suffix => $extension) { - // Check tables for this build - if ($suffix) { - $suffixBaseTable = "{$baseTable}_{$suffix}"; - $suffixTable = "{$classTable}_{$suffix}"; - } else { - $suffixBaseTable = $baseTable; - $suffixTable = $classTable; - } - - $fields = $schema->databaseFields($class, false); - unset($fields['ID']); - if ($fields) { - $options = Config::inst()->get($class, 'create_table_options'); - $indexes = $schema->databaseIndexes($class, false); - $extensionClass = $allSuffixes[$suffix]; - if ($suffix && ($extension = $owner->getExtensionInstance($extensionClass))) { - if (!$extension instanceof VersionableExtension) { - throw new LogicException( - "Extension {$extensionClass} must implement VersionableExtension" - ); - } - // Allow versionable extension to customise table fields and indexes - try { - $extension->setOwner($owner); - if ($extension->isVersionedTable($suffixTable)) { - $extension->updateVersionableFields($suffix, $fields, $indexes); - } - } finally { - $extension->clearOwner(); - } - } - - // Build _Live table - if ($this->hasStages()) { - $liveTable = $this->stageTable($suffixTable, static::LIVE); - DB::require_table($liveTable, $fields, $indexes, false, $options); - } - - // Build _Versions table - //Unique indexes will not work on versioned tables, so we'll convert them to standard indexes: - $nonUniqueIndexes = $this->uniqueToIndex($indexes); - if ($isRootClass) { - // Create table for all versions - $versionFields = array_merge( - Config::inst()->get(static::class, 'db_for_versions_table'), - (array)$fields - ); - $versionIndexes = array_merge( - Config::inst()->get(static::class, 'indexes_for_versions_table'), - (array)$nonUniqueIndexes - ); - } else { - // Create fields for any tables of subclasses - $versionFields = array_merge( - [ - "RecordID" => "Int", - "Version" => "Int", - ], - (array)$fields - ); - $versionIndexes = array_merge( - [ - 'RecordID_Version' => [ - 'type' => 'unique', - 'columns' => ['RecordID', 'Version'] - ], - 'RecordID' => [ - 'type' => 'index', - 'columns' => ['RecordID'], - ], - 'Version' => [ - 'type' => 'index', - 'columns' => ['Version'], - ], - ], - (array)$nonUniqueIndexes - ); - } - - // Cleanup any orphans - $this->cleanupVersionedOrphans("{$suffixBaseTable}_Versions", "{$suffixTable}_Versions"); - - // Build versions table - DB::require_table("{$suffixTable}_Versions", $versionFields, $versionIndexes, true, $options); - } else { - DB::dont_require_table("{$suffixTable}_Versions"); - if ($this->hasStages()) { - $liveTable = $this->stageTable($suffixTable, static::LIVE); - DB::dont_require_table($liveTable); - } - } - } - } - - /** - * Cleanup orphaned records in the _Versions table - * - * @param string $baseTable base table to use as authoritative source of records - * @param string $childTable Sub-table to clean orphans from - */ - protected function cleanupVersionedOrphans($baseTable, $childTable) - { - // Avoid if disabled - if ($this->owner->config()->get('versioned_orphans_disabled')) { - return; - } - - // Skip if tables are the same (ignore case) - if (strcasecmp($childTable ?? '', $baseTable ?? '') === 0) { - return; - } - - // Skip if child table doesn't exist - // If it does, ensure query case matches found case - $tables = DB::get_schema()->tableList(); - if (!array_key_exists(strtolower($childTable ?? ''), $tables ?? [])) { - return; - } - $childTable = $tables[strtolower($childTable)]; - - // Select all orphaned version records - $orphanedQuery = SQLSelect::create() - ->selectField("\"{$childTable}\".\"ID\"") - ->setFrom("\"{$childTable}\""); - - // If we have a parent table limit orphaned records - // to only those that exist in this - if (array_key_exists(strtolower($baseTable ?? ''), $tables ?? [])) { - // Ensure we match db table case - $baseTable = $tables[strtolower($baseTable)]; - $orphanedQuery - ->addLeftJoin( - $baseTable, - "\"{$childTable}\".\"RecordID\" = \"{$baseTable}\".\"RecordID\" - AND \"{$childTable}\".\"Version\" = \"{$baseTable}\".\"Version\"" - ) - ->addWhere("\"{$baseTable}\".\"ID\" IS NULL"); - } - - $count = $orphanedQuery->count(); - if ($count > 0) { - DB::alteration_message("Removing {$count} orphaned versioned records", "deleted"); - $ids = $orphanedQuery->execute()->column(); - foreach ($ids as $id) { - DB::prepared_query("DELETE FROM \"{$childTable}\" WHERE \"ID\" = ?", [$id]); - } - } - } - - /** - * Helper for augmentDatabase() to find unique indexes and convert them to non-unique - * - * @param array $indexes The indexes to convert - * @return array $indexes - */ - private function uniqueToIndex($indexes) - { - foreach ($indexes as &$spec) { - if ($spec['type'] === 'unique') { - $spec['type'] = 'index'; - } - } - return $indexes; - } - - /** - * Generates a ($table)_version DB manipulation and injects it into the current $manipulation - * - * @param array $manipulation Source manipulation data - * @param string $class Class - * @param string $table Table Table for this class - * @param int $recordID ID of record to version - * @param array|string $stages Stage or array of affected stages - * @param bool $isDelete Set to true of version is created from a deletion - */ - protected function augmentWriteVersioned(&$manipulation, $class, $table, $recordID, $stages, $isDelete = false) - { - $schema = DataObject::getSchema(); - $baseDataClass = $schema->baseDataClass($class); - $baseDataTable = $schema->tableName($baseDataClass); - - // Set up a new entry in (table)_Versions - $newManipulation = [ - "command" => "insert", - "fields" => isset($manipulation[$table]['fields']) ? $manipulation[$table]['fields'] : [], - "class" => $class, - ]; - - // Add any extra, unchanged fields to the version record. - $data = DB::prepared_query("SELECT * FROM \"{$table}\" WHERE \"ID\" = ?", [$recordID])->record(); - if ($data) { - $fields = $schema->databaseFields($class, false); - if (is_array($fields)) { - $data = array_intersect_key($data ?? [], $fields); - - foreach ($data as $k => $v) { - // If the value is not set at all in the manipulation currently, use the existing value from the database - if (!array_key_exists($k, $newManipulation['fields'] ?? [])) { - $newManipulation['fields'][$k] = $v; - } - } - } - } - - // Ensure that the ID is instead written to the RecordID field - $newManipulation['fields']['RecordID'] = $recordID; - unset($newManipulation['fields']['ID']); - - // Generate next version ID to use - $nextVersion = 0; - if ($recordID) { - $nextVersion = DB::prepared_query( - "SELECT MAX(\"Version\") + 1 - FROM \"{$baseDataTable}_Versions\" WHERE \"RecordID\" = ?", - [$recordID] - )->value(); - } - $nextVersion = $nextVersion ?: 1; - - if ($class === $baseDataClass) { - // Write AuthorID for baseclass - if ((Security::getCurrentUser())) { - $userID = Security::getCurrentUser()->ID; - } else { - $userID = 0; - } - $wasPublished = (int)in_array(static::LIVE, (array)$stages); - $wasDraft = (int)in_array(static::DRAFT, (array)$stages); - $newManipulation['fields'] = array_merge( - $newManipulation['fields'], - [ - 'AuthorID' => $userID, - 'PublisherID' => $wasPublished ? $userID : 0, - 'WasPublished' => $wasPublished, - 'WasDraft' => $wasDraft, - 'WasDeleted' => (int)$isDelete, - ] - ); - - // Update main table version if not previously known - if (isset($manipulation[$table]['fields'])) { - $manipulation[$table]['fields']['Version'] = $nextVersion; - } - } - - // Update _Versions table manipulation - $newManipulation['fields']['Version'] = $nextVersion; - $manipulation["{$table}_Versions"] = $newManipulation; - } - - /** - * Rewrite the given manipulation to update the selected (non-default) stage - * - * @param array $manipulation Source manipulation data - * @param string $table Name of table - * @param int $recordID ID of record to version - */ - protected function augmentWriteStaged(&$manipulation, $table, $recordID) - { - // If the record has already been inserted in the (table), get rid of it. - if ($manipulation[$table]['command'] == 'insert') { - DB::prepared_query( - "DELETE FROM \"{$table}\" WHERE \"ID\" = ?", - [$recordID] - ); - } - - $newTable = $this->stageTable($table, Versioned::get_stage()); - $manipulation[$newTable] = $manipulation[$table]; - } - - /** - * Adds a WasDeleted=1 version entry for this record, and records any stages - * the deletion applies to - * - * @param string[]|string $stages Stage or array of affected stages - */ - protected function createDeletedVersion($stages = []) - { - // Skip if suppressed by parent delete - if (!$this->getDeleteWritesVersion()) { - return; - } - // Prepare manipulation - $baseTable = $this->owner->baseTable(); - $now = DBDatetime::now()->Rfc2822(); - // Ensure all fixed_fields are specified - $manipulation = [ - $baseTable => [ - 'fields' => [ - 'ID' => $this->owner->ID, - 'LastEdited' => $now, - 'Created' => $this->owner->Created ?: $now, - 'ClassName' => $this->owner->ClassName, - ], - ], - ]; - // Prepare "deleted" augment write - $this->augmentWriteVersioned( - $manipulation, - $this->owner->baseClass(), - $baseTable, - $this->owner->ID, - $stages, - true - ); - unset($manipulation[$baseTable]); - $this->owner->extend('augmentWriteDeletedVersion', $manipulation, $stages); - DB::manipulate($manipulation); - $this->owner->Version = $manipulation["{$baseTable}_Versions"]['fields']['Version']; - $this->owner->extend('onAfterVersionDelete'); - } - - protected function augmentWrite(&$manipulation) - { - // get Version number from base data table on write - $version = null; - $owner = $this->owner; - $baseDataTable = DataObject::getSchema()->baseDataTable($owner); - $migratingVersion = $this->getMigratingVersion(); - if (isset($manipulation[$baseDataTable]['fields'])) { - if ($migratingVersion) { - $manipulation[$baseDataTable]['fields']['Version'] = $migratingVersion; - } - if (isset($manipulation[$baseDataTable]['fields']['Version'])) { - $version = $manipulation[$baseDataTable]['fields']['Version']; - } - } - - // Update all tables - $thisVersion = null; - $tables = array_keys($manipulation ?? []); - foreach ($tables as $table) { - // Make sure that the augmented write is being applied to a table that can be versioned - $class = isset($manipulation[$table]['class']) ? $manipulation[$table]['class'] : null; - if (!$class || !$this->canBeVersioned($class)) { - unset($manipulation[$table]); - continue; - } - - // Get ID field - $id = $manipulation[$table]['id'] - ? $manipulation[$table]['id'] - : $manipulation[$table]['fields']['ID']; - if (!$id) { - throw new InvalidArgumentException( - "Couldn't find ID in " . var_export($manipulation[$table], true) - ); - } - - if ($version < 0 || $this->getNextWriteWithoutVersion()) { - // Putting a Version of -1 is a signal to leave the version table alone, despite their being no version - unset($manipulation[$table]['fields']['Version']); - } else { - // All writes are to draft, only live affect both - $stages = !$this->hasStages() || static::get_stage() === static::LIVE - ? [Versioned::DRAFT, Versioned::LIVE] - : [Versioned::DRAFT]; - $this->augmentWriteVersioned($manipulation, $class, $table, $id, $stages, false); - } - - // Remove "Version" column from subclasses of baseDataClass - if (!$this->hasVersionField($table)) { - unset($manipulation[$table]['fields']['Version']); - } - - // Grab a version number - it should be the same across all tables. - if (isset($manipulation[$table]['fields']['Version'])) { - $thisVersion = $manipulation[$table]['fields']['Version']; - } - - // If we're editing Live, then write to (table)_Live as well as (table) - if ($this->hasStages() && static::get_stage() === static::LIVE) { - $this->augmentWriteStaged($manipulation, $table, $id); - } - } - - // Clear the migration flag - if ($migratingVersion) { - $this->setMigratingVersion(null); - } - - // Add the new version # back into the data object, for accessing - // after this write - if ($thisVersion !== null) { - $owner->Version = str_replace("'", "", $thisVersion ?? ''); - } - } - - /** - * Perform a write without affecting the version table. - * - * @return int The ID of the record - */ - public function writeWithoutVersion() - { - $this->setNextWriteWithoutVersion(true); - - return $this->owner->write(); - } - - /** - * - */ - protected function onAfterWrite() - { - $this->setNextWriteWithoutVersion(false); - } - - /** - * Check if next write is without version - * - * @return bool - */ - public function getNextWriteWithoutVersion() - { - return $this->owner->getField(Versioned::NEXT_WRITE_WITHOUT_VERSIONED); - } - - /** - * Set if next write should be without version or not - * - * @param bool $flag - * @return DataObject owner - */ - public function setNextWriteWithoutVersion($flag) - { - return $this->owner->setField(Versioned::NEXT_WRITE_WITHOUT_VERSIONED, $flag); - } - - /** - * Check if delete() should write _Version rows or not - * - * @return bool - */ - public function getDeleteWritesVersion() - { - return !$this->owner->getField(Versioned::DELETE_WRITES_VERSION_DISABLED); - } - - /** - * Set if delete() should write _Version rows - * - * @param bool $flag - * @return DataObject owner - */ - public function setDeleteWritesVersion($flag) - { - return $this->owner->setField(Versioned::DELETE_WRITES_VERSION_DISABLED, !$flag); - } - - /** - * Helper method to safely suppress delete callback - * - * @param callable $callback - * @return mixed Result of $callback() - */ - protected function suppressDeletedVersion($callback) - { - $original = $this->getDeleteWritesVersion(); - try { - $this->setDeleteWritesVersion(false); - return $callback(); - } finally { - $this->setDeleteWritesVersion($original); - } - } - - /** - * If a write was skipped, then we need to ensure that we don't leave a - * migrateVersion() value lying around for the next write. - */ - protected function onAfterSkippedWrite() - { - $this->setMigratingVersion(null); - } - - /** - * This function should return true if the current user can publish this record. - * It can be overloaded to customise the security model for an application. - * - * Denies permission if any of the following conditions is true: - * - canPublish() on any extension returns false - * - canEdit() returns false - * - * @param Member $member - * @return bool True if the current user can publish this record. - */ - public function canPublish($member = null) - { - if (!$member) { - $member = Security::getCurrentUser(); - } - - if (Permission::checkMember($member, "ADMIN")) { - return true; - } - - // Standard mechanism for accepting permission changes from extensions - $owner = $this->owner; - $extended = $owner->extendedCan('canPublish', $member); - if ($extended !== null) { - return $extended; - } - - // Default to relying on edit permission - return $owner->canEdit($member); - } - - protected function extendCanPublish() - { - // prevent canPublish() from extending itself - return null; - } - - /** - * Check if the current user can delete this record from live - * - * @param null $member - * @return mixed - */ - public function canUnpublish($member = null) - { - if (!$member) { - $member = Security::getCurrentUser(); - } - - if (Permission::checkMember($member, "ADMIN")) { - return true; - } - - // Standard mechanism for accepting permission changes from extensions - $owner = $this->owner; - $extended = $owner->extendedCan('canUnpublish', $member); - if ($extended !== null) { - return $extended; - } - - // Default to relying on canPublish - return $owner->canPublish($member); - } - - protected function extendCanUnpublish() - { - // prevent canUnpublish() extending itself - return null; - } - - /** - * Check if the current user is allowed to archive this record. - * - * We're intentionally using the canDelete check for archiving, - * since there's no concept of "deleting" a versioned record - * and having separate permission checks was confusing and easy - * to forget. - */ - public function canDelete($member = null): ?bool - { - // If the user isn't allowed to unpublish, they're definitely - // not allowed to archive live content. - if ($this->hasStages() && $this->isPublished() && !$this->getOwner()->canUnpublish($member)) { - return false; - } - return null; - } - - /** - * Check if the user can revert this record to live - * - * @param Member $member - * @return bool - */ - public function canRevertToLive($member = null) - { - $owner = $this->owner; - - // Can't revert if not on live - if (!$owner->isPublished()) { - return false; - } - - if (!$member) { - $member = Security::getCurrentUser(); - } - - if (Permission::checkMember($member, "ADMIN")) { - return true; - } - - // Standard mechanism for accepting permission changes from extensions - $extended = $owner->extendedCan('canRevertToLive', $member); - if ($extended !== null) { - return $extended; - } - - // Default to canEdit - return $owner->canEdit($member); - } - - protected function extendCanRevertToLive() - { - // Prevent canRevertToLive() extending itself - return null; - } - - /** - * Check if the user can restore this record to draft - * - * @param Member $member - * @return bool - */ - public function canRestoreToDraft($member = null) - { - $owner = $this->owner; - - if (!$member) { - $member = Security::getCurrentUser(); - } - - if (Permission::checkMember($member, "ADMIN")) { - return true; - } - - // Standard mechanism for accepting permission changes from extensions - $extended = $owner->extendedCan('canRestoreToDraft', $member); - if ($extended !== null) { - return $extended; - } - - // Default to canEdit - return $owner->canEdit($member); - } - - protected function extendcanRestoreToDraft() - { - // Prevent canRestoreToDraft() extending itself - return null; - } - - /** - * Extend permissions to include additional security for objects that are not published to live. - * - * @param Member $member - * @return bool|null - */ - protected function canView($member = null) - { - // Invoke default version-gnostic canView - if ($this->owner->canViewVersioned($member) === false) { - return false; - } - return null; - } - - /** - * Determine if there are any additional restrictions on this object for the given reading version. - * - * Override this in a subclass to customise any additional effect that Versioned applies to canView. - * - * This is expected to be called by canView, and thus is only responsible for denying access if - * the default canView would otherwise ALLOW access. Thus it should not be called in isolation - * as an authoritative permission check. - * - * This has the following extension points: - * - canViewDraft is invoked if Mode = stage and Stage = stage - * - canViewArchived is invoked if Mode = archive - * - * @param Member $member - * @return bool False is returned if the current viewing mode denies visibility - */ - public function canViewVersioned($member = null) - { - // Bypass when live stage - $owner = $this->owner; - - // Bypass if site is unsecured - if (!Versioned::get_draft_site_secured()) { - return true; - } - - // Get reading mode from source query (or current mode) - $readingParams = $owner->getSourceQueryParams() - // Guess record mode from current reading mode instead - ?: ReadingMode::toDataQueryParams(static::get_reading_mode()); - - // If this is the live record we can view it - if (isset($readingParams["Versioned.mode"]) - && $readingParams["Versioned.mode"] === 'stage' - && $readingParams["Versioned.stage"] === static::LIVE - ) { - return true; - } - - // Bypass if record doesn't have a live stage - if (!$this->hasStages()) { - return true; - } - - // If we weren't definitely loaded from live, and we can't view non-live content, we need to - // check to make sure this version is the live version and so can be viewed - $latestVersion = Versioned::get_versionnumber_by_stage($this->owner->baseClass(), static::LIVE, $owner->ID); - if ($latestVersion == $owner->Version) { - // Even if this is loaded from a non-live stage, this is the live version - return true; - } - - // If stages are synchronised treat this as the live stage - if (!$this->stagesDiffer()) { - return true; - } - - // Extend versioned behaviour - $extended = $owner->extendedCan('canViewNonLive', $member); - if ($extended !== null) { - return (bool)$extended; - } - - // Fall back to default permission check - $permissions = Config::inst()->get(get_class($owner), 'non_live_permissions'); - $check = Permission::checkMember($member, $permissions); - return (bool)$check; - } - - /** - * Determines canView permissions for the latest version of this object on a specific stage. - * Usually the stage is read from {@link Versioned::current_stage()}. - * - * This method should be invoked by user code to check if a record is visible in the given stage. - * - * This method should not be called via ->extend('canViewStage'), but rather should be - * overridden in the extended class. - * - * @param string $stage - * @param Member $member - * @return bool - */ - public function canViewStage($stage = Versioned::LIVE, $member = null) - { - return static::withVersionedMode(function () use ($stage, $member) { - Versioned::set_stage($stage); - - $owner = $this->owner; - $baseClass = DataObject::getSchema()->baseDataClass($owner); - $versionFromStage = DataObject::get($baseClass)->byID($owner->ID); - - return $versionFromStage ? $versionFromStage->canView($member) : false; - }); - } - - /** - * Determine if a class is supporting the Versioned extensions (e.g. - * $table_Versions does exists). - * - * @param string $class Class name - * @return boolean - */ - public function canBeVersioned($class) - { - return ClassInfo::exists($class) - && is_subclass_of($class, DataObject::class) - && DataObject::getSchema()->classHasTable($class); - } - - /** - * Check if a certain table has the 'Version' field. - * - * @param string $table Table name - * - * @return boolean Returns false if the field isn't in the table, true otherwise - */ - public function hasVersionField($table) - { - // Base table has version field - $class = DataObject::getSchema()->tableClass($table); - return $class === DataObject::getSchema()->baseDataClass($class); - } - - /** - * @param string $table - * - * @return string - */ - public function extendWithSuffix($table) - { - $owner = $this->owner; - $versionableExtensions = (array)$owner->config()->get('versionableExtensions'); - - if (count($versionableExtensions ?? [])) { - foreach ($versionableExtensions as $versionableExtension => $suffixes) { - if ($owner->hasExtension($versionableExtension)) { - /** @var VersionableExtension|Extension $ext */ - $ext = $owner->getExtensionInstance($versionableExtension); - try { - $ext->setOwner($owner); - $table = $ext->extendWithSuffix($table); - } finally { - $ext->clearOwner(); - } - } - } - } - - return $table; - } - - /** - * Determines if the current draft version is the same as live or rather, that there are no outstanding draft changes - * - * @return bool - */ - public function latestPublished() - { - $id = $this->owner->ID ?: $this->owner->OldID; - if (!$id) { - return false; - } - if (!$this->hasStages()) { - return true; - } - $draftVersion = static::get_versionnumber_by_stage($this->owner, Versioned::DRAFT, $id); - $liveVersion = static::get_versionnumber_by_stage($this->owner, Versioned::LIVE, $id); - return $draftVersion === $liveVersion; - } - - /** - * Publishes this object to Live, but doesn't publish owned objects. - * - * User code should call {@see canPublish()} prior to invoking this method. - * - * @return bool True if publish was successful - */ - public function publishSingle() - { - $owner = $this->owner; - // get the last published version - $original = null; - if ($this->isPublished()) { - $original = Versioned::get_by_stage($owner->baseClass(), Versioned::LIVE) - ->byID($owner->ID); - } - - // Publish it - $owner->invokeWithExtensions('onBeforePublish', $original); - $owner->writeToStage(static::LIVE); - $owner->invokeWithExtensions('onAfterPublish', $original); - return true; - } - - /** - * Removes the record from both live and stage - * - * User code should call {@see canDelete()} prior to invoking this method. - * - * @return bool Success - */ - public function doArchive() - { - $owner = $this->owner; - $owner->invokeWithExtensions('onBeforeArchive', $this); - $owner->deleteFromChangeSets(); - // Unpublish without creating deleted version - $this->suppressDeletedVersion(function () use ($owner) { - $owner->doUnpublish(); - }); - // Create deleted version in both stages - $this->createDeletedVersion([ - static::LIVE, - static::DRAFT, - ]); - $this->suppressDeletedVersion(function () use ($owner) { - $owner->deleteFromStage(static::DRAFT); - }); - $owner->invokeWithExtensions('onAfterArchive', $this); - return true; - } - - /** - * Removes this record from the live site - * - * User code should call {@see canUnpublish()} prior to invoking this method. - * - * @return bool Flag whether the unpublish was successful - */ - public function doUnpublish() - { - $owner = $this->owner; - // Skip if this record isn't saved - if (!$owner->isInDB()) { - return false; - } - - // Skip if this record isn't on live - if (!$owner->isPublished()) { - return false; - } - - $owner->invokeWithExtensions('onBeforeUnpublish'); - - // Modify in isolated mode - static::withVersionedMode(function () use ($owner) { - static::set_stage(static::LIVE); - - // Re-fetch the current DataObject to ensure we have data from the LIVE stage - // This is particularly relevant for DataObject's in a modified state so that - // any delete extensions have the correct database record values - $obj = $owner::get()->byID($owner->ID); - if (!$obj) { - return; - } - $obj->setDeleteWritesVersion($owner->getDeleteWritesVersion()); - $obj->delete(); - }); - - $owner->invokeWithExtensions('onAfterUnpublish'); - return true; - } - - protected function onAfterDelete() - { - // Create deleted record for current stage - $this->createDeletedVersion(static::get_stage()); - } - - /** - * Determine if this object is published, and has any published owners. - * If this is true, a warning should be shown before this is published. - * - * Note: This method returns false if the object itself is unpublished, - * since owners are only considered on the same stage as the record itself. - * - * @return bool - */ - public function hasPublishedOwners() - { - if (!$this->isPublished()) { - return false; - } - // Count live owners - $baseClass = $this->owner->baseClass(); - - /** @var Versioned|RecursivePublishable|DataObject $liveRecord */ - $liveRecord = static::get_by_stage($baseClass, Versioned::LIVE)->byID($this->owner->ID); - return $liveRecord->findOwners(false)->count() > 0; - } - - /** - * Revert the draft changes: replace the draft content with the content on live - * - * User code should call {@see canRevertToLive()} prior to invoking this method. - * - * @return bool True if the revert was successful - */ - public function doRevertToLive() - { - $owner = $this->owner; - $owner->invokeWithExtensions('onBeforeRevertToLive'); - $owner->rollbackRecursive(static::LIVE); - $owner->invokeWithExtensions('onAfterRevertToLive'); - return true; - } - - /** - * Move a database record from one stage to the other. - * - * @param int|string|null $fromStage Place to copy from. Can be either a stage name or a version number. - * Null copies current object to stage - * @param string $toStage Place to copy to. Must be a stage name. - */ - public function copyVersionToStage($fromStage, $toStage) - { - $owner = $this->owner; - $owner->invokeWithExtensions('onBeforeVersionedPublish', $fromStage, $toStage); - - // Get at specific version - $from = $this->getAtVersion($fromStage); - if (!$from) { - $baseClass = $owner->baseClass(); - throw new InvalidArgumentException("Can't find {$baseClass}#{$owner->ID} in stage {$fromStage}"); - } - - $from->writeToStage($toStage); - $owner->invokeWithExtensions('onAfterVersionedPublish', $fromStage, $toStage); - } - - /** - * Get version migrated to - * - * @return int|null - */ - public function getMigratingVersion() - { - return $this->owner->getField(Versioned::MIGRATING_VERSION); - } - - /** - * Set the migrating version. - * - * @param string $version The version. - * @return DataObject Owner - */ - public function setMigratingVersion($version) - { - return $this->owner->setField(Versioned::MIGRATING_VERSION, $version); - } - - /** - * Compare two stages to see if they're different. - * - * Only checks the version numbers, not the actual content. - * - * @return bool - */ - public function stagesDiffer() - { - $id = $this->owner->ID ?: $this->owner->OldID; - if (!$id || !$this->hasStages()) { - return false; - } - - $draftVersion = static::get_versionnumber_by_stage($this->owner, Versioned::DRAFT, $id); - $liveVersion = static::get_versionnumber_by_stage($this->owner, Versioned::LIVE, $id); - $stagesDiffer = $draftVersion !== $liveVersion; - - $this->owner->extend('updateStagesDiffer', $stagesDiffer); - - return (bool) $stagesDiffer; - } - - /** - * Determine if content differs on stages including nested objects - * 'owns' configuration drives the relationship traversal - */ - public function stagesDifferRecursive(): bool - { - $service = Injector::inst()->get(RecursiveStagesInterface::class); - - return $service->stagesDifferRecursive($this->owner); - } - - /** - * @param string $filter - * @param string $sort - * @param string $limit - * @param string $join Deprecated, use leftJoin($table, $joinClause) instead - * @param string $having @deprecated 2.2.0 The $having parameter does nothing and will be removed without - * equivalent functionality to replace it - * @return ArrayList - */ - public function Versions($filter = "", $sort = "", $limit = "", $join = "", $having = "") - { - if ($having) { - Deprecation::withSuppressedNotice(function () { - $message = 'The $having parameter does nothing and will be removed without equivalent' - . ' functionality to replace it'; - Deprecation::notice('2.2.0', $message); - }); - } - - $owner = $this->owner; - - // When an object is not yet in the Database, we can't get its versions - if (!$owner->isInDB()) { - return ArrayList::create(); - } - - // Make sure the table names are not postfixed (e.g. _Live) - $oldMode = static::get_reading_mode(); - static::set_stage(static::DRAFT); - - $list = DataObject::get(DataObject::getSchema()->baseDataClass($owner), $filter, $sort, $join, $limit); - - $query = $list->dataQuery()->query(); - - $baseTable = null; - foreach ($query->getFrom() as $table => $tableJoin) { - if (is_string($tableJoin) && $tableJoin[0] == '"') { - $baseTable = str_replace('"', '', $tableJoin ?? ''); - } elseif (is_string($tableJoin) && substr($tableJoin ?? '', 0, 5) != 'INNER') { - $query->setFrom([ - $table => "LEFT JOIN \"$table\" ON \"$table\".\"RecordID\"=\"{$baseTable}_Versions\".\"RecordID\"" - . " AND \"$table\".\"Version\" = \"{$baseTable}_Versions\".\"Version\"" - ]); - } - $query->renameTable($table, $table . '_Versions'); - } - - // Add all _Versions columns - foreach (Config::inst()->get(static::class, 'db_for_versions_table') as $name => $type) { - $query->selectField(sprintf('"%s_Versions"."%s"', $baseTable, $name), $name); - } - - $query->addWhere([ - "\"{$baseTable}_Versions\".\"RecordID\" = ?" => $owner->ID - ]); - $query->setOrderBy(($sort) ? $sort - : "\"{$baseTable}_Versions\".\"LastEdited\" DESC, \"{$baseTable}_Versions\".\"Version\" DESC"); - - $records = $query->execute(); - $versions = new ArrayList(); - - foreach ($records as $record) { - $versions->push(new Versioned_Version($record)); - } - - Versioned::set_reading_mode($oldMode); - return $versions; - } - - /** - * Compare two version, and return the diff between them. - * - * @param string $from The version to compare from. - * @param string $to The version to compare to. - * - * @return DataObject - */ - public function compareVersions($from, $to) - { - $owner = $this->owner; - $baseClass = $this->owner->baseClass(); - - $fromRecord = Versioned::get_version($baseClass, $owner->ID, $from); - $toRecord = Versioned::get_version($baseClass, $owner->ID, $to); - - $diff = new DataDifferencer($fromRecord, $toRecord); - - return $diff->diffedData(); - } - - /** - * Return the base table - the class that directly extends DataObject. - * - * Protected so it doesn't conflict with DataObject::baseTable() - * - * @param string $stage - * @return string - */ - protected function baseTable($stage = null) - { - $baseTable = $this->owner->baseTable(); - return $this->stageTable($baseTable, $stage); - } - - /** - * Given a table and stage determine the table name. - * - * Note: Stages this asset does not exist in will default to the draft table. - * - * @param string $table Main table - * @param string $stage - * @return string Staged table name - */ - public function stageTable($table, $stage) - { - if ($this->hasStages() && $stage === static::LIVE) { - return "{$table}_{$stage}"; - } - return $table; - } - - //-----------------------------------------------------------------------------------------------// - - - /** - * Determine if the current user is able to set the given site stage / archive - * - * @param HTTPRequest $request - * @return bool - */ - public static function can_choose_site_stage($request) - { - // Request is allowed if stage isn't being modified - if ((!$request->getVar('stage') || $request->getVar('stage') === static::LIVE) - && !$request->getVar('archiveDate') - ) { - return true; - } - - // Request is allowed if unsecuredDraftSite is enabled - if (!static::get_draft_site_secured()) { - return true; - } - - // Predict if choose_site_stage() will allow unsecured draft assignment by session - if (Config::inst()->get(static::class, 'use_session') && $request->getSession()->get('unsecuredDraftSite')) { - return true; - } - - // Check permissions with member ID in session. - $member = Security::getCurrentUser(); - $permissions = Config::inst()->get(get_called_class(), 'non_live_permissions'); - return $member && Permission::checkMember($member, $permissions); - } - - /** - * Choose the stage the site is currently on. - * - * If $_GET['stage'] is set, then it will use that stage, and store it in - * the session. - * - * if $_GET['archiveDate'] is set, it will use that date, and store it in - * the session. - * - * If neither of these are set, it checks the session, otherwise the stage - * is set to 'Live'. - * @param HTTPRequest $request - */ - public static function choose_site_stage(HTTPRequest $request) - { - $mode = static::get_default_reading_mode(); - - // Check any pre-existing session mode - $useSession = Config::inst()->get(static::class, 'use_session'); - $updateSession = false; - if ($useSession) { - // Boot reading mode from session - $mode = $request->getSession()->get('readingMode') ?: $mode; - - // Set draft site security if disabled for this session - if ($request->getSession()->get('unsecuredDraftSite')) { - static::set_draft_site_secured(false); - } - } - - // Verify if querystring contains valid reading mode - $queryMode = ReadingMode::fromQueryString($request->getVars()); - if ($queryMode) { - $mode = $queryMode; - $updateSession = true; - } - - // Save reading mode - Versioned::set_reading_mode($mode); - - // Set mode if session enabled - if ($useSession && $updateSession) { - $request->getSession()->set('readingMode', $mode); - } - - if (!headers_sent() && !Director::is_cli()) { - if (Versioned::get_stage() === static::LIVE) { - // clear the cookie if it's set - if (Cookie::get('bypassStaticCache')) { - Cookie::force_expiry('bypassStaticCache', null, null, false, true /* httponly */); - } - } else { - // set the cookie if it's cleared - if (!Cookie::get('bypassStaticCache')) { - Cookie::set('bypassStaticCache', '1', 0, null, null, false, true /* httponly */); - } - } - } - } - - /** - * Set the current reading mode. - * - * @param string $mode - */ - public static function set_reading_mode($mode) - { - Versioned::$reading_mode = $mode; - } - - /** - * Get the current reading mode. - * - * @return string - */ - public static function get_reading_mode() - { - return Versioned::$reading_mode; - } - - /** - * Get the current reading stage. - * - * @return string - */ - public static function get_stage() - { - $parts = explode('.', Versioned::get_reading_mode() ?? ''); - - if ($parts[0] == 'Stage') { - return $parts[1]; - } - return null; - } - - /** - * Get the current archive date. - * - * @return string - */ - public static function current_archived_date() - { - $parts = explode('.', Versioned::get_reading_mode() ?? ''); - if ($parts[0] == 'Archive') { - return $parts[1]; - } - return null; - } - - /** - * Get the current archive stage. - * - * @return string - */ - public static function current_archived_stage() - { - $parts = explode('.', Versioned::get_reading_mode() ?? ''); - if (sizeof($parts ?? []) === 3 && $parts[0] == 'Archive') { - return $parts[2]; - } - return static::DRAFT; - } - - /** - * Set the reading stage. - * - * @param string $stage New reading stage. - * @throws InvalidArgumentException - */ - public static function set_stage($stage) - { - ReadingMode::validateStage($stage); - static::set_reading_mode('Stage.' . $stage); - } - - /** - * Replace default mode. - * An non-default mode should be specified via querystring arguments. - * - * @param string $mode - */ - public static function set_default_reading_mode($mode) - { - Versioned::$default_reading_mode = $mode; - } - - /** - * Get default reading mode - * - * @return string - */ - public static function get_default_reading_mode() - { - return Versioned::$default_reading_mode ?: Versioned::DEFAULT_MODE; - } - - /** - * Check if draft site should be secured. - * Can be turned off if draft site unauthenticated - * - * @return bool - */ - public static function get_draft_site_secured() - { - if (isset(static::$is_draft_site_secured)) { - return (bool)static::$is_draft_site_secured; - } - // Config default - return (bool)Config::inst()->get(Versioned::class, 'draft_site_secured'); - } - - /** - * Set if the draft site should be secured or not - * - * @param bool $secured - */ - public static function set_draft_site_secured($secured) - { - static::$is_draft_site_secured = $secured; - } - - /** - * Set the reading archive date. - * - * @param string $date New reading archived date. - * @param string $stage Set stage - */ - public static function reading_archived_date($date, $stage = Versioned::DRAFT) - { - ReadingMode::validateStage($stage); - Versioned::set_reading_mode('Archive.' . $date . '.' . $stage); - } - - /** - * Get a singleton instance of a class in the given stage. - * - * @template T of DataObject - * @param class-string $class The name of the class. - * @param string $stage The name of the stage. - * @param string $filter A filter to be inserted into the WHERE clause. - * @param boolean $cache Use caching. - * @param string $sort A sort expression to be inserted into the ORDER BY clause. - * @return T&static - */ - public static function get_one_by_stage($class, $stage, $filter = '', $cache = true, $sort = '') - { - return static::withVersionedMode(function () use ($class, $stage, $filter, $cache, $sort) { - Versioned::set_stage($stage); - return DataObject::get_one($class, $filter, $cache, $sort); - }); - } - - /** - * Gets the current version number of a specific record. - * - * @param string $class Class to search - * @param string $stage Stage name - * @param int $id ID of the record - * @param bool $cache Set to true to turn on cache - * @return int|null Return the version number, or null if not on this stage - */ - public static function get_versionnumber_by_stage($class, $stage, $id, $cache = true) - { - $version = static::determineVersionNumberByStage($class, $stage, $id, $cache); - $className = $class instanceof DataObject ? $class->ClassName : $class; - $object = DataObject::singleton($className); - $object->invokeWithExtensions('updateGetVersionNumberByStage', $version, $class, $stage, $id, $cache); - - return $version; - } - - /** - * @param DataObject|string $class - * @param string $stage - * @param int $id - * @param bool $cache - * @return int|null - */ - private static function determineVersionNumberByStage($class, $stage, $id, $cache) - { - ReadingMode::validateStage($stage); - $baseClass = DataObject::getSchema()->baseDataClass($class); - $stageTable = DataObject::getSchema()->tableName($baseClass); - if ($stage === static::LIVE) { - $stageTable .= "_{$stage}"; - } - - // cached call - if ($cache) { - if (isset(Versioned::$cache_versionnumber[$baseClass][$stage][$id])) { - return Versioned::$cache_versionnumber[$baseClass][$stage][$id] ?: null; - } elseif (isset(Versioned::$cache_versionnumber[$baseClass][$stage]['_complete'])) { - // if the cache was marked as "complete" then we know the record is missing, just return null - // this is used for treeview optimisation to avoid unnecessary re-requests for draft pages - return null; - } - } - - // get version as performance-optimized SQL query (gets called for each record in the sitetree) - $version = DB::prepared_query( - "SELECT \"Version\" FROM \"$stageTable\" WHERE \"ID\" = ?", - [$id] - )->value(); - - // cache value (if required) - if ($cache) { - if (!isset(Versioned::$cache_versionnumber[$baseClass])) { - Versioned::$cache_versionnumber[$baseClass] = []; - } - - if (!isset(Versioned::$cache_versionnumber[$baseClass][$stage])) { - Versioned::$cache_versionnumber[$baseClass][$stage] = []; - } - - // Internally store nulls as 0 - Versioned::$cache_versionnumber[$baseClass][$stage][$id] = $version ?: 0; - } - - return $version ?: null; - } - - /** - * Hook into {@link Hierarchy::prepopulateTreeDataCache}. - * - * @param DataList|array $recordList The list of records to prepopulate caches for. Null for all records. - * @param array $options A map of hints about what should be cached. "numChildrenMethod" and - * "childrenMethod" are allowed keys. - */ - protected function onPrepopulateTreeDataCache($recordList = null, array $options = []) - { - $idList = is_array($recordList) ? $recordList : - ($recordList instanceof DataList ? $recordList->column('ID') : null); - Versioned::prepopulate_versionnumber_cache($this->owner->baseClass(), Versioned::DRAFT, $idList); - Versioned::prepopulate_versionnumber_cache($this->owner->baseClass(), Versioned::LIVE, $idList); - } - - /** - * Pre-populate the cache for Versioned::get_versionnumber_by_stage() for - * a list of record IDs, for more efficient database querying. If $idList - * is null, then every record will be pre-cached. - * - * @param string $class - * @param string $stage - * @param array $idList - */ - public static function prepopulate_versionnumber_cache($class, $stage, $idList = null) - { - ReadingMode::validateStage($stage); - if (!Config::inst()->get(static::class, 'prepopulate_versionnumber_cache')) { - return; - } - - $singleton = DataObject::singleton($class); - $baseClass = $singleton->baseClass(); - $baseTable = $singleton->baseTable(); - $stageTable = $singleton->stageTable($baseTable, $stage); - - $filter = ""; - $parameters = []; - if ($idList) { - // Validate the ID list - foreach ($idList as $id) { - if (!is_numeric($id)) { - throw new InvalidArgumentException( - "Bad ID passed to Versioned::prepopulate_versionnumber_cache() in \$idList: " . $id - ); - } - } - $filter = 'WHERE "ID" IN (' . DB::placeholders($idList) . ')'; - $parameters = $idList; - - // If we are caching IDs for _all_ records then we can mark this cache as "complete" and in the case of a cache-miss - // no subsequent call is necessary - } else { - Versioned::$cache_versionnumber[$baseClass][$stage] = [ '_complete' => true ]; - } - - $versions = DB::prepared_query("SELECT \"ID\", \"Version\" FROM \"$stageTable\" $filter", $parameters)->map(); - - foreach ($versions as $id => $version) { - Versioned::$cache_versionnumber[$baseClass][$stage][$id] = $version; - } - - $className = $class instanceof DataObject ? $class->ClassName : $class; - $object = DataObject::singleton($className); - $object->invokeWithExtensions('updatePrePopulateVersionNumberCache', $versions, $class, $stage, $idList); - } - - /** - * Get a set of class instances by the given stage. - * - * @template T of DataObject - * @param class-string $class The name of the class. - * @param string $stage The name of the stage. - * @param string $filter A filter to be inserted into the WHERE clause. - * @param string $sort A sort expression to be inserted into the ORDER BY clause. - * @param string $join Deprecated, use leftJoin($table, $joinClause) instead - * @param int $limit A limit on the number of records returned from the database. - * @param string $containerClass The container class for the result set (default is DataList) - * - * @return DataList A modified DataList designated to the specified stage - */ - public static function get_by_stage( - $class, - $stage, - $filter = '', - $sort = '', - $join = '', - $limit = null, - $containerClass = DataList::class - ) { - ReadingMode::validateStage($stage); - $result = DataObject::get($class, $filter, $sort, $join, $limit, $containerClass); - return $result->setDataQueryParam([ - 'Versioned.mode' => 'stage', - 'Versioned.stage' => $stage - ]); - } - - /** - * Delete this record from the given stage - * - * @param string $stage - */ - public function deleteFromStage($stage) - { - ReadingMode::validateStage($stage); - $owner = $this->owner; - static::withVersionedMode(function () use ($stage, $owner) { - Versioned::set_stage($stage); - $clone = clone $owner; - $clone->delete(); - }); - - // Fix the version number cache (in case you go delete from stage and then check ExistsOnLive) - $baseClass = $owner->baseClass(); - Versioned::$cache_versionnumber[$baseClass][$stage][$owner->ID] = null; - } - - /** - * Write the given record to the given stage. - * Note: If writing to live, this will write to stage as well. - * - * @param string $stage - * @param boolean $forceInsert - * @return int The ID of the record - */ - public function writeToStage($stage, $forceInsert = false) - { - ReadingMode::validateStage($stage); - $owner = $this->owner; - return static::withVersionedMode(function () use ($stage, $forceInsert, $owner) { - $oldParams = $owner->getSourceQueryParams(); - try { - // Lazy load and reset version in current stage prior to resetting write stage - $owner->forceChange(); - $owner->Version = null; - - // Migrate stage prior to write - Versioned::set_stage($stage); - $owner->setSourceQueryParam('Versioned.mode', 'stage'); - $owner->setSourceQueryParam('Versioned.stage', $stage); - - // Write - $owner->invokeWithExtensions('onBeforeWriteToStage', $stage, $forceInsert); - return $owner->write(false, $forceInsert); - } finally { - // Revert global state - $owner->invokeWithExtensions('onAfterWriteToStage', $stage, $forceInsert); - $owner->setSourceQueryParams($oldParams); - } - }); - } - - /** - * Recursively rollback draft to the given version. This will also rollback any owned objects - * at that point in time to the same date. Objects which didn't exist (or weren't attached) - * to the record at the target point in time will be "unlinked", which dis-associates - * the record without requiring a hard deletion. - * - * @param int|string|null $version Version ID or Versioned::LIVE to rollback from live. - * Pass in null to rollback to the current object - * @return DataObject|Versioned The object rolled back - */ - public function rollbackRecursive($version = null) - { - $owner = $this->owner; - $owner->invokeWithExtensions('onBeforeRollbackRecursive', $version); - $owner->rollbackSingle($version); - - // Rollback relations on this item (works on unversioned records too) - $rolledBackOwner = $this->getAtVersion($version); - if ($rolledBackOwner) { - $rolledBackOwner->rollbackRelations($version); - } - - // Unlink any objects disowned as a result of this action - // I.e. objects which aren't owned anymore by this record, but are by the old draft record - $rolledBackOwner->unlinkDisownedObjects($rolledBackOwner, Versioned::DRAFT); - $rolledBackOwner->invokeWithExtensions('onAfterRollbackRecursive', $version); - - // Get rolled back version on draft - return $this->getAtVersion(Versioned::DRAFT); - } - - /** - * Rollback draft to a given version - * - * @param int|string|null $version Version ID or Versioned::LIVE to rollback from live. - * Null to rollback current owner object. - */ - public function rollbackSingle($version) - { - // Validate $version and safely cast - if (isset($version) && !is_numeric($version) && $version !== Versioned::LIVE) { - throw new InvalidArgumentException("Invalid rollback source version $version"); - } - if (isset($version) && is_numeric($version)) { - $version = (int)$version; - } - // Copy version between stage - $owner = $this->owner; - $owner->invokeWithExtensions('onBeforeRollbackSingle', $version); - $owner->copyVersionToStage($version, Versioned::DRAFT); - $owner->invokeWithExtensions('onAfterRollbackSingle', $version); - } - - /** - * Return the latest version of the given record. - * - * @template T of DataObject - * @param class-string $class - * @param int $id - * @return T&static - */ - public static function get_latest_version($class, $id) - { - $baseClass = DataObject::getSchema()->baseDataClass($class); - $list = DataList::create($baseClass) - ->setDataQueryParam([ - "Versioned.mode" => 'latest_version_single', - "Versioned.id" => $id - ]); - return $list->first(); - } - - /** - * Returns whether the current record is the latest one. - * - * @see get_latest_version() - * @see latestPublished - * - * @return boolean - */ - public function isLatestVersion() - { - $owner = $this->owner; - if (!$owner->isInDB()) { - return false; - } - - $version = static::get_latest_version($this->owner->baseClass(), $owner->ID); - return ($version->Version == $owner->Version); - } - - /** - * Returns whether the current record's version is the current live/published version - * - * @return bool - */ - public function isLiveVersion() - { - $id = $this->owner->ID ?: $this->owner->OldID; - if (!$id || !$this->isPublished()) { - return false; - } - - $liveVersionNumber = static::get_versionnumber_by_stage($this->owner, Versioned::LIVE, $id); - return $liveVersionNumber == $this->owner->Version; - } - - /** - * Returns whether the current record's version is the current draft/modified version - * - * @return bool - */ - public function isLatestDraftVersion() - { - $id = $this->owner->ID ?: $this->owner->OldID; - if (!$id || !$this->isOnDraft()) { - return false; - } - - $draftVersionNumber = static::get_versionnumber_by_stage($this->owner, Versioned::DRAFT, $id); - return $draftVersionNumber == $this->owner->Version; - } - - /** - * Check if this record exists on live - * On objects with only 1 stage, check if the record exists on that stage. - * - * @return bool - */ - public function isPublished() - { - $id = $this->owner->ID ?: $this->owner->OldID; - if (!$id) { - return false; - } - - // Non-staged objects are considered "published" if saved - if (!$this->hasStages()) { - return $this->isOnDraft(); - } - - $liveVersion = static::get_versionnumber_by_stage($this->owner, Versioned::LIVE, $id); - $isPublished = (bool) $liveVersion; - - $this->owner->extend('updateIsPublished', $isPublished); - - return (bool) $isPublished; - } - - /** - * Check if page doesn't exist on any stage, but used to be - * - * @return bool - */ - public function isArchived() - { - $owner = $this->owner; - $id = $owner->ID ?: $owner->OldID; - $isArchived = $id && !$this->isOnDraft() && !$this->isPublished(); - - $owner->invokeWithExtensions('updateIsArchived', $isArchived); - - return (bool) $isArchived; - } - - /** - * Check if this record exists on the draft stage. - * On objects with only 1 stage, check if the record exists on that stage. - * - * @return bool - */ - public function isOnDraft() - { - $id = $this->owner->ID ?: $this->owner->OldID; - if (!$id) { - return false; - } - - $draftVersion = static::get_versionnumber_by_stage($this->owner, Versioned::DRAFT, $id); - $isOnDraft = (bool) $draftVersion; - - $this->owner->extend('updateIsOnDraft', $isOnDraft); - - return (bool) $isOnDraft; - } - - /** - * Compares current draft with live version, and returns true if no draft version of this page exists but the page - * is still published (eg, after triggering "Delete from draft site" in the CMS). - * - * @return bool - */ - public function isOnLiveOnly() - { - return $this->isPublished() && !$this->isOnDraft(); - } - - /** - * Compares current draft with live version, and returns true if no live version exists, meaning the page was never - * published. - * - * @return bool - */ - public function isOnDraftOnly() - { - return $this->isOnDraft() && !$this->isPublished(); - } - - /** - * Compares current draft with live version, and returns true if these versions differ, meaning there have been - * unpublished changes to the draft site. - * - * @return bool - */ - public function isModifiedOnDraft() - { - return $this->isOnDraft() && $this->stagesDiffer(); - } - - /** - * Return the equivalent of a DataList::create() call, querying the latest - * version of each record stored in the (class)_Versions tables. - * - * In particular, this will query deleted records as well as active ones. - * - * @template T of DataObject - * @param class-string $class - * @param string $filter - * @param string $sort - * @return DataList - */ - public static function get_including_deleted($class, $filter = "", $sort = "") - { - $list = DataList::create($class); - if (!empty($filter)) { - $list = $list->where($filter); - } - if (!empty($sort)) { - $list = $list->orderBy($sort); - } - $list = $list->setDataQueryParam("Versioned.mode", "latest_versions"); - return $list; - } - - /** - * Return the specific version of the given id. - * - * Caution: The record is retrieved as a DataObject, but saving back - * modifications via write() will create a new version, rather than - * modifying the existing one. - * - * @template T of DataObject - * @param class-string $class - * @param int $id - * @param int $version - * @return T&static - */ - public static function get_version($class, $id, $version) - { - $baseClass = DataObject::getSchema()->baseDataClass($class); - $list = DataList::create($baseClass) - ->setDataQueryParam([ - "Versioned.mode" => 'version', - "Versioned.version" => $version - ]); - - return $list->byID($id); - } - - /** - * Return a list of all versions for a given id. - * - * @template T - * @param class-string $class - * @param int $id - * - * @return DataList - */ - public static function get_all_versions($class, $id) - { - $list = DataList::create($class) - ->filter('ID', $id) - ->setDataQueryParam('Versioned.mode', 'all_versions'); - - return $list; - } - - /** - * @param array $labels - */ - protected function updateFieldLabels(&$labels) - { - $labels['Versions'] = _t(__CLASS__ . '.has_many_Versions', 'Versions', 'Past Versions of this record'); - } - - /** - * @param FieldList $fields - */ - protected function updateCMSFields(FieldList $fields) - { - // remove the version field from the CMS as this should be left - // entirely up to the extension (not the cms user). - $fields->removeByName('Version'); - } - - /** - * Ensure version ID is reset to 0 on duplicate - * - * @param DataObject $source Record this was duplicated from - * @param bool $doWrite - */ - protected function onBeforeDuplicate($source, $doWrite) - { - $this->owner->Version = 0; - } - - protected function onFlushCache() - { - Versioned::$cache_versionnumber = []; - $this->versionModifiedCache = []; - } - - /** - * Return a piece of text to keep DataObject cache keys appropriately specific. - * - * @return string - */ - protected function cacheKeyComponent() - { - return 'versionedmode-' . static::get_reading_mode(); - } - - /** - * Returns an array of possible stages. - * - * @return array - */ - public function getVersionedStages() - { - if ($this->hasStages()) { - return [static::DRAFT, static::LIVE]; - } else { - return [static::DRAFT]; - } - } - - public static function get_template_global_variables() - { - return [ - 'CurrentReadingMode' => 'get_reading_mode' - ]; - } - - /** - * Check if this object has stages - * - * @return bool True if this object is staged - */ - public function hasStages() - { - return $this->mode === static::STAGEDVERSIONED; - } - - /** - * Invoke a callback which may modify reading mode, but ensures this mode is restored - * after completion, without modifying global state. - * - * The desired reading mode should be set by the callback directly - * - * @param callable $callback - * @return mixed Result of $callback - */ - public static function withVersionedMode($callback) - { - $origReadingMode = static::get_reading_mode(); - try { - return $callback(); - } finally { - static::set_reading_mode($origReadingMode); - } - } - - /** - * Get author of this record. - * Note: Only works on records selected via Versions() - * - * @return Member|null - */ - public function Author() - { - if (!$this->owner->AuthorID) { - return null; - } - $member = DataObject::get_by_id(Member::class, $this->owner->AuthorID); - return $member; - } - /** - * Get publisher of this record. - * Note: Only works on records selected via Versions() - * - * @return Member|null - */ - public function Publisher() - { - if (!$this->owner->PublisherID) { - return null; - } - $member = DataObject::get_by_id(Member::class, $this->owner->PublisherID); - return $member; - } -} diff --git a/src/GridFieldArchiveAction.php b/src/Versioned/GridFieldArchiveAction.php similarity index 98% rename from src/GridFieldArchiveAction.php rename to src/Versioned/GridFieldArchiveAction.php index 3a120dd0..649908e1 100644 --- a/src/GridFieldArchiveAction.php +++ b/src/Versioned/GridFieldArchiveAction.php @@ -1,6 +1,6 @@ diff --git a/src/VersionedGridFieldItemRequest.php b/src/Versioned/VersionedGridFieldItemRequest.php similarity index 99% rename from src/VersionedGridFieldItemRequest.php rename to src/Versioned/VersionedGridFieldItemRequest.php index fdcefabf..2f412868 100644 --- a/src/VersionedGridFieldItemRequest.php +++ b/src/Versioned/VersionedGridFieldItemRequest.php @@ -1,6 +1,6 @@