diff --git a/admin/code/LeftAndMain.php b/admin/code/LeftAndMain.php index c85a33925..74f0e2550 100644 --- a/admin/code/LeftAndMain.php +++ b/admin/code/LeftAndMain.php @@ -521,7 +521,7 @@ class LeftAndMain extends Controller implements PermissionProvider { Config::inst()->update('SSViewer', 'theme_enabled', false); //set the reading mode for the admin to stage - Versioned::reading_stage('Stage'); + Versioned::set_stage(Versioned::DRAFT); } public function handleRequest(SS_HTTPRequest $request, DataModel $model = null) { diff --git a/control/Director.php b/control/Director.php index 6ba130b85..f6ac0dc7e 100644 --- a/control/Director.php +++ b/control/Director.php @@ -268,7 +268,7 @@ class Director implements TemplateGlobalProvider { // These are needed so that calling Director::test() does not muck with whoever is calling it. // Really, it's some inappropriate coupling and should be resolved by making less use of statics. - $oldStage = Versioned::current_stage(); + $oldStage = Versioned::get_stage(); $getVars = array(); if(!$httpMethod) $httpMethod = ($postVars || is_array($postVars)) ? "POST" : "GET"; @@ -308,7 +308,7 @@ class Director implements TemplateGlobalProvider { // These are needed so that calling Director::test() does not muck with whoever is calling it. // Really, it's some inappropriate coupling and should be resolved by making less use of statics - Versioned::reading_stage($oldStage); + Versioned::set_stage($oldStage); Injector::unnest(); // Restore old CookieJar, etc Config::unnest(); diff --git a/dev/DevelopmentAdmin.php b/dev/DevelopmentAdmin.php index d3553e824..ff60a3993 100644 --- a/dev/DevelopmentAdmin.php +++ b/dev/DevelopmentAdmin.php @@ -76,7 +76,7 @@ class DevelopmentAdmin extends Controller { // Backwards compat: Default to "draft" stage, which is important // for tasks like dev/build which call DataObject->requireDefaultRecords(), // but also for other administrative tasks which have assumptions about the default stage. - Versioned::reading_stage('Stage'); + Versioned::set_stage(Versioned::DRAFT); } public function index() { diff --git a/docs/en/02_Developer_Guides/00_Model/10_Versioning.md b/docs/en/02_Developer_Guides/00_Model/10_Versioning.md index c1255804c..121db4bda 100644 --- a/docs/en/02_Developer_Guides/00_Model/10_Versioning.md +++ b/docs/en/02_Developer_Guides/00_Model/10_Versioning.md @@ -13,14 +13,32 @@ Versioning in SilverStripe is handled through the [api:Versioned] class. As a [a be applied to any [api:DataObject] subclass. The extension class will automatically update read and write operations done via the ORM via the `augmentSQL` database hook. -Adding Versioned to your `DataObject` subclass works the same as any other extension. It accepts two or more arguments -denoting the different "stages", which map to different database tables. +Adding Versioned to your `DataObject` subclass works the same as any other extension. It has one of two behaviours, +which can be applied via the constructor argument. + +By default, adding the `Versioned extension will create a "Stage" and "Live" stage on your model, and will +also track versioned history. + + + :::php + class MyStagedModel extends DataObject { + private staic $extensions = [ + "Versioned" + ]; + } + + +Alternatively, staging can be disabled, so that only versioned changes are tracked for your model. This +can be specified by setting the constructor argument to "Versioned" + + + :::php + class VersionedModel extends DataObject { + private staic $extensions = [ + "Versioned('Versioned')" + ]; + } -**mysite/_config/app.yml** - :::yml - MyRecord: - extensions: - - Versioned("Stage","Live")
The extension is automatically applied to `SiteTree` class. For more information on extensions see @@ -34,8 +52,9 @@ of `DataObject`. Adding this extension to children of the base class will have u ## Database Structure -Depending on how many stages you configured, two or more new tables will be created for your records. In the above, this -will create a new `MyRecord_Live` table once you've rebuilt the database. +Depending on whether staging is enabled, one or more new tables will be created for your records. `_versions` +is always created to track historic versions for your model. If staging is enabled this will also create a new +`_Live` table once you've rebuilt the database.
Note that the "Stage" naming has a special meaning here, it will leave the original table name unchanged, rather than @@ -144,6 +163,9 @@ and has_one/has_many, however it relies on a pre-existing relationship to functi For instance, in order to specify this dependency, you must apply `owns` and `owned_by` config on a relationship. +When pages of type `MyPage` are published, any owned images and banners will be automatically published, +without requiring any custom code. + :::php class MyPage extends Page { diff --git a/docs/en/04_Changelogs/4.0.0.md b/docs/en/04_Changelogs/4.0.0.md index aa8bdde05..c105a5535 100644 --- a/docs/en/04_Changelogs/4.0.0.md +++ b/docs/en/04_Changelogs/4.0.0.md @@ -7,6 +7,7 @@ * Minimum PHP version raised to 5.5.0 * Deprecate `SQLQuery` in favour `SQLSelect` * `DataList::filter` by null now internally generates "IS NULL" or "IS NOT NULL" conditions appropriately on queries + * `DataObject` constructor now has an additional parameter, which must be included in subclasses. * `DataObject::database_fields` now returns all fields on that table. * `DataObject::db` now returns composite fields. * `DataObject::ClassName` field has been refactored into a `DBClassName` type field. @@ -34,6 +35,10 @@ definitions of the [behat-extension](https://github.com/silverstripe-labs/silverstripe-behat-extension) module). Run `composer require --dev 'phpunit/phpunit:~4.8'` on existing projects to pull in the new dependency. * `Object::invokeWithExtensions` now has the same method signature as `Object::extend` and behaves the same way. + * `CMSMain` model actions have been changed: + * `CMSBatchAction_Delete` and `CMSMain::delete` are no longer deprecated. + * `CMSBatchAction_DeleteFromLive` is removed. + * `CMSMain.enabled_legacy_actions` config is removed. ## New API @@ -69,6 +74,18 @@ automatically check `can*()` permissions, and must be done by usercode before invocation. * GridField edit form now has improved support for versioned DataObjects, with basic publishing actions available when editing records. + * `Versioned` API has some breaking changes: + * Versioned constructor now only allows a single string to declare whether staging is enabled or not. The + number of names of stages are no longer able to be specified. See below for upgrading notes for models + with custom stages. + * `reading_stage` is now `set_stage` + * `current_stage` is now `get_stage` + * `getVersionedStages` is gone. + * `get_live_stage` is removed. Use the `Versioned::LIVE` constant instead. + * `getDefaultStage` is removed. Use the `Versioned::DRAFT` constant instead. + * `$versionableExtensions` is now `private static` instead of `protected static` + * `hasStages` is addded to check if an object has a given stage. + * `stageTable` is added to get the table for a given class and stage. ### Front-end build tooling for CMS interface @@ -647,8 +664,42 @@ has been removed from core. You can configure the built-in `charmap` plugin inst For more information on available options and plugins please refer to the [tinymce documentation](https://www.tinymce.com/docs/configure/) +### Upgrading DataObjects with the `Versioned` extension + +In most cases, versioned models with the default versioning parameters will not need to be changed. However, +there are now additional restrictions on the use of custom stage names. + +Rather than declaring the list of stages a model has, the constructor for `Versioned` will take a single mode +parameter, which declares whether or not the model is versioned and has a draft and live stage, or alternatively +if it only has versioning without staging. + +For instance: + + + :::php + /** + * This model has staging and versioning. Stages will be "Stage" and "Live" + */ + class MyStagedModel extends DataObject { + private staic $extensions = array( + "Versioned('StagedVersioned')" + ); + } + + /** + * This model has versioning only, and will not has a draft or live stage, nor be affected by the current stage. + */ + class MyVersionedModel extends DataObject { + private static $extensions = array( + "Versioned('Versioned')" + ); + } + + ### Implementation of ownership API In order to support the recursive publishing of dataobjects, a new API has been developed to allow developers to declare dependencies between objects. See the [versioned documentation](/developer_guides/model/versioning) for more information. + +By default all versioned dataobjects will automatically publish objects that they own. diff --git a/filesystem/AssetControlExtension.php b/filesystem/AssetControlExtension.php index de771d2fc..b987a921d 100644 --- a/filesystem/AssetControlExtension.php +++ b/filesystem/AssetControlExtension.php @@ -99,10 +99,10 @@ class AssetControlExtension extends \DataExtension protected function getRecordState($record) { if($this->isVersioned()) { // Check stage this record belongs to - $stage = $record->getSourceQueryParam('Versioned.stage') ?: Versioned::current_stage(); + $stage = $record->getSourceQueryParam('Versioned.stage') ?: Versioned::get_stage(); // Non-live stages are automatically non-public - if($stage !== Versioned::get_live_stage()) { + if($stage !== Versioned::LIVE) { return AssetManipulationList::STATE_PROTECTED; } } @@ -159,7 +159,7 @@ class AssetControlExtension extends \DataExtension $stages = $this->owner->getVersionedStages(); // {@see Versioned::getVersionedStages} foreach ($stages as $stage) { // Skip current stage; These should be handled explicitly - if($stage === Versioned::current_stage()) { + if($stage === Versioned::get_stage()) { continue; } diff --git a/filesystem/File.php b/filesystem/File.php index f60fb86d4..858d0a148 100644 --- a/filesystem/File.php +++ b/filesystem/File.php @@ -551,7 +551,7 @@ class File extends DataObject implements ShortcodeHandler, AssetContainer { * This method will update the File {@see DBFile} field value on success, so it must be called * before writing to the database * - * @param bool True if changed + * @return bool True if changed */ public function updateFilesystem() { if(!$this->config()->update_filesystem) { @@ -563,24 +563,22 @@ class File extends DataObject implements ShortcodeHandler, AssetContainer { return false; } + // Avoid moving files on live; Rely on this being done on stage prior to publish. + if(Versioned::get_stage() !== Versioned::DRAFT) { + return false; + } + // Check path updated record will point to // If no changes necessary, skip $pathBefore = $this->File->getFilename(); - $pathAfter = $this->getFilename(); + $pathAfter = $this->generateFilename(); if($pathAfter === $pathBefore) { return false; } // Copy record to new location via stream $stream = $this->File->getStream(); - $result = $this->File->setFromStream($stream, $pathAfter); - - // If the backend chose a new name, update the local record - if($result['Filename'] !== $pathAfter) { - // Correct saved folder to selected filename - $pathAfter = $result['Filename']; - $this->setFilename($pathAfter); - } + $this->File->setFromStream($stream, $pathAfter); return true; } @@ -588,8 +586,10 @@ class File extends DataObject implements ShortcodeHandler, AssetContainer { * Collate selected descendants of this page. * $condition will be evaluated on each descendant, and if it is succeeds, that item will be added * to the $collator array. - * @param condition The PHP condition to be evaluated. The page will be called $item - * @param collator An array, passed by reference, to collect all of the matching descendants. + * + * @param string $condition The PHP condition to be evaluated. The page will be called $item + * @param array $collator An array, passed by reference, to collect all of the matching descendants. + * @return true|null */ public function collateDescendants($condition, &$collator) { if($children = $this->Children()) { @@ -610,7 +610,8 @@ class File extends DataObject implements ShortcodeHandler, AssetContainer { * * Does not change the filesystem itself, please use {@link write()} for this. * - * @param String $name + * @param string $name + * @return $this */ public function setName($name) { $oldName = $this->Name; @@ -655,7 +656,7 @@ class File extends DataObject implements ShortcodeHandler, AssetContainer { $this->Title = str_replace(array('-','_'),' ', preg_replace('/\.[^.]+$/', '', $name)); } - return $name; + return $this; } /** @@ -704,13 +705,18 @@ class File extends DataObject implements ShortcodeHandler, AssetContainer { return Director::absoluteBaseURL()."admin/assets/removefile/".$this->ID; } - public function getFilename() { + /** + * Get expected value of Filename tuple value. Will be used to trigger + * a file move on draft stage. + * + * @return string + */ + public function generateFilename() { // Check if this file is nested within a folder $parent = $this->Parent(); if($parent && $parent->exists()) { return $this->join_paths($parent->getFilename(), $this->Name); } - return $this->Name; } @@ -1027,6 +1033,10 @@ class File extends DataObject implements ShortcodeHandler, AssetContainer { return false; } + public function getFilename() { + return $this->File->Filename; + } + public function getHash() { return $this->File->Hash; } diff --git a/filesystem/FileMigrationHelper.php b/filesystem/FileMigrationHelper.php index fff99f85a..45f4d7471 100644 --- a/filesystem/FileMigrationHelper.php +++ b/filesystem/FileMigrationHelper.php @@ -34,8 +34,8 @@ class FileMigrationHelper extends Object { // Loop over all files $count = 0; - $originalState = \Versioned::get_reading_mode(); - \Versioned::reading_stage('Stage'); + $originalState = Versioned::get_reading_mode(); + Versioned::set_stage(Versioned::DRAFT); $filenameMap = $this->getFilenameArray(); foreach($this->getFileQuery() as $file) { // Get the name of the file to import @@ -45,7 +45,7 @@ class FileMigrationHelper extends Object { $count++; } } - \Versioned::set_reading_mode($originalState); + Versioned::set_reading_mode($originalState); return $count; } @@ -54,7 +54,7 @@ class FileMigrationHelper extends Object { * * @param string $base Absolute base path (parent of assets folder) * @param File $file - * @param type $legacyFilename + * @param string $legacyFilename * @return bool True if this file is imported successfully */ protected function migrateFile($base, File $file, $legacyFilename) { @@ -64,8 +64,9 @@ class FileMigrationHelper extends Object { return false; } + // Copy local file into this filesystem - $filename = $file->getFilename(); + $filename = $file->generateFilename(); $result = $file->setFromLocalFile( $path, $filename, null, null, array('conflict' => AssetStore::CONFLICT_OVERWRITE) @@ -73,7 +74,7 @@ class FileMigrationHelper extends Object { // Move file if the APL changes filename value if($result['Filename'] !== $filename) { - $this->setFilename($result['Filename']); + $file->setFilename($result['Filename']); } // Save and publish diff --git a/filesystem/Folder.php b/filesystem/Folder.php index 4914c17b7..5a8b7c7a0 100644 --- a/filesystem/Folder.php +++ b/filesystem/Folder.php @@ -233,7 +233,7 @@ class Folder extends File { } public function getFilename() { - return parent::getFilename() . '/'; + return parent::generateFilename() . '/'; } /** @@ -258,11 +258,24 @@ class Folder extends File { public function onAfterWrite() { parent::onAfterWrite(); - // Ensure that children loading $this->Parent() load the refreshed record - $this->flushCache(); + // No publishing UX for folders, so just cascade changes live + if(Versioned::get_stage() === Versioned::DRAFT) { + $this->publish(Versioned::DRAFT, Versioned::LIVE); + } + + // Update draft version of all child records $this->updateChildFilesystem(); } + public function onAfterDelete() { + parent::onAfterDelete(); + + // Cascade deletions to live + if(Versioned::get_stage() === Versioned::DRAFT) { + $this->deleteFromStage(Versioned::LIVE); + } + } + public function updateFilesystem() { // No filesystem changes to update } @@ -278,8 +291,14 @@ class Folder extends File { * Update filesystem of all children */ public function updateChildFilesystem() { + // Don't synchronise on live (rely on publishing instead) + if(Versioned::get_stage() === Versioned::LIVE) { + return; + } + + $this->flushCache(); // Writing this record should trigger a write (and potential updateFilesystem) on each child - foreach($this->AllChildren() as $child) { + foreach ($this->AllChildren() as $child) { $child->write(); } } diff --git a/model/DataList.php b/model/DataList.php index db7652234..7097eb15e 100644 --- a/model/DataList.php +++ b/model/DataList.php @@ -748,10 +748,8 @@ class DataList extends ViewableData implements SS_List, SS_Filterable, SS_Sortab if(class_exists($row['RecordClassName'])) { $class = $row['RecordClassName']; } - $item = Injector::inst()->create($class, $row, false, $this->model); - //set query params on the DataObject to tell the lazy loading mechanism the context the object creation context - $item->setSourceQueryParams($this->getQueryParams()); + $item = Injector::inst()->create($class, $row, false, $this->model, $this->getQueryParams()); return $item; } diff --git a/model/DataObject.php b/model/DataObject.php index 88cf67574..f9dfe1004 100644 --- a/model/DataObject.php +++ b/model/DataObject.php @@ -398,10 +398,15 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * field values. Normally this contructor is only used by the internal systems that get objects from the database. * @param boolean $isSingleton This this to true if this is a singleton() object, a stub for calling methods. * Singletons don't have their defaults set. + * @param DataModel $model + * @param array $queryParams List of DataQuery params necessary to lazy load, or load related objects. */ - public function __construct($record = null, $isSingleton = false, $model = null) { + public function __construct($record = null, $isSingleton = false, $model = null, $queryParams = array()) { parent::__construct(); + // Set query params on the DataObject to tell the lazy loading mechanism the context the object creation context + $this->setSourceQueryParams($queryParams); + // Set the fields data. if(!$record) { $record = array( @@ -2313,8 +2318,8 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity /** * Loads all the stub fields that an initial lazy load didn't load fully. * - * @param tableClass Base table to load the values from. Others are joined as required. - * Not specifying a tableClass will load all lazy fields from all tables. + * @param string $tableClass Base table to load the values from. Others are joined as required. + * Not specifying a tableClass will load all lazy fields from all tables. */ protected function loadLazyFields($tableClass = null) { if (!$tableClass) { @@ -2334,17 +2339,22 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity // Reset query parameter context to that of this DataObject if($params = $this->getSourceQueryParams()) { - foreach($params as $key => $value) $dataQuery->setQueryParam($key, $value); + foreach($params as $key => $value) { + $dataQuery->setQueryParam($key, $value); + } } // TableField sets the record ID to "new" on new row data, so don't try doing anything in that case - if(!is_numeric($this->record['ID'])) return false; + if(!is_numeric($this->record['ID'])) { + return; + } // Limit query to the current record, unless it has the Versioned extension, // in which case it requires special handling through augmentLoadLazyFields() - if(!$this->hasExtension('Versioned')) { - $dataQuery->where("\"$tableClass\".\"ID\" = {$this->record['ID']}")->limit(1); - } + $baseTable = ClassInfo::baseDataClass($this); + $dataQuery->where([ + "\"{$baseTable}\".\"ID\"" => $this->record['ID'] + ])->limit(1); $columns = array(); diff --git a/model/Image.php b/model/Image.php index 6a24bf30d..ab151df84 100644 --- a/model/Image.php +++ b/model/Image.php @@ -7,8 +7,8 @@ * @subpackage filesystem */ class Image extends File { - public function __construct($record = null, $isSingleton = false, $model = null) { - parent::__construct($record, $isSingleton, $model); + public function __construct($record = null, $isSingleton = false, $model = null, $queryParams = array()) { + parent::__construct($record, $isSingleton, $model, $queryParams); $this->File->setAllowedCategories('image/supported'); } diff --git a/model/RelationList.php b/model/RelationList.php index f4d182c4f..fdb6eebb3 100644 --- a/model/RelationList.php +++ b/model/RelationList.php @@ -22,7 +22,7 @@ abstract class RelationList extends DataList implements Relation { // Remove `Foreign.` query parameters for created objects, // as this would interfere with relations on those objects. foreach(array_keys($params) as $key) { - if(stripos($key, 'Foreign.') !== 0) { + if(stripos($key, 'Foreign.') === 0) { unset($params[$key]); } } diff --git a/model/fieldtypes/Datetime.php b/model/fieldtypes/Datetime.php index e4b7a4be5..f2219890e 100644 --- a/model/fieldtypes/Datetime.php +++ b/model/fieldtypes/Datetime.php @@ -177,6 +177,7 @@ class SS_Datetime extends Date implements TemplateGlobalProvider { * Caution: This sets a fixed date that doesn't increment with time. * * @param SS_Datetime|string $datetime Either in object format, or as a SS_Datetime compatible string. + * @throws Exception */ public static function set_mock_now($datetime) { if($datetime instanceof SS_Datetime) { diff --git a/model/queries/SQLSelect.php b/model/queries/SQLSelect.php index ee388d4f4..48f3b8a8c 100644 --- a/model/queries/SQLSelect.php +++ b/model/queries/SQLSelect.php @@ -61,7 +61,7 @@ class SQLSelect extends SQLConditionalExpression { /** * Construct a new SQLSelect. * - * @param array $select An array of SELECT fields. + * @param array|string $select An array of SELECT fields. * @param array|string $from An array of FROM clauses. The first one should be just the table name. * Each should be ANSI quoted. * @param array $where An array of WHERE clauses. diff --git a/model/versioning/Versioned.php b/model/versioning/Versioned.php index 003686910..9e65eb407 100644 --- a/model/versioning/Versioned.php +++ b/model/versioning/Versioned.php @@ -14,29 +14,42 @@ * @subpackage model */ class Versioned extends DataExtension implements TemplateGlobalProvider { - /** - * An array of possible stages. - * @var array - */ - protected $stages; /** - * The 'default' stage. + * 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 $defaultStage; - - /** - * The 'live' stage. - * @var string - */ - protected $liveStage; + 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 version that a DataObject should be when it is 'migrating', * that is, when it is in the process of moving from one stage to another. @@ -53,6 +66,8 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { protected static $cache_versionnumber; /** + * Current reading mode + * * @var string */ protected static $reading_mode = null; @@ -92,6 +107,7 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { * Used to enable or disable the prepopulation of the version number cache. * Defaults to true. * + * @config * @var boolean */ private static $prepopulate_versionnumber_cache = true; @@ -99,6 +115,7 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { /** * Keep track of the archive tables that have been created. * + * @config * @var array */ private static $archive_tables = array(); @@ -149,9 +166,10 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { * Make sure your extension has a static $enabled-property that determines if it is * processed by Versioned. * + * @config * @var array */ - protected static $versionableExtensions = array('Translatable' => 'lang'); + private static $versionableExtensions = array('Translatable' => 'lang'); /** * Permissions necessary to view records outside of the live stage (e.g. archive / draft stage). @@ -194,29 +212,9 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { */ public static function reset() { self::$reading_mode = ''; - Session::clear('readingMode'); } - /** - * Construct a new Versioned object. - * - * @var array $stages The different stages the versioned object can be. - * The first stage is considered the 'default' stage, the last stage is - * considered the 'live' stage. - */ - public function __construct($stages = array('Stage','Live')) { - parent::__construct(); - - if(!is_array($stages)) { - $stages = func_get_args(); - } - - $this->stages = $stages; - $this->defaultStage = reset($stages); - $this->liveStage = array_pop($stages); - } - /** * Amend freshly created DataQuery objects with versioned-specific * information. @@ -230,24 +228,119 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { if($parts[0] == 'Archive') { $dataQuery->setQueryParam('Versioned.mode', 'archive'); $dataQuery->setQueryParam('Versioned.date', $parts[1]); - } else if($parts[0] == 'Stage' && in_array($parts[1], $this->stages)) { + } else if($parts[0] == 'Stage' && $this->hasStages()) { $dataQuery->setQueryParam('Versioned.mode', 'stage'); $dataQuery->setQueryParam('Versioned.stage', $parts[1]); } } + /** + * Construct a new Versioned object. + * + * @var string $mode One of "StagedVersioned" or "Versioned". + */ + public function __construct($mode = self::STAGEDVERSIONED) { + parent::__construct(); + + // Handle deprecated behaviour + if($mode === 'Stage' && func_num_args() === 1) { + Deprecation::notice("5.0", "Versioned now takes a mode as a single parameter"); + $mode = static::VERSIONED; + } elseif(is_array($mode) || func_num_args() > 1) { + Deprecation::notice("5.0", "Versioned now takes a mode as a single parameter"); + $mode = func_num_args() > 1 || count($mode) > 1 + ? static::STAGEDVERSIONED + : static::VERSIONED; + } + + if(!in_array($mode, array(static::STAGEDVERSIONED, static::VERSIONED))) { + throw new InvalidArgumentException("Invalid mode: {$mode}"); + } + + $this->mode = $mode; + } + + /** + * Cache of version to modified dates for this objects + * + * @var array + */ + protected $versionModifiedCache = array(); + + /** + * Get modified date for the given version + * + * @param int $version + * @return string + */ + protected function getLastEditedForVersion($version) { + // Cache key + $baseTable = ClassInfo::baseDataClass($this->owner); + $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"', $table) + ->addWhere([ + "{$table}.\"RecordID\"" => $id, + "{$table}.\"Version\"" => $version + ]); + $date = $query->execute()->value(); + if($date) { + $this->versionModifiedCache[$key] = $date; + } + return $date; + } + public function updateInheritableQueryParams(&$params) { - // Versioned.mode === all_versions doesn't inherit very well, so default to stage - if(isset($params['Versioned.mode']) && $params['Versioned.mode'] === 'all_versions') { - $params['Versioned.mode'] = 'stage'; - $params['Versioned.stage'] = $this->defaultStage; + // Skip if versioned isn't set + if(!isset($params['Versioned.mode'])) { + return; + } + + // Adjust query based on original selection criterea + $owner = $this->owner; + 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']; + $date = $this->getLastEditedForVersion($version); + + // Filter related objects at the same date as this version + unset($params['Versioned.version']); + if($date) { + $params['Versioned.mode'] = 'archive'; + $params['Versioned.date'] = $date; + } 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 $dataQuery * @throws InvalidArgumentException @@ -259,62 +352,32 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { $baseTable = ClassInfo::baseDataClass($dataQuery->dataClass()); - switch($dataQuery->getQueryParam('Versioned.mode')) { - // Reading a specific data from the archive - case 'archive': - $date = $dataQuery->getQueryParam('Versioned.date'); + $versionedMode = $dataQuery->getQueryParam('Versioned.mode'); + switch($versionedMode) { + // Reading a specific stage (Stage or Live) + case 'stage': + // Check if we need to rewrite this table + $stage = $dataQuery->getQueryParam('Versioned.stage'); + if(!$this->hasStages() || $stage === static::DRAFT) { + break; + } + // Rewrite all tables to select from the live version foreach($query->getFrom() as $table => $dummy) { if(!$this->isTableVersioned($table)) { continue; } - - $query->renameTable($table, $table . '_versions'); - $query->replaceText("\"{$table}_versions\".\"ID\"", "\"{$table}_versions\".\"RecordID\""); - $query->replaceText("`{$table}_versions`.`ID`", "`{$table}_versions`.`RecordID`"); - - // Add all _versions columns - foreach(Config::inst()->get('Versioned', 'db_for_versions_table') as $name => $type) { - $query->selectField(sprintf('"%s_versions"."%s"', $baseTable, $name), $name); - } - $query->selectField(sprintf('"%s_versions"."%s"', $baseTable, 'RecordID'), "ID"); - - if($table != $baseTable) { - $query->addWhere("\"{$table}_versions\".\"Version\" = \"{$baseTable}_versions\".\"Version\""); - } - } - // Link to the version archived on that date - $query->addWhere(array( - "\"{$baseTable}_versions\".\"Version\" IN - (SELECT LatestVersion FROM - (SELECT - \"{$baseTable}_versions\".\"RecordID\", - MAX(\"{$baseTable}_versions\".\"Version\") AS LatestVersion - FROM \"{$baseTable}_versions\" - WHERE \"{$baseTable}_versions\".\"LastEdited\" <= ? - GROUP BY \"{$baseTable}_versions\".\"RecordID\" - ) AS \"{$baseTable}_versions_latest\" - WHERE \"{$baseTable}_versions_latest\".\"RecordID\" = \"{$baseTable}_versions\".\"RecordID\" - )" => $date - )); - break; - - // Reading a specific stage (Stage or Live) - case 'stage': - $stage = $dataQuery->getQueryParam('Versioned.stage'); - if($stage && ($stage != $this->defaultStage)) { - foreach($query->getFrom() as $table => $dummy) { - if(!$this->isTableVersioned($table)) { - continue; - } - $query->renameTable($table, $table . '_' . $stage); - } + $stageTable = $this->stageTable($table, $stage); + $query->renameTable($table, $stageTable); } break; // Reading a specific stage, but only return items that aren't in any other stage case 'stage_unique': - $stage = $dataQuery->getQueryParam('Versioned.stage'); + if(!$this->hasStages()) { + break; + } + $stage = $dataQuery->getQueryParam('Versioned.stage'); // Recurse to do the default stage behavior (must be first, we rely on stage renaming happening before // below) $dataQuery->setQueryParam('Versioned.mode', 'stage'); @@ -323,11 +386,13 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { // Now exclude any ID from any other stage. Note that we double rename to avoid the regular stage rename // renaming all subquery references to be Versioned.stage - foreach($this->stages as $excluding) { - if ($excluding == $stage) continue; + foreach([static::DRAFT, static::LIVE] as $excluding) { + if ($excluding == $stage) { + continue; + } $tempName = 'ExclusionarySource_'.$excluding; - $excludingTable = $baseTable . ($excluding && $excluding != $this->defaultStage ? "_$excluding" : ''); + $excludingTable = $baseTable . ($excluding && $excluding != static::DRAFT ? "_$excluding" : ''); $query->addWhere('"'.$baseTable.'"."ID" NOT IN (SELECT "ID" FROM "'.$tempName.'")'); $query->renameTable($tempName, $excludingTable); @@ -335,8 +400,10 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { break; // Return all version instances + case 'archive': case 'all_versions': case 'latest_versions': + case 'version': foreach($query->getFrom() as $alias => $join) { if(!$this->isTableVersioned($alias)) { continue; @@ -368,24 +435,62 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { "count(DISTINCT \"{$baseTable}_versions\".\"ID\")" ); - // latest_version has one more step - // Return latest version instances, regardless of whether they are on a particular stage - // This provides "show all, including deleted" functonality - if($dataQuery->getQueryParam('Versioned.mode') == 'latest_versions') { - $query->addWhere( - "\"{$baseTable}_versions\".\"Version\" IN - (SELECT LatestVersion FROM - (SELECT - \"{$baseTable}_versions\".\"RecordID\", - MAX(\"{$baseTable}_versions\".\"Version\") AS LatestVersion - FROM \"{$baseTable}_versions\" - GROUP BY \"{$baseTable}_versions\".\"RecordID\" - ) AS \"{$baseTable}_versions_latest\" - WHERE \"{$baseTable}_versions_latest\".\"RecordID\" = \"{$baseTable}_versions\".\"RecordID\" - )"); - } else { - // If all versions are requested, ensure that records are sorted by this field - $query->addOrderBy(sprintf('"%s_versions"."%s"', $baseTable, 'Version')); + // Add additional versioning filters + switch($versionedMode) { + case 'archive': { + $date = $dataQuery->getQueryParam('Versioned.date'); + if(!$date) { + throw new InvalidArgumentException("Invalid archive date"); + } + // Link to the version archived on that date + $query->addWhere([ + "\"{$baseTable}_versions\".\"Version\" IN + (SELECT LatestVersion FROM + (SELECT + \"{$baseTable}_versions\".\"RecordID\", + MAX(\"{$baseTable}_versions\".\"Version\") AS LatestVersion + FROM \"{$baseTable}_versions\" + WHERE \"{$baseTable}_versions\".\"LastEdited\" <= ? + GROUP BY \"{$baseTable}_versions\".\"RecordID\" + ) AS \"{$baseTable}_versions_latest\" + WHERE \"{$baseTable}_versions_latest\".\"RecordID\" = \"{$baseTable}_versions\".\"RecordID\" + )" => $date + ]); + break; + } + case 'latest_versions': { + // Return latest version instances, regardless of whether they are on a particular stage + // This provides "show all, including deleted" functonality + $query->addWhere( + "\"{$baseTable}_versions\".\"Version\" IN + (SELECT LatestVersion FROM + (SELECT + \"{$baseTable}_versions\".\"RecordID\", + MAX(\"{$baseTable}_versions\".\"Version\") AS LatestVersion + FROM \"{$baseTable}_versions\" + GROUP BY \"{$baseTable}_versions\".\"RecordID\" + ) AS \"{$baseTable}_versions_latest\" + WHERE \"{$baseTable}_versions_latest\".\"RecordID\" = \"{$baseTable}_versions\".\"RecordID\" + )" + ); + break; + } + case 'version': { + // If selecting a specific version, filter it here + $version = $dataQuery->getQueryParam('Versioned.version'); + if(!$version) { + throw new InvalidArgumentException("Invalid version"); + } + $query->addWhere([ + "\"{$baseTable}_versions\".\"Version\"" => $version + ]); + break; + } + default: { + // If all versions are requested, ensure that records are sorted by this field + $query->addOrderBy(sprintf('"%s_versions"."%s"', $baseTable, 'Version')); + break; + } } break; default: @@ -422,18 +527,18 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { // metadata set on the query object. This prevents regular queries from // accidentally querying the *_versions tables. $versionedMode = $dataObject->getSourceQueryParam('Versioned.mode'); - $dataClass = $dataQuery->dataClass(); - $modesToAllowVersioning = array('all_versions', 'latest_versions', 'archive'); + $dataClass = ClassInfo::baseDataClass($dataQuery->dataClass()); + $modesToAllowVersioning = array('all_versions', 'latest_versions', 'archive', 'version'); if( !empty($dataObject->Version) && (!empty($versionedMode) && in_array($versionedMode,$modesToAllowVersioning)) ) { - $dataQuery->where("\"$dataClass\".\"RecordID\" = " . $dataObject->ID); - $dataQuery->where("\"$dataClass\".\"Version\" = " . $dataObject->Version); + // This will ensure that augmentSQL will select only the same version as the owner, + // regardless of how this object was initially selected + $dataQuery->where([ + "\"$dataClass\".\"Version\"" => $dataObject->Version + ]); $dataQuery->setQueryParam('Versioned.mode', 'all_versions'); - } else { - // Same behaviour as in DataObject->loadLazyFields - $dataQuery->where("\"$dataClass\".\"ID\" = {$dataObject->ID}")->limit(1); } } @@ -446,33 +551,34 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { public static function on_db_reset() { // Drop all temporary tables $db = DB::get_conn(); - foreach(self::$archive_tables as $tableName) { + foreach(static::$archive_tables as $tableName) { if(method_exists($db, 'dropTable')) $db->dropTable($tableName); else $db->query("DROP TABLE \"$tableName\""); } // Remove references to them - self::$archive_tables = array(); + static::$archive_tables = array(); } public function augmentDatabase() { - $classTable = $this->owner->class; + $owner = $this->owner; + $classTable = $owner->class; - $isRootClass = ($this->owner->class == ClassInfo::baseDataClass($this->owner->class)); + $isRootClass = ($owner->class == ClassInfo::baseDataClass($owner->class)); // Build a list of suffixes whose tables need versioning $allSuffixes = array(); - $versionableExtensions = $this->owner->config()->versionableExtensions; + $versionableExtensions = $owner->config()->versionableExtensions; if(count($versionableExtensions)){ foreach ($versionableExtensions as $versionableExtension => $suffixes) { - if ($this->owner->hasExtension($versionableExtension)) { - $allSuffixes = array_merge($allSuffixes, (array)$suffixes); - foreach ((array)$suffixes as $suffix) { - $allSuffixes[$suffix] = $versionableExtension; + if ($owner->hasExtension($versionableExtension)) { + $allSuffixes = array_merge($allSuffixes, (array)$suffixes); + foreach ((array)$suffixes as $suffix) { + $allSuffixes[$suffix] = $versionableExtension; + } } } } - } // Add the default table with an empty suffix to the list (table name = class name) array_push($allSuffixes,''); @@ -484,14 +590,14 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { if ($suffix) $table = "{$classTable}_$suffix"; else $table = $classTable; - $fields = DataObject::database_fields($this->owner->class); + $fields = DataObject::database_fields($owner->class); unset($fields['ID']); if($fields) { - $options = Config::inst()->get($this->owner->class, 'create_table_options', Config::FIRST_SET); - $indexes = $this->owner->databaseIndexes(); - if ($suffix && ($ext = $this->owner->getExtensionInstance($allSuffixes[$suffix]))) { + $options = Config::inst()->get($owner->class, 'create_table_options', Config::FIRST_SET); + $indexes = $owner->databaseIndexes(); + if ($suffix && ($ext = $owner->getExtensionInstance($allSuffixes[$suffix]))) { if (!$ext->isVersionedTable($table)) continue; - $ext->setOwner($this->owner); + $ext->setOwner($owner); $fields = $ext->fieldsInExtraTables($suffix); $ext->clearOwner(); $indexes = $fields['indexes']; @@ -499,24 +605,13 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { } // Create tables for other stages - foreach($this->stages as $stage) { + if($this->hasStages()) { // Extra tables for _Live, etc. // Change unique indexes to 'index'. Versioned tables may run into unique indexing difficulties // otherwise. + $liveTable = $this->stageTable($table, static::LIVE); $indexes = $this->uniqueToIndex($indexes); - if($stage != $this->defaultStage) { - DB::require_table("{$table}_$stage", $fields, $indexes, false, $options); - } - - // Version fields on each root table (including Stage) - /* - if($isRootClass) { - $stageTable = ($stage == $this->defaultStage) ? $table : "{$table}_$stage"; - $parts=Array('datatype'=>'int', 'precision'=>11, 'null'=>'not null', 'default'=>(int)0); - $values=Array('type'=>'int', 'parts'=>$parts); - DB::requireField($stageTable, 'Version', $values); - } - */ + DB::require_table($liveTable, $fields, $indexes, false, $options); } if($isRootClass) { @@ -611,8 +706,9 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { DB::require_table("{$table}_versions", $versionFields, $versionIndexes, true, $options); } else { DB::dont_require_table("{$table}_versions"); - foreach($this->stages as $stage) { - if($stage != $this->defaultStage) DB::dont_require_table("{$table}_$stage"); + if($this->hasStages()) { + $liveTable = $this->stageTable($table, static::LIVE); + DB::dont_require_table($liveTable); } } } @@ -695,7 +791,7 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { $nextVersion = $nextVersion ?: 1; if($table === $baseDataClass) { - // Write AuthorID for baseclass + // Write AuthorID for baseclass $userID = (Member::currentUser()) ? Member::currentUser()->ID : 0; $newManipulation['fields']['AuthorID'] = $userID; @@ -724,7 +820,7 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { ); } - $newTable = $table . '_' . Versioned::current_stage(); + $newTable = $this->stageTable($table, Versioned::get_stage()); $manipulation[$newTable] = $manipulation[$table]; unset($manipulation[$table]); } @@ -733,7 +829,8 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { public function augmentWrite(&$manipulation) { // get Version number from base data table on write $version = null; - $baseDataClass = ClassInfo::baseDataClass($this->owner->class); + $owner = $this->owner; + $baseDataClass = ClassInfo::baseDataClass($owner->class); if(isset($manipulation[$baseDataClass]['fields'])) { if ($this->migratingVersion) { $manipulation[$baseDataClass]['fields']['Version'] = $this->migratingVersion; @@ -781,11 +878,7 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { } // If we're editing Live, then use (table)_Live instead of (table) - if( - Versioned::current_stage() - && Versioned::current_stage() != $this->defaultStage - && in_array(Versioned::current_stage(), $this->stages) - ) { + if($this->hasStages() && static::get_stage() === static::LIVE) { $this->augmentWriteStaged($manipulation, $table, $id); } } @@ -798,7 +891,7 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { // Add the new version # back into the data object, for accessing // after this write if(isset($thisVersion)) { - $this->owner->Version = str_replace("'","", $thisVersion); + $owner->Version = str_replace("'","", $thisVersion); } } @@ -872,25 +965,26 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { } // Skip search for unsaved records - if(!$this->owner->isInDB()) { + $owner = $this->owner; + if(!$owner->isInDB()) { return $list; } - $relationships = $this->owner->config()->{$source}; + $relationships = $owner->config()->{$source}; foreach($relationships as $relationship) { // Warn if invalid config - if(!$this->owner->hasMethod($relationship)) { + if(!$owner->hasMethod($relationship)) { trigger_error(sprintf( "Invalid %s config value \"%s\" on object on class \"%s\"", $source, $relationship, - $this->owner->class + $owner->class ), E_USER_WARNING); continue; } // Inspect value of this relationship - $items = $this->owner->{$relationship}(); + $items = $owner->{$relationship}(); if(!$items) { continue; } @@ -944,13 +1038,14 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { } // Standard mechanism for accepting permission changes from extensions - $extended = $this->owner->extendedCan('canPublish', $member); + $owner = $this->owner; + $extended = $owner->extendedCan('canPublish', $member); if($extended !== null) { return $extended; } // Default to relying on edit permission - return $this->owner->canEdit($member); + return $owner->canEdit($member); } /** @@ -974,13 +1069,14 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { } // Standard mechanism for accepting permission changes from extensions - $extended = $this->owner->extendedCan('canUnpublish', $member); + $owner = $this->owner; + $extended = $owner->extendedCan('canUnpublish', $member); if($extended !== null) { return $extended; } // Default to relying on canPublish - return $this->owner->canPublish($member); + return $owner->canPublish($member); } /** @@ -1005,24 +1101,62 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { } // Standard mechanism for accepting permission changes from extensions - $extended = $this->owner->extendedCan('canArchive', $member); + $owner = $this->owner; + $extended = $owner->extendedCan('canArchive', $member); if($extended !== null) { return $extended; } // Check if this record can be deleted from stage - if(!$this->owner->canDelete($member)) { + if(!$owner->canDelete($member)) { return false; } // Check if we can delete from live - if(!$this->owner->canUnpublish($member)) { + if(!$owner->canUnpublish($member)) { return false; } return true; } + /** + * Check if the user can revert this record to live + * + * @param Member $member + * @return bool + */ + public function canRevertToLive($member = null) { + $owner = $this->owner; + + // Skip if invoked by extendedCan() + if(func_num_args() > 4) { + return null; + } + + // Can't revert if not on live + if(!$owner->isPublished()) { + return false; + } + + if(!$member) { + $member = Member::currentUser(); + } + + 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); + } + /** * Extend permissions to include additional security for objects that are not published to live. * @@ -1054,9 +1188,10 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { */ public function canViewVersioned($member = null) { // Bypass when live stage - $mode = $this->owner->getSourceQueryParam("Versioned.mode"); - $stage = $this->owner->getSourceQueryParam("Versioned.stage"); - if ($mode === 'stage' && $stage === static::get_live_stage()) { + $owner = $this->owner; + $mode = $owner->getSourceQueryParam("Versioned.mode"); + $stage = $owner->getSourceQueryParam("Versioned.stage"); + if ($mode === 'stage' && $stage === static::LIVE) { return true; } @@ -1066,26 +1201,26 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { } // Bypass if record doesn't have a live stage - if(!in_array(static::get_live_stage(), $this->getVersionedStages())) { + 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->class, 'Live', $this->owner->ID); - if ($latestVersion == $this->owner->Version) { + $latestVersion = Versioned::get_versionnumber_by_stage($owner->class, 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; } // Extend versioned behaviour - $extended = $this->owner->extendedCan('canViewNonLive', $member); + $extended = $owner->extendedCan('canViewNonLive', $member); if($extended !== null) { return (bool)$extended; } // Fall back to default permission check - $permissions = Config::inst()->get($this->owner->class, 'non_live_permissions', Config::FIRST_SET); + $permissions = Config::inst()->get($owner->class, 'non_live_permissions', Config::FIRST_SET); $check = Permission::checkMember($member, $permissions); return (bool)$check; } @@ -1105,9 +1240,10 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { */ public function canViewStage($stage = 'Live', $member = null) { $oldMode = Versioned::get_reading_mode(); - Versioned::reading_stage($stage); + Versioned::set_stage($stage); - $versionFromStage = DataObject::get($this->owner->class)->byID($this->owner->ID); + $owner = $this->owner; + $versionFromStage = DataObject::get($owner->class)->byID($owner->ID); Versioned::set_reading_mode($oldMode); return $versionFromStage ? $versionFromStage->canView($member) : false; @@ -1134,15 +1270,14 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { * @return boolean Returns false if the field isn't in the table, true otherwise */ public function hasVersionField($table) { - $rPos = strrpos($table,'_'); - - if(($rPos !== false) && in_array(substr($table,$rPos), $this->stages)) { - $tableWithoutStage = substr($table,0,$rPos); - } else { - $tableWithoutStage = $table; + // Strip "_Live" from end of table + $live = static::LIVE; + if($this->hasStages() && preg_match("/^(?.*)_{$live}$/", $table, $matches)) { + $table = $matches['table']; } - return ('DataObject' == get_parent_class($tableWithoutStage)); + // Base table has version field + return $table === ClassInfo::baseDataClass($table); } /** @@ -1175,15 +1310,14 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { */ public function latestPublished() { // Get the root data object class - this will have the version field - $table1 = $this->owner->class; - while( ($p = get_parent_class($table1)) != "DataObject") $table1 = $p; - - $table2 = $table1 . "_$this->liveStage"; + $owner = $this->owner; + $table1 = ClassInfo::baseDataClass($owner); + $table2 = $this->stageTable($table1, static::LIVE); return DB::prepared_query("SELECT \"$table1\".\"Version\" = \"$table2\".\"Version\" FROM \"$table1\" INNER JOIN \"$table2\" ON \"$table1\".\"ID\" = \"$table2\".\"ID\" WHERE \"$table1\".\"ID\" = ?", - array($this->owner->ID) + array($owner->ID) )->value(); } @@ -1194,14 +1328,102 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { */ public function doPublish() { $owner = $this->owner; + if(!$owner->canPublish()) { + return false; + } + $owner->invokeWithExtensions('onBeforePublish'); $owner->write(); - $owner->publish("Stage", "Live"); + $owner->publish(static::DRAFT, static::LIVE); $owner->invokeWithExtensions('onAfterPublish'); return true; } + /** + * Trigger publishing of owned objects + */ + public function onAfterPublish() { + $owner = $this->owner; + // Publish owned objects + foreach ($owner->findOwned(false) as $object) { + /** @var Versioned|DataObject $object */ + $object->doPublish(); + } + + // 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 live record + $owner->unlinkDisownedObjects(Versioned::DRAFT, Versioned::LIVE); + } + + /** + * Set foreign keys of has_many objects to 0 where those objects were + * disowned as a result of a partial publish / unpublish. + * I.e. this object and its owned objects were recently written to $targetStage, + * but deleted objects were not. + * + * Note that this operation does not create any new Versions + * + * @param string $sourceStage Objects in this stage will not be unlinked. + * @param string $targetStage Objects which exist in this stage but not $sourceStage + * will be unlinked. + */ + public function unlinkDisownedObjects($sourceStage, $targetStage) { + $owner = $this->owner; + + // after publishing, objects which used to be owned need to be + // dis-connected from this object (set ForeignKeyID = 0) + $owns = $owner->config()->owns; + $hasMany = $owner->config()->has_many; + if(empty($owns) || empty($hasMany)) { + return; + } + + $ownedHasMany = array_intersect($owns, array_keys($hasMany)); + foreach($ownedHasMany as $relationship) { + // Find metadata on relationship + $joinClass = $owner->hasManyComponent($relationship); + $joinField = $owner->getRemoteJoinField($relationship, 'has_many', $polymorphic); + $idField = $polymorphic ? "{$joinField}ID" : $joinField; + $joinTable = ClassInfo::table_for_object_field($joinClass, $idField); + + // Generate update query which will unlink disowned objects + $targetTable = $this->stageTable($joinTable, $targetStage); + $disowned = new SQLUpdate("\"{$targetTable}\""); + $disowned->assign("\"{$idField}\"", 0); + $disowned->addWhere(array( + "\"{$targetTable}\".\"{$idField}\"" => $owner->ID + )); + + // Build exclusion list (items to owned objects we need to keep) + $sourceTable = $this->stageTable($joinTable, $sourceStage); + $owned = new SQLSelect("\"{$sourceTable}\".\"ID\"", "\"{$sourceTable}\""); + $owned->addWhere(array( + "\"{$sourceTable}\".\"{$idField}\"" => $owner->ID + )); + + // Apply class condition if querying on polymorphic has_one + if($polymorphic) { + $disowned->assign("\"{$joinField}Class\"", null); + $disowned->addWhere(array( + "\"{$targetTable}\".\"{$joinField}Class\"" => get_class($owner) + )); + $owned->addWhere(array( + "\"{$sourceTable}\".\"{$joinField}Class\"" => get_class($owner) + )); + } + + // Merge queries and perform unlink + $ownedSQL = $owned->sql($ownedParams); + $disowned->addWhere(array( + "\"{$targetTable}\".\"ID\" NOT IN ({$ownedSQL})" => $ownedParams + )); + + $owner->extend('updateDisownershipQuery', $disowned, $sourceStage, $targetStage, $relationship); + + $disowned->execute(); + } + } /** * Removes the record from both live and stage @@ -1210,54 +1432,105 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { */ public function doArchive() { $owner = $this->owner; - $owner->invokeWithExtensions('onBeforeArchive', $this); - - if($owner->doUnpublish()) { - $owner->delete(); - $owner->invokeWithExtensions('onAfterArchive', $this); - - return true; + if(!$owner->canArchive()) { + return false; } - return false; + $owner->invokeWithExtensions('onBeforeArchive', $this); + $owner->doUnpublish(); + $owner->delete(); + $owner->invokeWithExtensions('onAfterArchive', $this); + + return true; } /** * Removes this record from the live site * * @return bool Flag whether the unpublish was successful - * - * @uses SiteTreeExtension->onBeforeUnpublish() - * @uses SiteTreeExtension->onAfterUnpublish() */ public function doUnpublish() { $owner = $this->owner; + if(!$owner->canUnpublish()) { + return false; + } + + // 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'); - $origStage = self::current_stage(); - self::reading_stage(self::get_live_stage()); + $origStage = static::get_stage(); + static::set_stage(static::LIVE); // This way our ID won't be unset $clone = clone $owner; $clone->delete(); - self::reading_stage($origStage); - - // If we're on the draft site, then we can update the status. - // Otherwise, these lines will resurrect an inappropriate record - if(self::current_stage() != self::get_live_stage() && $this->isOnDraft()) { - $owner->write(); - } + static::set_stage($origStage); $owner->invokeWithExtensions('onAfterUnpublish'); - return true; } + /** + * Trigger unpublish of owning objects + */ + public function onAfterUnpublish() { + $owner = $this->owner; + + // Any objects which owned (and thus relied on the unpublished object) are now unpublished automatically. + foreach ($owner->findOwners(false) as $object) { + /** @var Versioned|DataObject $object */ + $object->doUnpublish(); + } + } + + + /** + * Revert the draft changes: replace the draft content with the content on live + * + * @return bool True if the revert was successful + */ + public function doRevertToLive() { + $owner = $this->owner; + if(!$owner->canRevertToLive()) { + return false; + } + + $owner->invokeWithExtensions('onBeforeRevertToLive'); + $owner->publish("Live", "Stage", false); + $owner->invokeWithExtensions('onAfterRevertToLive'); + return true; + } + + /** + * Trigger revert of all owned objects to stage + */ + public function onAfterRevertToLive() { + $owner = $this->owner; + /** @var Versioned|DataObject $liveOwner */ + $liveOwner = static::get_by_stage(get_class($owner), static::LIVE) + ->byID($owner->ID); + + // Revert any owned objects from the live stage only + foreach ($liveOwner->findOwned(false) as $object) { + /** @var Versioned|DataObject $object */ + $object->doRevertToLive(); + } + + // 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 + $owner->unlinkDisownedObjects(Versioned::LIVE, Versioned::DRAFT); + } + /** * Move a database record from one stage to the other. * @@ -1276,7 +1549,7 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { if(is_numeric($fromStage)) { $from = Versioned::get_version($baseClass, $owner->ID, $fromStage); } else { - $this->owner->flushCache(); + $owner->flushCache(); $from = Versioned::get_one_by_stage($baseClass, $fromStage, array( "\"{$baseClass}\".\"ID\" = ?" => $owner->ID )); @@ -1292,19 +1565,19 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { } else { $from->migrateVersion($from->Version); - // Mark this version as having been published at some stage + // Mark this version as having been published at some stage $publisherID = isset(Member::currentUser()->ID) ? Member::currentUser()->ID : 0; $extTable = $this->extendWithSuffix($baseClass); - DB::prepared_query("UPDATE \"{$extTable}_versions\" - SET \"WasPublished\" = ?, \"PublisherID\" = ? - WHERE \"RecordID\" = ? AND \"Version\" = ?", - array(1, $publisherID, $from->ID, $from->Version) - ); + DB::prepared_query("UPDATE \"{$extTable}_versions\" + SET \"WasPublished\" = ?, \"PublisherID\" = ? + WHERE \"RecordID\" = ? AND \"Version\" = ?", + array(1, $publisherID, $from->ID, $from->Version) + ); } // Change to new stage, write, and revert state $oldMode = Versioned::get_reading_mode(); - Versioned::reading_stage($toStage); + Versioned::set_stage($toStage); // Migrate stage prior to write $from->setSourceQueryParam('Versioned.mode', 'stage'); @@ -1342,12 +1615,14 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { * * @param string $stage1 The first stage to check. * @param string $stage2 + * @return bool */ public function stagesDiffer($stage1, $stage2) { $table1 = $this->baseTable($stage1); $table2 = $this->baseTable($stage2); - if(!is_numeric($this->owner->ID)) { + $owner = $this->owner; + if(!is_numeric($owner->ID)) { return true; } @@ -1359,7 +1634,7 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { "SELECT CASE WHEN \"$table1\".\"Version\"=\"$table2\".\"Version\" THEN 1 ELSE 0 END FROM \"$table1\" INNER JOIN \"$table2\" ON \"$table1\".\"ID\" = \"$table2\".\"ID\" AND \"$table1\".\"ID\" = ?", - array($this->owner->ID) + array($owner->ID) )->value(); return !$stagesAreEqual; @@ -1371,6 +1646,7 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { * @param string $limit * @param string $join Deprecated, use leftJoin($table, $joinClause) instead * @param string $having + * @return ArrayList */ public function Versions($filter = "", $sort = "", $limit = "", $join = "", $having = "") { return $this->allVersions($filter, $sort, $limit, $join, $having); @@ -1388,11 +1664,14 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { */ public function allVersions($filter = "", $sort = "", $limit = "", $join = "", $having = "") { // Make sure the table names are not postfixed (e.g. _Live) - $oldMode = self::get_reading_mode(); - self::reading_stage('Stage'); + $oldMode = static::get_reading_mode(); + static::set_stage('Stage'); - $list = DataObject::get(get_class($this->owner), $filter, $sort, $join, $limit); - if($having) $having = $list->having($having); + $owner = $this->owner; + $list = DataObject::get(get_class($owner), $filter, $sort, $join, $limit); + if($having) { + $list->having($having); + } $query = $list->dataQuery()->query(); @@ -1414,7 +1693,7 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { } $query->addWhere(array( - "\"{$baseTable}_versions\".\"RecordID\" = ?" => $this->owner->ID + "\"{$baseTable}_versions\".\"RecordID\" = ?" => $owner->ID )); $query->setOrderBy(($sort) ? $sort : "\"{$baseTable}_versions\".\"LastEdited\" DESC, \"{$baseTable}_versions\".\"Version\" DESC"); @@ -1439,8 +1718,9 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { * @return DataObject */ public function compareVersions($from, $to) { - $fromRecord = Versioned::get_version($this->owner->class, $this->owner->ID, $from); - $toRecord = Versioned::get_version($this->owner->class, $this->owner->ID, $to); + $owner = $this->owner; + $fromRecord = Versioned::get_version($owner->class, $owner->ID, $from); + $toRecord = Versioned::get_version($owner->class, $owner->ID, $to); $diff = new DataDifferencer($fromRecord, $toRecord); @@ -1454,14 +1734,24 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { * @return string */ public function baseTable($stage = null) { - $tableClasses = ClassInfo::dataClassesFor($this->owner->class); - $baseClass = array_shift($tableClasses); + $baseClass = ClassInfo::baseDataClass($this->owner); + return $this->stageTable($baseClass, $stage); + } - if(!$stage || $stage == $this->defaultStage) { - return $baseClass; + /** + * Given a class and stage determine the table name. + * + * Note: Stages this asset does not exist in will default to the draft table. + * + * @param string $class + * @param string $stage + * @return string Table name + */ + public function stageTable($class, $stage) { + if($this->hasStages() && $stage === static::LIVE) { + return "{$class}_{$stage}"; } - - return $baseClass . "_$stage"; + return $class; } //-----------------------------------------------------------------------------------------------// @@ -1475,7 +1765,7 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { */ 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::get_live_stage()) + if((!$request->getVar('stage') || $request->getVar('stage') === static::LIVE) && !$request->getVar('archiveDate') ) { return true; @@ -1506,14 +1796,16 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { // Determine the reading mode if(isset($_GET['stage'])) { $stage = ucfirst(strtolower($_GET['stage'])); - if(!in_array($stage, array('Stage', 'Live'))) $stage = 'Live'; + if(!in_array($stage, array(static::DRAFT, static::LIVE))) { + $stage = static::LIVE; + } $mode = 'Stage.' . $stage; } elseif (isset($_GET['archiveDate']) && strtotime($_GET['archiveDate'])) { $mode = 'Archive.' . $_GET['archiveDate']; } elseif($preexistingMode) { $mode = $preexistingMode; } else { - $mode = self::DEFAULT_MODE; + $mode = static::DEFAULT_MODE; } // Save reading mode @@ -1521,13 +1813,13 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { // Try not to store the mode in the session if not needed if(($preexistingMode && $preexistingMode !== $mode) - || (!$preexistingMode && $mode !== self::DEFAULT_MODE) + || (!$preexistingMode && $mode !== static::DEFAULT_MODE) ) { Session::set('readingMode', $mode); } if(!headers_sent() && !Director::is_cli()) { - if(Versioned::current_stage() == 'Live') { + if(Versioned::get_stage() == 'Live') { // clear the cookie if it's set if(Cookie::get('bypassStaticCache')) { Cookie::force_expiry('bypassStaticCache', null, null, false, true /* httponly */); @@ -1547,7 +1839,7 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { * @param string $mode */ public static function set_reading_mode($mode) { - Versioned::$reading_mode = $mode; + self::$reading_mode = $mode; } /** @@ -1556,16 +1848,7 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { * @return string */ public static function get_reading_mode() { - return Versioned::$reading_mode; - } - - /** - * Get the name of the 'live' stage. - * - * @return string - */ - public static function get_live_stage() { - return "Live"; + return self::$reading_mode; } /** @@ -1573,7 +1856,7 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { * * @return string */ - public static function current_stage() { + public static function get_stage() { $parts = explode('.', Versioned::get_reading_mode()); if($parts[0] == 'Stage') { @@ -1588,7 +1871,9 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { */ public static function current_archived_date() { $parts = explode('.', Versioned::get_reading_mode()); - if($parts[0] == 'Archive') return $parts[1]; + if($parts[0] == 'Archive') { + return $parts[1]; + } } /** @@ -1596,8 +1881,8 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { * * @param string $stage New reading stage. */ - public static function reading_stage($stage) { - Versioned::set_reading_mode('Stage.' . $stage); + public static function set_stage($stage) { + static::set_reading_mode('Stage.' . $stage); } /** @@ -1623,7 +1908,7 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { */ public static function get_one_by_stage($class, $stage, $filter = '', $cache = true, $sort = '') { // TODO: No identity cache operating - $items = self::get_by_stage($class, $stage, $filter, $sort, null, 1); + $items = static::get_by_stage($class, $stage, $filter, $sort, null, 1); return $items->First(); } @@ -1736,14 +2021,15 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { */ public function deleteFromStage($stage) { $oldMode = Versioned::get_reading_mode(); - Versioned::reading_stage($stage); - $clone = clone $this->owner; + Versioned::set_stage($stage); + $owner = $this->owner; + $clone = clone $owner; $clone->delete(); Versioned::set_reading_mode($oldMode); // Fix the version number cache (in case you go delete from stage and then check ExistsOnLive) - $baseClass = ClassInfo::baseDataClass($this->owner->class); - self::$cache_versionnumber[$baseClass][$stage][$this->owner->ID] = null; + $baseClass = ClassInfo::baseDataClass($owner->class); + self::$cache_versionnumber[$baseClass][$stage][$owner->ID] = null; } /** @@ -1755,10 +2041,11 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { */ public function writeToStage($stage, $forceInsert = false) { $oldMode = Versioned::get_reading_mode(); - Versioned::reading_stage($stage); + Versioned::set_stage($stage); - $this->owner->forceChange(); - $result = $this->owner->write(false, $forceInsert); + $owner = $this->owner; + $owner->forceChange(); + $result = $owner->write(false, $forceInsert); Versioned::set_reading_mode($oldMode); return $result; @@ -1768,16 +2055,31 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { * Roll the draft version of this record to match the published record. * Caution: Doesn't overwrite the object properties with the rolled back version. * - * @param int $version Either the string 'Live' or a version number + * {@see doRevertToLive()} to reollback to live + * + * @param int $version Version number */ public function doRollbackTo($version) { $owner = $this->owner; $owner->extend('onBeforeRollback', $version); - $this->publish($version, "Stage", true); + $owner->publish($version, "Stage", true); $owner->writeWithoutVersion(); $owner->extend('onAfterRollback', $version); } + public function onAfterRollback($version) { + // Find record at this version + $baseClass = ClassInfo::baseDataClass($this->owner); + $recordVersion = static::get_version($baseClass, $this->owner->ID, $version); + + // Note that unlike other publishing actions, rollback is NOT recursive; + // The owner collects all objects and writes them back using writeToStage(); + foreach ($recordVersion->findOwned() as $object) { + /** @var Versioned|DataObject $object */ + $object->writeToStage(static::DRAFT); + } + } + /** * Return the latest version of the given record. * @@ -1788,10 +2090,9 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { public static function get_latest_version($class, $id) { $baseClass = ClassInfo::baseDataClass($class); $list = DataList::create($baseClass) - ->where(array("\"$baseClass\".\"RecordID\"" => $id)) ->setDataQueryParam("Versioned.mode", "latest_versions"); - return $list->First(); + return $list->byID($id); } /** @@ -1805,12 +2106,13 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { * @return boolean */ public function isLatestVersion() { - if(!$this->owner->isInDB()) { + $owner = $this->owner; + if(!$owner->isInDB()) { return false; } - $version = self::get_latest_version($this->owner->class, $this->owner->ID); - return ($version->Version == $this->owner->Version); + $version = static::get_latest_version($owner->class, $owner->ID); + return ($version->Version == $owner->Version); } /** @@ -1819,14 +2121,21 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { * @return bool */ public function isPublished() { - if(!$this->owner->isInDB()) { + $owner = $this->owner; + if(!$owner->isInDB()) { return false; } - $table = ClassInfo::baseDataClass($this->owner->class) . '_' . self::get_live_stage(); + // Non-staged objects are considered "published" if saved + if(!$this->hasStages()) { + return true; + } + + $baseClass = ClassInfo::baseDataClass($owner->class); + $table = $this->stageTable($baseClass, static::LIVE); $result = DB::prepared_query( "SELECT COUNT(*) FROM \"{$table}\" WHERE \"{$table}\".\"ID\" = ?", - array($this->owner->ID) + array($owner->ID) ); return (bool)$result->value(); } @@ -1837,14 +2146,15 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { * @return bool */ public function isOnDraft() { - if(!$this->owner->isInDB()) { + $owner = $this->owner; + if(!$owner->isInDB()) { return false; } - $table = ClassInfo::baseDataClass($this->owner->class); + $table = ClassInfo::baseDataClass($owner->class); $result = DB::prepared_query( "SELECT COUNT(*) FROM \"{$table}\" WHERE \"{$table}\".\"ID\" = ?", - array($this->owner->ID) + array($owner->ID) ); return (bool)$result->value(); } @@ -1887,13 +2197,12 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { public static function get_version($class, $id, $version) { $baseClass = ClassInfo::baseDataClass($class); $list = DataList::create($baseClass) - ->where(array( - "\"{$baseClass}\".\"RecordID\"" => $id, - "\"{$baseClass}\".\"Version\"" => $version - )) - ->setDataQueryParam("Versioned.mode", 'all_versions'); + ->setDataQueryParam([ + "Versioned.mode" => 'version', + "Versioned.version" => $version + ]); - return $list->First(); + return $list->byID($id); } /** @@ -1948,7 +2257,7 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { * @return string */ public function cacheKeyComponent() { - return 'versionedmode-'.self::get_reading_mode(); + return 'versionedmode-'.static::get_reading_mode(); } /** @@ -1957,14 +2266,11 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { * @return array */ public function getVersionedStages() { - return $this->stages; - } - - /** - * @return string - */ - public function getDefaultStage() { - return $this->defaultStage; + if($this->hasStages()) { + return [static::DRAFT, static::LIVE]; + } else { + return [static::DRAFT]; + } } public static function get_template_global_variables() { @@ -1972,6 +2278,15 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { '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; + } } /** @@ -2086,9 +2401,9 @@ class Versioned_Version extends ViewableData { if(!$component) { return null; } - if ($component->hasMethod($fieldName)) { - return $component->$fieldName(); - } - return $component->$fieldName; + if ($component->hasMethod($fieldName)) { + return $component->$fieldName(); } + return $component->$fieldName; } +} diff --git a/tests/filesystem/AssetControlExtensionTest.php b/tests/filesystem/AssetControlExtensionTest.php index 7027569ec..bcf3b4e84 100644 --- a/tests/filesystem/AssetControlExtensionTest.php +++ b/tests/filesystem/AssetControlExtensionTest.php @@ -15,7 +15,7 @@ class AssetControlExtensionTest extends SapphireTest { parent::setUp(); // Set backend and base url - \Versioned::reading_stage('Stage'); + \Versioned::set_stage(Versioned::DRAFT); AssetStoreTest_SpyStore::activate('AssetControlExtensionTest'); $this->logInWithPermission('ADMIN'); @@ -46,7 +46,7 @@ class AssetControlExtensionTest extends SapphireTest { } public function testFileDelete() { - \Versioned::reading_stage('Stage'); + \Versioned::set_stage(Versioned::DRAFT); /** @var AssetControlExtensionTest_VersionedObject $object1 */ $object1 = AssetControlExtensionTest_VersionedObject::get() @@ -120,7 +120,7 @@ class AssetControlExtensionTest extends SapphireTest { * Test files being replaced */ public function testReplaceFile() { - \Versioned::reading_stage('Stage'); + \Versioned::set_stage(Versioned::DRAFT); /** @var AssetControlExtensionTest_VersionedObject $object1 */ $object1 = AssetControlExtensionTest_VersionedObject::get() diff --git a/tests/filesystem/AssetStoreTest.php b/tests/filesystem/AssetStoreTest.php index aafda8489..4fc413c64 100644 --- a/tests/filesystem/AssetStoreTest.php +++ b/tests/filesystem/AssetStoreTest.php @@ -602,6 +602,7 @@ class AssetStoreTest_SpyStore extends FlysystemAssetStore { * Helper method to get local filesystem path for this file * * @param AssetContainer $asset + * @return string */ public static function getLocalPath(AssetContainer $asset) { if($asset instanceof Folder) { @@ -621,7 +622,6 @@ class AssetStoreTest_SpyStore extends FlysystemAssetStore { /** @var Local $adapter */ $adapter = $filesystem->getAdapter(); return $adapter->applyPathPrefix($fileID); - } public function cleanFilename($filename) { diff --git a/tests/filesystem/FileMigrationHelperTest.php b/tests/filesystem/FileMigrationHelperTest.php index 65e9d03f3..c0bc74478 100644 --- a/tests/filesystem/FileMigrationHelperTest.php +++ b/tests/filesystem/FileMigrationHelperTest.php @@ -21,6 +21,7 @@ class FileMigrationHelperTest extends SapphireTest { * @return string */ protected function getBasePath() { + // Note that the actual filesystem base is the 'assets' subdirectory within this return ASSETS_PATH . '/FileMigrationHelperTest'; } @@ -36,7 +37,7 @@ class FileMigrationHelperTest extends SapphireTest { // Ensure that each file has a local record file in this new assets base $from = FRAMEWORK_PATH . '/tests/model/testimages/test-image-low-quality.jpg'; foreach(File::get()->exclude('ClassName', 'Folder') as $file) { - $dest = $this->getBasePath() . '/assets/' . $file->getFilename(); + $dest = AssetStoreTest_SpyStore::base_path() . '/' . $file->generateFilename(); SS_Filesystem::makeFolder(dirname($dest)); copy($from, $dest); } @@ -55,7 +56,7 @@ class FileMigrationHelperTest extends SapphireTest { public function testMigration() { // Prior to migration, check that each file has empty Filename / Hash properties foreach(File::get()->exclude('ClassName', 'Folder') as $file) { - $filename = $file->getFilename(); + $filename = $file->generateFilename(); $this->assertNotEmpty($filename, "File {$file->Name} has a filename"); $this->assertEmpty($file->File->getFilename(), "File {$file->Name} has no DBFile filename"); $this->assertEmpty($file->File->getHash(), "File {$file->Name} has no hash"); @@ -70,20 +71,25 @@ class FileMigrationHelperTest extends SapphireTest { // Test that each file exists foreach(File::get()->exclude('ClassName', 'Folder') as $file) { + $expectedFilename = $file->generateFilename(); $filename = $file->File->getFilename(); + $this->assertTrue($file->exists(), "File with name {$filename} exists"); $this->assertNotEmpty($filename, "File {$file->Name} has a Filename"); + $this->assertEquals($expectedFilename, $filename, "File {$file->Name} has retained its Filename value"); $this->assertEquals( '33be1b95cba0358fe54e8b13532162d52f97421c', $file->File->getHash(), "File with name {$filename} has the correct hash" ); - $this->assertTrue($file->exists(), "File with name {$filename} exists"); $this->assertTrue($file->isPublished(), "File is published after migration"); } } } +/** + * @property File $owner + */ class FileMigrationHelperTest_Extension extends DataExtension implements TestOnly { /** * Ensure that File dataobject has the legacy "Filename" field @@ -94,6 +100,6 @@ class FileMigrationHelperTest_Extension extends DataExtension implements TestOnl public function onBeforeWrite() { // Ensure underlying filename field is written to the database - $this->owner->setField('Filename', 'assets/' . $this->owner->getFilename()); + $this->owner->setField('Filename', 'assets/' . $this->owner->generateFilename()); } } diff --git a/tests/filesystem/FileTest.php b/tests/filesystem/FileTest.php index 4de31bb7f..bf2afe01b 100644 --- a/tests/filesystem/FileTest.php +++ b/tests/filesystem/FileTest.php @@ -15,7 +15,7 @@ class FileTest extends SapphireTest { public function setUp() { parent::setUp(); $this->logInWithPermission('ADMIN'); - Versioned::reading_stage('Stage'); + Versioned::set_stage(Versioned::DRAFT); // Set backend root to /ImageTest AssetStoreTest_SpyStore::activate('FileTest'); @@ -242,7 +242,7 @@ class FileTest extends SapphireTest { // Rename $file->Name = 'renamed.txt'; $newTuple = $oldTuple; - $newTuple['Filename'] = $file->getFilename(); + $newTuple['Filename'] = $file->generateFilename(); // Before write() $this->assertTrue( @@ -287,7 +287,7 @@ class FileTest extends SapphireTest { // set ParentID $file->ParentID = $subfolder->ID; $newTuple = $oldTuple; - $newTuple['Filename'] = $file->getFilename(); + $newTuple['Filename'] = $file->generateFilename(); // Before write() $this->assertTrue( diff --git a/tests/filesystem/FolderTest.php b/tests/filesystem/FolderTest.php index 1717d7ba3..e3ef8b69c 100644 --- a/tests/filesystem/FolderTest.php +++ b/tests/filesystem/FolderTest.php @@ -16,7 +16,7 @@ class FolderTest extends SapphireTest { parent::setUp(); $this->logInWithPermission('ADMIN'); - Versioned::reading_stage('Stage'); + Versioned::set_stage(Versioned::DRAFT); // Set backend root to /FolderTest AssetStoreTest_SpyStore::activate('FolderTest'); @@ -110,25 +110,44 @@ class FolderTest extends SapphireTest { Folder::find_or_make($folder1->Filename); $folder2 = $this->objFromFixture('Folder', 'folder2'); + // Publish file1 + $file1 = DataObject::get_by_id('File', $this->idFromFixture('File', 'file1-folder1'), false); + $file1->doPublish(); + // set ParentID. This should cause updateFilesystem to be called on all children $folder1->ParentID = $folder2->ID; $folder1->write(); // Check if the file in the folder moved along - $file1 = DataObject::get_by_id('File', $this->idFromFixture('File', 'file1-folder1'), false); - $this->assertFileExists(AssetStoreTest_SpyStore::getLocalPath($file1)); + $file1Draft = Versioned::get_by_stage('File', Versioned::DRAFT)->byID($file1->ID); + $this->assertFileExists(AssetStoreTest_SpyStore::getLocalPath($file1Draft)); $this->assertEquals( 'FileTest-folder2/FileTest-folder1/File1.txt', - $file1->Filename, + $file1Draft->Filename, 'The file DataObject has updated path' ); // File should be located in new folder $this->assertEquals( ASSETS_PATH . '/FolderTest/.protected/FileTest-folder2/FileTest-folder1/55b443b601/File1.txt', - AssetStoreTest_SpyStore::getLocalPath($file1) + AssetStoreTest_SpyStore::getLocalPath($file1Draft) ); + + // Published (live) version remains in the old location + $file1Live = Versioned::get_by_stage('File', Versioned::LIVE)->byID($file1->ID); + $this->assertEquals( + ASSETS_PATH . '/FolderTest/FileTest-folder1/55b443b601/File1.txt', + AssetStoreTest_SpyStore::getLocalPath($file1Live) + ); + + // Publishing the draft to live should move the new file to the public store + $file1Draft->doPublish(); + $this->assertEquals( + ASSETS_PATH . '/FolderTest/FileTest-folder2/FileTest-folder1/55b443b601/File1.txt', + AssetStoreTest_SpyStore::getLocalPath($file1Draft) + ); + } /** diff --git a/tests/filesystem/UploadTest.php b/tests/filesystem/UploadTest.php index fa1202142..9e7556b5c 100644 --- a/tests/filesystem/UploadTest.php +++ b/tests/filesystem/UploadTest.php @@ -10,7 +10,7 @@ class UploadTest extends SapphireTest { public function setUp() { parent::setUp(); - Versioned::reading_stage('Stage'); + Versioned::set_stage(Versioned::DRAFT); AssetStoreTest_SpyStore::activate('UploadTest'); } diff --git a/tests/forms/uploadfield/AssetFieldTest.php b/tests/forms/uploadfield/AssetFieldTest.php index 4b956a2cb..152ec5640 100644 --- a/tests/forms/uploadfield/AssetFieldTest.php +++ b/tests/forms/uploadfield/AssetFieldTest.php @@ -16,7 +16,7 @@ class AssetFieldTest extends FunctionalTest { parent::setUp(); $this->logInWithPermission('ADMIN'); - Versioned::reading_stage('Stage'); + Versioned::set_stage(Versioned::DRAFT); // Set backend root to /AssetFieldTest AssetStoreTest_SpyStore::activate('AssetFieldTest'); diff --git a/tests/forms/uploadfield/UploadFieldTest.php b/tests/forms/uploadfield/UploadFieldTest.php index d95d605f8..aed1a9056 100644 --- a/tests/forms/uploadfield/UploadFieldTest.php +++ b/tests/forms/uploadfield/UploadFieldTest.php @@ -23,7 +23,7 @@ class UploadFieldTest extends FunctionalTest { // Save versioned state $this->oldReadingMode = Versioned::get_reading_mode(); - Versioned::reading_stage('Stage'); + Versioned::set_stage(Versioned::DRAFT); // Set backend root to /UploadFieldTest AssetStoreTest_SpyStore::activate('UploadFieldTest'); diff --git a/tests/model/DataDifferencerTest.php b/tests/model/DataDifferencerTest.php index b595f2cc8..da5ab2090 100644 --- a/tests/model/DataDifferencerTest.php +++ b/tests/model/DataDifferencerTest.php @@ -19,7 +19,7 @@ class DataDifferencerTest extends SapphireTest { public function setUp() { parent::setUp(); - Versioned::reading_stage('Stage'); + Versioned::set_stage(Versioned::DRAFT); // Set backend root to /DataDifferencerTest AssetStoreTest_SpyStore::activate('DataDifferencerTest'); diff --git a/tests/model/DataObjectLazyLoadingTest.php b/tests/model/DataObjectLazyLoadingTest.php index 001e24b20..fc944e6f3 100644 --- a/tests/model/DataObjectLazyLoadingTest.php +++ b/tests/model/DataObjectLazyLoadingTest.php @@ -408,7 +408,7 @@ class DataObjectLazyLoadingTest extends SapphireTest { $obj1ID )); - Versioned::reading_stage('Live'); + Versioned::set_stage(Versioned::LIVE); $obj1 = VersionedLazy_DataObject::get()->byID($obj1ID); $this->assertEquals( 'live-value', diff --git a/tests/model/VersionedOwnershipTest.php b/tests/model/VersionedOwnershipTest.php index c399d1cc7..1b7f5f2e9 100644 --- a/tests/model/VersionedOwnershipTest.php +++ b/tests/model/VersionedOwnershipTest.php @@ -10,6 +10,7 @@ class VersionedOwnershipTest extends SapphireTest { 'VersionedOwnershipTest_Subclass', 'VersionedOwnershipTest_Related', 'VersionedOwnershipTest_Attachment', + 'VersionedOwnershipTest_RelatedMany', ); protected static $fixture_file = 'VersionedOwnershipTest.yml'; @@ -18,7 +19,7 @@ class VersionedOwnershipTest extends SapphireTest { { parent::setUp(); - Versioned::reading_stage('Stage'); + Versioned::set_stage(Versioned::DRAFT); // Automatically publish any object named *_published foreach($this->getFixtureFactory()->getFixtures() as $class => $fixtures) { @@ -32,6 +33,18 @@ class VersionedOwnershipTest extends SapphireTest { } } + /** + * Virtual "sleep" that doesn't actually slow execution, only advances SS_DateTime::now() + * + * @param int $minutes + */ + protected function sleep($minutes) { + $now = SS_Datetime::now(); + $date = DateTime::createFromFormat('Y-m-d H:i:s', $now->getValue()); + $date->modify("+{$minutes} minutes"); + SS_Datetime::set_mock_now($date->format('Y-m-d H:i:s')); + } + /** * Test basic findOwned() in stage mode */ @@ -44,6 +57,9 @@ class VersionedOwnershipTest extends SapphireTest { ['Title' => 'Attachment 1'], ['Title' => 'Attachment 2'], ['Title' => 'Attachment 5'], + ['Title' => 'Related Many 1'], + ['Title' => 'Related Many 2'], + ['Title' => 'Related Many 3'], ], $subclass1->findOwned() ); @@ -52,6 +68,9 @@ class VersionedOwnershipTest extends SapphireTest { $this->assertDOSEquals( [ ['Title' => 'Related 1'], + ['Title' => 'Related Many 1'], + ['Title' => 'Related Many 2'], + ['Title' => 'Related Many 3'], ], $subclass1->findOwned(false) ); @@ -64,6 +83,7 @@ class VersionedOwnershipTest extends SapphireTest { ['Title' => 'Attachment 3'], ['Title' => 'Attachment 4'], ['Title' => 'Attachment 5'], + ['Title' => 'Related Many 4'], ], $subclass2->findOwned() ); @@ -71,7 +91,8 @@ class VersionedOwnershipTest extends SapphireTest { // Non-recursive search $this->assertDOSEquals( [ - ['Title' => 'Related 2'] + ['Title' => 'Related 2'], + ['Title' => 'Related Many 4'], ], $subclass2->findOwned(false) ); @@ -175,6 +196,7 @@ class VersionedOwnershipTest extends SapphireTest { ['Title' => 'Related 2 Modified'], ['Title' => 'Attachment 3 Modified'], ['Title' => 'Attachment 5'], + ['Title' => 'Related Many 4'], ], $subclass2Stage->findOwned() ); @@ -183,6 +205,7 @@ class VersionedOwnershipTest extends SapphireTest { $this->assertDOSEquals( [ ['Title' => 'Related 2 Modified'], + ['Title' => 'Related Many 4'], ], $subclass2Stage->findOwned(false) ); @@ -196,6 +219,7 @@ class VersionedOwnershipTest extends SapphireTest { ['Title' => 'Attachment 3'], ['Title' => 'Attachment 4'], ['Title' => 'Attachment 5'], + ['Title' => 'Related Many 4'], ], $subclass2Live->findOwned() ); @@ -204,10 +228,282 @@ class VersionedOwnershipTest extends SapphireTest { $this->assertDOSEquals( [ ['Title' => 'Related 2'], + ['Title' => 'Related Many 4'], ], $subclass2Live->findOwned(false) ); } + + /** + * Test that objects are correctly published recursively + */ + public function testRecursivePublish() { + /** @var VersionedOwnershipTest_Subclass $parent */ + $parent = $this->objFromFixture('VersionedOwnershipTest_Subclass', 'subclass1_published'); + $parentID = $parent->ID; + $banner1 = $this->objFromFixture('VersionedOwnershipTest_RelatedMany', 'relatedmany1_published'); + $banner2 = $this->objFromFixture('VersionedOwnershipTest_RelatedMany', 'relatedmany2_published'); + $banner2ID = $banner2->ID; + + // Modify, Add, and Delete banners on stage + $banner1->Title = 'Renamed Banner 1'; + $banner1->write(); + + $banner2->delete(); + + $banner4 = new VersionedOwnershipTest_RelatedMany(); + $banner4->Title = 'New Banner'; + $parent->Banners()->add($banner4); + + // Check state of objects before publish + $oldLiveBanners = [ + ['Title' => 'Related Many 1'], + ['Title' => 'Related Many 2'], // Will be deleted + // `Related Many 3` isn't published + ]; + $newBanners = [ + ['Title' => 'Renamed Banner 1'], // Renamed + ['Title' => 'Related Many 3'], // Published without changes + ['Title' => 'New Banner'], // Created + ]; + $parentDraft = Versioned::get_by_stage('VersionedOwnershipTest_Subclass', Versioned::DRAFT) + ->byID($parentID); + $this->assertDOSEquals($newBanners, $parentDraft->Banners()); + $parentLive = Versioned::get_by_stage('VersionedOwnershipTest_Subclass', Versioned::LIVE) + ->byID($parentID); + $this->assertDOSEquals($oldLiveBanners, $parentLive->Banners()); + + // On publishing of owner, all children should now be updated + $parent->doPublish(); + + // Now check each object has the correct state + $parentDraft = Versioned::get_by_stage('VersionedOwnershipTest_Subclass', Versioned::DRAFT) + ->byID($parentID); + $this->assertDOSEquals($newBanners, $parentDraft->Banners()); + $parentLive = Versioned::get_by_stage('VersionedOwnershipTest_Subclass', Versioned::LIVE) + ->byID($parentID); + $this->assertDOSEquals($newBanners, $parentLive->Banners()); + + // Check that the deleted banner hasn't actually been deleted from the live stage, + // but in fact has been unlinked. + $banner2Live = Versioned::get_by_stage('VersionedOwnershipTest_RelatedMany', Versioned::LIVE) + ->byID($banner2ID); + $this->assertEmpty($banner2Live->PageID); + } + + /** + * Test that owning objects get unpublished as needed + */ + public function testRecursiveUnpublish() { + // Unsaved objects can't be unpublished + $unsaved = new VersionedOwnershipTest_Subclass(); + $this->assertFalse($unsaved->doUnpublish()); + + // Draft-only objects can't be unpublished + /** @var VersionedOwnershipTest_RelatedMany $banner3Unpublished */ + $banner3Unpublished = $this->objFromFixture('VersionedOwnershipTest_RelatedMany', 'relatedmany3'); + $this->assertFalse($banner3Unpublished->doUnpublish()); + + // First test: mid-level unpublish; We expect that owners should be unpublished, but not + // owned objects, nor other siblings shared by the same owner. + $related2 = $this->objFromFixture('VersionedOwnershipTest_Related', 'related2_published'); + /** @var VersionedOwnershipTest_Attachment $attachment3 */ + $attachment3 = $this->objFromFixture('VersionedOwnershipTest_Attachment', 'attachment3_published'); + /** @var VersionedOwnershipTest_RelatedMany $relatedMany4 */ + $relatedMany4 = $this->objFromFixture('VersionedOwnershipTest_RelatedMany', 'relatedmany4_published'); + /** @var VersionedOwnershipTest_Related $related2 */ + $this->assertTrue($related2->doUnpublish()); + $subclass2 = $this->objFromFixture('VersionedOwnershipTest_Subclass', 'subclass2_published'); + + /** @var VersionedOwnershipTest_Subclass $subclass2 */ + $this->assertFalse($subclass2->isPublished()); // Owner IS unpublished + $this->assertTrue($attachment3->isPublished()); // Owned object is NOT unpublished + $this->assertTrue($relatedMany4->isPublished()); // Owned object by owner is NOT unpublished + + // Second test: multi-level unpublish should recursively cascade down all owning objects + // Publish related2 again + $subclass2->doPublish(); + $this->assertTrue($subclass2->isPublished()); + $this->assertTrue($related2->isPublished()); + $this->assertTrue($attachment3->isPublished()); + + // Unpublish leaf node + $this->assertTrue($attachment3->doUnpublish()); + + // Now all owning objects (only) are unpublished + $this->assertFalse($attachment3->isPublished()); // Unpublished because we just unpublished it + $this->assertFalse($related2->isPublished()); // Unpublished because it owns attachment3 + $this->assertFalse($subclass2->isPublished()); // Unpublished ecause it owns related2 + $this->assertTrue($relatedMany4->isPublished()); // Stays live because recursion only affects owners not owned. + } + + public function testRecursiveArchive() { + // When archiving an object, any published owners should be unpublished at the same time + // but NOT achived + + /** @var VersionedOwnershipTest_Attachment $attachment3 */ + $attachment3 = $this->objFromFixture('VersionedOwnershipTest_Attachment', 'attachment3_published'); + $attachment3ID = $attachment3->ID; + $this->assertTrue($attachment3->doArchive()); + + // This object is on neither stage nor live + $stageAttachment = Versioned::get_by_stage('VersionedOwnershipTest_Attachment', Versioned::DRAFT) + ->byID($attachment3ID); + $liveAttachment = Versioned::get_by_stage('VersionedOwnershipTest_Attachment', Versioned::LIVE) + ->byID($attachment3ID); + $this->assertEmpty($stageAttachment); + $this->assertEmpty($liveAttachment); + + // Owning object is unpublished only + /** @var VersionedOwnershipTest_Related $stageOwner */ + $stageOwner = $this->objFromFixture('VersionedOwnershipTest_Related', 'related2_published'); + $this->assertTrue($stageOwner->isOnDraft()); + $this->assertFalse($stageOwner->isPublished()); + + // Bottom level owning object is also unpublished + /** @var VersionedOwnershipTest_Subclass $stageTopOwner */ + $stageTopOwner = $this->objFromFixture('VersionedOwnershipTest_Subclass', 'subclass2_published'); + $this->assertTrue($stageTopOwner->isOnDraft()); + $this->assertFalse($stageTopOwner->isPublished()); + } + + public function testRecursiveRevertToLive() { + /** @var VersionedOwnershipTest_Subclass $parent */ + $parent = $this->objFromFixture('VersionedOwnershipTest_Subclass', 'subclass1_published'); + $parentID = $parent->ID; + $banner1 = $this->objFromFixture('VersionedOwnershipTest_RelatedMany', 'relatedmany1_published'); + $banner2 = $this->objFromFixture('VersionedOwnershipTest_RelatedMany', 'relatedmany2_published'); + $banner2ID = $banner2->ID; + + // Modify, Add, and Delete banners on stage + $banner1->Title = 'Renamed Banner 1'; + $banner1->write(); + + $banner2->delete(); + + $banner4 = new VersionedOwnershipTest_RelatedMany(); + $banner4->Title = 'New Banner'; + $banner4->write(); + $parent->Banners()->add($banner4); + + // Check state of objects before publish + $liveBanners = [ + ['Title' => 'Related Many 1'], + ['Title' => 'Related Many 2'], + ]; + $modifiedBanners = [ + ['Title' => 'Renamed Banner 1'], // Renamed + ['Title' => 'Related Many 3'], // Published without changes + ['Title' => 'New Banner'], // Created + ]; + $parentDraft = Versioned::get_by_stage('VersionedOwnershipTest_Subclass', Versioned::DRAFT) + ->byID($parentID); + $this->assertDOSEquals($modifiedBanners, $parentDraft->Banners()); + $parentLive = Versioned::get_by_stage('VersionedOwnershipTest_Subclass', Versioned::LIVE) + ->byID($parentID); + $this->assertDOSEquals($liveBanners, $parentLive->Banners()); + + // When reverting parent, all records should be put back on stage + $this->assertTrue($parent->doRevertToLive()); + + // Now check each object has the correct state + $parentDraft = Versioned::get_by_stage('VersionedOwnershipTest_Subclass', Versioned::DRAFT) + ->byID($parentID); + $this->assertDOSEquals($liveBanners, $parentDraft->Banners()); + $parentLive = Versioned::get_by_stage('VersionedOwnershipTest_Subclass', Versioned::LIVE) + ->byID($parentID); + $this->assertDOSEquals($liveBanners, $parentLive->Banners()); + + // Check that the newly created banner, even though it still exist, has been + // unlinked from the reverted draft record + /** @var VersionedOwnershipTest_RelatedMany $banner4Draft */ + $banner4Draft = Versioned::get_by_stage('VersionedOwnershipTest_RelatedMany', Versioned::DRAFT) + ->byID($banner4->ID); + $this->assertTrue($banner4Draft->isOnDraft()); + $this->assertFalse($banner4Draft->isPublished()); + $this->assertEmpty($banner4Draft->PageID); + } + + /** + * Test that rolling back to a single version works recursively + */ + public function testRecursiveRollback() { + /** @var VersionedOwnershipTest_Subclass $subclass2 */ + $this->sleep(1); + $subclass2 = $this->objFromFixture('VersionedOwnershipTest_Subclass', 'subclass2_published'); + + // Create a few new versions + $versions = []; + for($version = 1; $version <= 3; $version++) { + // Write owned objects + $this->sleep(1); + foreach($subclass2->findOwned(true) as $obj) { + $obj->Title .= " - v{$version}"; + $obj->write(); + } + // Write parent + $this->sleep(1); + $subclass2->Title .= " - v{$version}"; + $subclass2->write(); + $versions[$version] = $subclass2->Version; + } + + + // Check reverting to first version + $this->sleep(1); + $subclass2->doRollbackTo($versions[1]); + /** @var VersionedOwnershipTest_Subclass $subclass2Draft */ + $subclass2Draft = Versioned::get_by_stage('VersionedOwnershipTest_Subclass', Versioned::DRAFT) + ->byID($subclass2->ID); + $this->assertEquals('Subclass 2 - v1', $subclass2Draft->Title); + $this->assertDOSEquals( + [ + ['Title' => 'Related 2 - v1'], + ['Title' => 'Attachment 3 - v1'], + ['Title' => 'Attachment 4 - v1'], + ['Title' => 'Attachment 5 - v1'], + ['Title' => 'Related Many 4 - v1'], + ], + $subclass2Draft->findOwned(true) + ); + + // Check rolling forward to a later version + $this->sleep(1); + $subclass2->doRollbackTo($versions[3]); + /** @var VersionedOwnershipTest_Subclass $subclass2Draft */ + $subclass2Draft = Versioned::get_by_stage('VersionedOwnershipTest_Subclass', Versioned::DRAFT) + ->byID($subclass2->ID); + $this->assertEquals('Subclass 2 - v1 - v2 - v3', $subclass2Draft->Title); + $this->assertDOSEquals( + [ + ['Title' => 'Related 2 - v1 - v2 - v3'], + ['Title' => 'Attachment 3 - v1 - v2 - v3'], + ['Title' => 'Attachment 4 - v1 - v2 - v3'], + ['Title' => 'Attachment 5 - v1 - v2 - v3'], + ['Title' => 'Related Many 4 - v1 - v2 - v3'], + ], + $subclass2Draft->findOwned(true) + ); + + // And rolling back one version + $this->sleep(1); + $subclass2->doRollbackTo($versions[2]); + /** @var VersionedOwnershipTest_Subclass $subclass2Draft */ + $subclass2Draft = Versioned::get_by_stage('VersionedOwnershipTest_Subclass', Versioned::DRAFT) + ->byID($subclass2->ID); + $this->assertEquals('Subclass 2 - v1 - v2', $subclass2Draft->Title); + $this->assertDOSEquals( + [ + ['Title' => 'Related 2 - v1 - v2'], + ['Title' => 'Attachment 3 - v1 - v2'], + ['Title' => 'Attachment 4 - v1 - v2'], + ['Title' => 'Attachment 5 - v1 - v2'], + ['Title' => 'Related Many 4 - v1 - v2'], + ], + $subclass2Draft->findOwned(true) + ); + } + } /** @@ -224,6 +520,11 @@ class VersionedOwnershipTest_Object extends DataObject implements TestOnly { ); } +/** + * Object which: + * - owns a has_one object + * - owns has_many objects + */ class VersionedOwnershipTest_Subclass extends VersionedOwnershipTest_Object implements TestOnly { private static $db = array( 'Description' => 'Text', @@ -233,12 +534,21 @@ class VersionedOwnershipTest_Subclass extends VersionedOwnershipTest_Object impl 'Related' => 'VersionedOwnershipTest_Related', ); + private static $has_many = array( + 'Banners' => 'VersionedOwnershipTest_RelatedMany' + ); + private static $owns = array( 'Related', + 'Banners', ); } /** + * Object which: + * - owned by has_many objects + * - owns many_many Objects + * * @mixin Versioned */ class VersionedOwnershipTest_Related extends DataObject implements TestOnly { @@ -268,6 +578,29 @@ class VersionedOwnershipTest_Related extends DataObject implements TestOnly { ); } +/** + * Object which is owned by a has_one object + * + * @mixin Versioned + */ +class VersionedOwnershipTest_RelatedMany extends DataObject implements TestOnly { + private static $extensions = array( + 'Versioned', + ); + + private static $db = array( + 'Title' => 'Varchar(255)', + ); + + private static $has_one = array( + 'Page' => 'VersionedOwnershipTest_Subclass' + ); + + private static $owned_by = array( + 'Page' + ); +} + /** * @mixin Versioned */ diff --git a/tests/model/VersionedOwnershipTest.yml b/tests/model/VersionedOwnershipTest.yml index 270de2cc3..40f0b1276 100644 --- a/tests/model/VersionedOwnershipTest.yml +++ b/tests/model/VersionedOwnershipTest.yml @@ -26,6 +26,20 @@ VersionedOwnershipTest_Subclass: Title: 'Subclass 2' Related: =>VersionedOwnershipTest_Related.related2_published +VersionedOwnershipTest_RelatedMany: + relatedmany1_published: + Title: 'Related Many 1' + Page: =>VersionedOwnershipTest_Subclass.subclass1_published + relatedmany2_published: + Title: 'Related Many 2' + Page: =>VersionedOwnershipTest_Subclass.subclass1_published + relatedmany3: + Title: 'Related Many 3' + Page: =>VersionedOwnershipTest_Subclass.subclass1_published + relatedmany4_published: + Title: 'Related Many 4' + Page: =>VersionedOwnershipTest_Subclass.subclass2_published + VersionedOwnershipTest_Object: object1: Title: 'Object 1' diff --git a/tests/model/VersionedTest.php b/tests/model/VersionedTest.php index 3824e6481..cae53250c 100644 --- a/tests/model/VersionedTest.php +++ b/tests/model/VersionedTest.php @@ -184,7 +184,7 @@ class VersionedTest extends SapphireTest { $this->assertEquals($allPageIDs, $allPages->column('ID')); // Check that this still works if we switch to reading the other stage - Versioned::reading_stage("Live"); + Versioned::set_stage(Versioned::LIVE); $allPages = Versioned::get_including_deleted("VersionedTest_DataObject", "\"ParentID\" = 0", "\"VersionedTest_DataObject\".\"ID\" ASC"); $this->assertEquals(array("Page 1", "Page 2", "Page 3", "Subclass Page 1"), $allPages->column('Title')); @@ -312,9 +312,9 @@ class VersionedTest extends SapphireTest { } public function testWritingNewToStage() { - $origStage = Versioned::current_stage(); + $origStage = Versioned::get_stage(); - Versioned::reading_stage("Stage"); + Versioned::set_stage(Versioned::DRAFT); $page = new VersionedTest_DataObject(); $page->Title = "testWritingNewToStage"; $page->URLSegment = "testWritingNewToStage"; @@ -331,7 +331,7 @@ class VersionedTest extends SapphireTest { $this->assertEquals(1, $stage->count()); $this->assertEquals($stage->First()->Title, 'testWritingNewToStage'); - Versioned::reading_stage($origStage); + Versioned::set_stage($origStage); } /** @@ -341,9 +341,9 @@ class VersionedTest extends SapphireTest { * the VersionedTest_DataObject record though. */ public function testWritingNewToLive() { - $origStage = Versioned::current_stage(); + $origStage = Versioned::get_stage(); - Versioned::reading_stage("Live"); + Versioned::set_stage(Versioned::LIVE); $page = new VersionedTest_DataObject(); $page->Title = "testWritingNewToLive"; $page->URLSegment = "testWritingNewToLive"; @@ -360,7 +360,7 @@ class VersionedTest extends SapphireTest { )); $this->assertEquals(0, $stage->count()); - Versioned::reading_stage($origStage); + Versioned::set_stage($origStage); } /** @@ -401,7 +401,7 @@ class VersionedTest extends SapphireTest { * Test that SQLSelect::queriedTables() applies the version-suffixes properly. */ public function testQueriedTables() { - Versioned::reading_stage('Live'); + Versioned::set_stage(Versioned::LIVE); $this->assertEquals(array( 'VersionedTest_DataObject_Live', @@ -409,6 +409,102 @@ class VersionedTest extends SapphireTest { ), DataObject::get('VersionedTest_Subclass')->dataQuery()->query()->queriedTables()); } + /** + * Virtual "sleep" that doesn't actually slow execution, only advances SS_DateTime::now() + * + * @param int $minutes + */ + protected function sleep($minutes) { + $now = SS_Datetime::now(); + $date = DateTime::createFromFormat('Y-m-d H:i:s', $now->getValue()); + $date->modify("+{$minutes} minutes"); + SS_Datetime::set_mock_now($date->format('Y-m-d H:i:s')); + } + + /** + * Tests records selected by specific version + */ + public function testGetVersion() { + // Create a few initial versions to ensure this version + // doesn't clash with child versions + $this->sleep(1); + /** @var VersionedTest_DataObject $page2 */ + $page2 = $this->objFromFixture('VersionedTest_DataObject', 'page2'); + $page2->Title = 'dummy1'; + $page2->write(); + $this->sleep(1); + $page2->Title = 'dummy2'; + $page2->write(); + $this->sleep(1); + $page2->Title = 'Page 2 - v1'; + $page2->write(); + $version1Date = $page2->LastEdited; + $version1 = $page2->Version; + + // Create another version where this object and some + // child records have been modified + $this->sleep(1); + /** @var VersionedTest_DataObject $page2a */ + $page2a = $this->objFromFixture('VersionedTest_DataObject', 'page2a'); + $page2a->Title = 'Page 2a - v2'; + $page2a->write(); + $this->sleep(1); + $page2->Title = 'Page 2 - v2'; + $page2->write(); + $version2Date = $page2->LastEdited; + $version2 = $page2->Version; + $this->assertGreaterThan($version1, $version2); + $this->assertDOSEquals( + [ + ['Title' => 'Page 2a - v2'], + ['Title' => 'Page 2b'], + ], + $page2->Children() + ); + + // test selecting v1 + /** @var VersionedTest_DataObject $page2v1 */ + $page2v1 = Versioned::get_version('VersionedTest_DataObject', $page2->ID, $version1); + $this->assertEquals('Page 2 - v1', $page2v1->Title); + + // When selecting v1, related records should by filtered by + // the modified date of that version + $archiveParms = [ + 'Versioned.mode' => 'archive', + 'Versioned.date' => $version1Date + ]; + $this->assertEquals($archiveParms, $page2v1->getInheritableQueryParams()); + $this->assertArraySubset($archiveParms, $page2v1->Children()->getQueryParams()); + $this->assertDOSEquals( + [ + ['Title' => 'Page 2a'], + ['Title' => 'Page 2b'], + ], + $page2v1->Children() + ); + + // When selecting v2, we get the same as on stage + /** @var VersionedTest_DataObject $page2v2 */ + $page2v2 = Versioned::get_version('VersionedTest_DataObject', $page2->ID, $version2); + $this->assertEquals('Page 2 - v2', $page2v2->Title); + + // When selecting v2, related records should by filtered by + // the modified date of that version + $archiveParms = [ + 'Versioned.mode' => 'archive', + 'Versioned.date' => $version2Date + ]; + $this->assertEquals($archiveParms, $page2v2->getInheritableQueryParams()); + $this->assertArraySubset($archiveParms, $page2v2->Children()->getQueryParams()); + $this->assertDOSEquals( + [ + ['Title' => 'Page 2a - v2'], + ['Title' => 'Page 2b'], + ], + $page2v2->Children() + ); + } + public function testGetVersionWhenClassnameChanged() { $obj = new VersionedTest_DataObject; $obj->Name = "test"; @@ -593,7 +689,7 @@ class VersionedTest extends SapphireTest { 'Does not contain separate table with _Stage suffix' ); - Versioned::reading_stage("Stage"); + Versioned::set_stage(Versioned::DRAFT); $obj = new VersionedTest_SingleStage(array('Name' => 'MyObj')); $obj->write(); $this->assertNotNull( @@ -601,7 +697,7 @@ class VersionedTest extends SapphireTest { 'Writes to and reads from default stage if its set explicitly' ); - Versioned::reading_stage("Live"); + Versioned::set_stage(Versioned::LIVE); $obj = new VersionedTest_SingleStage(array('Name' => 'MyObj')); $obj->write(); $this->assertNotNull( @@ -617,14 +713,14 @@ class VersionedTest extends SapphireTest { $originalMode = Versioned::get_reading_mode(); // Generate staging record and retrieve it from stage in live mode - Versioned::reading_stage('Stage'); + Versioned::set_stage(Versioned::DRAFT); $obj = new VersionedTest_Subclass(); $obj->Name = 'bob'; $obj->ExtraField = 'Field Value'; $obj->write(); $objID = $obj->ID; $filter = sprintf('"VersionedTest_DataObject"."ID" = \'%d\'', Convert::raw2sql($objID)); - Versioned::reading_stage('Live'); + Versioned::set_stage(Versioned::LIVE); // Check fields are unloaded prior to access $objLazy = Versioned::get_one_by_stage('VersionedTest_DataObject', 'Stage', $filter, false); @@ -831,7 +927,7 @@ class VersionedTest extends SapphireTest { // Test that all (and only) public pages are viewable in stage mode Session::clear("loggedInAs"); - Versioned::reading_stage('Stage'); + Versioned::set_stage(Versioned::DRAFT); $public1 = Versioned::get_one_by_stage('VersionedTest_PublicStage', 'Stage', array('"ID"' => $public1ID)); $public2 = Versioned::get_one_by_stage('VersionedTest_PublicViaExtension', 'Stage', array('"ID"' => $public2ID)); $private = Versioned::get_one_by_stage('VersionedTest_DataObject', 'Stage', array('"ID"' => $privateID)); @@ -841,7 +937,7 @@ class VersionedTest extends SapphireTest { $this->assertFalse($private->canView()); // Adjusting the current stage should not allow objects loaded in stage to be viewable - Versioned::reading_stage('Live'); + Versioned::set_stage(Versioned::LIVE); $this->assertTrue($public1->canView()); $this->assertTrue($public2->canView()); $this->assertFalse($private->canView()); @@ -853,31 +949,30 @@ class VersionedTest extends SapphireTest { $this->assertTrue($privateLive->canView()); // But if the private version becomes different to the live version, it's once again disallowed - Versioned::reading_stage('Stage'); + Versioned::set_stage(Versioned::DRAFT); $private->Title = 'Secret Title'; $private->write(); $this->assertFalse($private->canView()); $this->assertTrue($privateLive->canView()); // And likewise, viewing a live page (when mode is draft) should be ok - Versioned::reading_stage('Stage'); + Versioned::set_stage(Versioned::DRAFT); $this->assertFalse($private->canView()); $this->assertTrue($privateLive->canView()); // Logging in as admin should allow all permissions $this->logInWithPermission('ADMIN'); - Versioned::reading_stage('Stage'); + Versioned::set_stage(Versioned::DRAFT); $this->assertTrue($public1->canView()); $this->assertTrue($public2->canView()); $this->assertTrue($private->canView()); } - public function testCanViewStage() { $public = $this->objFromFixture('VersionedTest_PublicStage', 'public1'); $private = $this->objFromFixture('VersionedTest_DataObject', 'page1'); Session::clear("loggedInAs"); - Versioned::reading_stage('Stage'); + Versioned::set_stage(Versioned::DRAFT); // Test that all (and only) public pages are viewable in stage mode // Unpublished records are not viewable in live regardless of permissions @@ -909,6 +1004,10 @@ class VersionedTest extends SapphireTest { /** + * @method VersionedTest_DataObject Parent() + * @method HasManyList Children() + * @method ManyManyList Related() + * * @package framework * @subpackage tests */ @@ -916,19 +1015,23 @@ class VersionedTest_DataObject extends DataObject implements TestOnly { private static $db = array( "Name" => "Varchar", 'Title' => 'Varchar', - 'Content' => 'HTMLText' + 'Content' => 'HTMLText', ); private static $extensions = array( - "Versioned('Stage', 'Live')" + "Versioned('Stage', 'Live')", ); private static $has_one = array( - 'Parent' => 'VersionedTest_DataObject' + 'Parent' => 'VersionedTest_DataObject', + ); + + private static $has_many = array( + 'Children' => 'VersionedTest_DataObject', ); private static $many_many = array( - 'Related' => 'VersionedTest_RelatedWithoutVersion' + 'Related' => 'VersionedTest_RelatedWithoutVersion', ); @@ -948,7 +1051,7 @@ class VersionedTest_WithIndexes extends DataObject implements TestOnly { 'UniqS' => 'Int', ); private static $extensions = array( - "Versioned('Stage', 'Live')" + "Versioned" ); private static $indexes = array( 'UniqS_idx' => 'unique ("UniqS")', @@ -1007,7 +1110,7 @@ class VersionedTest_SingleStage extends DataObject implements TestOnly { ); private static $extensions = array( - 'Versioned("Stage")' + 'Versioned("Versioned")' ); } @@ -1020,7 +1123,7 @@ class VersionedTest_PublicStage extends DataObject implements TestOnly { ); private static $extensions = array( - "Versioned('Stage', 'Live')" + "Versioned" ); public function canView($member = null) { @@ -1055,7 +1158,7 @@ class VersionedTest_PublicViaExtension extends DataObject implements TestOnly { ); private static $extensions = array( - "Versioned('Stage', 'Live')", + "Versioned", "VersionedTest_PublicExtension" ); } diff --git a/tests/view/SSViewerCacheBlockTest.php b/tests/view/SSViewerCacheBlockTest.php index 57c4243e6..2b916061f 100644 --- a/tests/view/SSViewerCacheBlockTest.php +++ b/tests/view/SSViewerCacheBlockTest.php @@ -145,11 +145,11 @@ class SSViewerCacheBlockTest extends SapphireTest { public function testVersionedCache() { - $origStage = Versioned::current_stage(); + $origStage = Versioned::get_stage(); // Run without caching in stage to prove data is uncached $this->_reset(false); - Versioned::reading_stage("Stage"); + Versioned::set_stage(Versioned::DRAFT); $data = new SSViewerCacheBlockTest_VersionedModel(); $data->setEntropy('default'); $this->assertEquals( @@ -165,7 +165,7 @@ class SSViewerCacheBlockTest extends SapphireTest { // Run without caching in live to prove data is uncached $this->_reset(false); - Versioned::reading_stage("Live"); + Versioned::set_stage(Versioned::LIVE); $data = new SSViewerCacheBlockTest_VersionedModel(); $data->setEntropy('default'); $this->assertEquals( @@ -183,7 +183,7 @@ class SSViewerCacheBlockTest extends SapphireTest { // changing the versioned reading mode doesn't cache between modes, but it does // within them $this->_reset(true); - Versioned::reading_stage("Stage"); + Versioned::set_stage(Versioned::DRAFT); $data = new SSViewerCacheBlockTest_VersionedModel(); $data->setEntropy('default'); $this->assertEquals( @@ -197,7 +197,7 @@ class SSViewerCacheBlockTest extends SapphireTest { $this->_runtemplate('<% cached %>$Inspect<% end_cached %>', $data) ); - Versioned::reading_stage('Live'); + Versioned::set_stage(Versioned::LIVE); $data = new SSViewerCacheBlockTest_VersionedModel(); $data->setEntropy('first'); $this->assertEquals( @@ -211,7 +211,7 @@ class SSViewerCacheBlockTest extends SapphireTest { $this->_runtemplate('<% cached %>$Inspect<% end_cached %>', $data) ); - Versioned::reading_stage($origStage); + Versioned::set_stage($origStage); } /**