silverstripe-framework/model/versioning/Versioned.php

2410 lines
68 KiB
PHP
Raw Normal View History

<?php
// namespace SilverStripe\Framework\Model\Versioning
/**
2014-08-15 08:53:05 +02:00
* The Versioned extension allows your DataObjects to have several versions,
* allowing you to rollback changes and view history. An example of this is
* the pages used in the CMS.
*
* @property int $Version
* @property DataObject|Versioned $owner
*
* @package framework
* @subpackage model
*/
class Versioned extends DataExtension implements TemplateGlobalProvider {
/**
* Versioning mode for this object.
* Note: Not related to the current versioning mode in the state / session
* Will be one of 'StagedVersioned' or 'Versioned';
*
* @var string
*/
protected $mode;
2014-08-15 08:53:05 +02:00
/**
* The default reading mode
*/
const DEFAULT_MODE = 'Stage.Live';
2014-08-15 08:53:05 +02:00
/**
* Constructor arg to specify that staging is active on this record.
* 'Staging' implies that 'Versioning' is also enabled.
*/
const STAGEDVERSIONED = 'StagedVersioned';
2014-08-15 08:53:05 +02:00
/**
* 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';
2014-08-15 08:53:05 +02:00
/**
* 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.
* @var string
*/
public $migratingVersion;
2014-08-15 08:53:05 +02:00
/**
* A cache used by get_versionnumber_by_stage().
* Clear through {@link flushCache()}.
2014-08-15 08:53:05 +02:00
*
* @var array
*/
protected static $cache_versionnumber;
2014-08-15 08:53:05 +02:00
/**
* Current reading mode
*
* @var string
*/
protected static $reading_mode = null;
/**
* @var Boolean Flag which is temporarily changed during the write() process
* to influence augmentWrite() behaviour. If set to TRUE, no new version will be created
* for the following write. Needs to be public as other classes introspect this state
* during the write process in order to adapt to this versioning behaviour.
*/
public $_nextWriteWithoutVersion = false;
/**
* Additional database columns for the new
* "_versions" table. Used in {@link augmentDatabase()}
* and all Versioned calls extending or creating
* SELECT statements.
2014-08-15 08:53:05 +02:00
*
* @var array $db_for_versions_table
*/
private static $db_for_versions_table = array(
"RecordID" => "Int",
"Version" => "Int",
"WasPublished" => "Boolean",
"AuthorID" => "Int",
"PublisherID" => "Int"
);
2014-08-15 08:53:05 +02:00
/**
* @var array
*/
private static $db = array(
'Version' => 'Int'
);
/**
* Used to enable or disable the prepopulation of the version number cache.
* Defaults to true.
*
* @config
* @var boolean
*/
private static $prepopulate_versionnumber_cache = true;
/**
* Keep track of the archive tables that have been created.
*
* @config
* @var array
*/
private static $archive_tables = array();
/**
* Additional database indexes for the new
* "_versions" table. Used in {@link augmentDatabase()}.
2014-08-15 08:53:05 +02:00
*
* @var array $indexes_for_versions_table
*/
private static $indexes_for_versions_table = array(
'RecordID_Version' => '("RecordID","Version")',
'RecordID' => true,
'Version' => true,
'AuthorID' => true,
'PublisherID' => true,
);
2016-02-23 21:53:52 +01:00
/**
* An array of DataObject extensions that may require versioning for extra tables
* The array value is a set of suffixes to form these table names, assuming a preceding '_'.
2014-08-15 08:53:05 +02:00
* E.g. if Extension1 creates a new table 'Class_suffix1'
* and Extension2 the tables 'Class_suffix2' and 'Class_suffix3':
*
* $versionableExtensions = array(
* 'Extension1' => 'suffix1',
* 'Extension2' => array('suffix2', 'suffix3'),
* );
2014-08-15 08:53:05 +02:00
*
2016-02-23 21:53:52 +01:00
* This can also be manipulated by updating the current loaded config
*
* SiteTree:
* versionableExtensions:
* - Extension1:
* - suffix1
* - suffix2
* - Extension2:
* - suffix1
* - suffix2
*
* or programatically:
*
* Config::inst()->update($this->owner->class, 'versionableExtensions',
* array('Extension1' => 'suffix1', 'Extension2' => array('suffix2', 'suffix3')));
*
*
* Make sure your extension has a static $enabled-property that determines if it is
* processed by Versioned.
*
* @config
* @var array
*/
private static $versionableExtensions = array('Translatable' => 'lang');
/**
* Permissions necessary to view records outside of the live stage (e.g. archive / draft stage).
*
* @config
* @var array
*/
private static $non_live_permissions = array('CMS_ACCESS_LeftAndMain', 'CMS_ACCESS_CMSMain', 'VIEW_DRAFT_CONTENT');
/**
* List of relationships on this object that are "owned" by this object.
* Owership in the context of versioned objects is a relationship where
* the publishing of owning objects requires the publishing of owned objects.
*
* E.g. A page owns a set of banners, as in order for the page to be published, all
* banners on this page must also be published for it to be visible.
*
* Typically any object and its owned objects should be visible in the same edit view.
* E.g. a page and {@see GridField} of banners.
*
* Page hierarchy is typically not considered an ownership relationship.
*
* Ownership is recursive; If A owns B and B owns C then A owns C.
*
* @config
* @var array List of has_many or many_many relationships owned by this object.
*/
private static $owns = array();
/**
* Opposing relationship to owns config; Represents the objects which
* own the current object.
*
* @var array
*/
private static $owned_by = array();
/**
* Reset static configuration variables to their default values.
*/
public static function reset() {
self::$reading_mode = '';
Session::clear('readingMode');
}
2014-08-15 08:53:05 +02:00
/**
2014-08-15 08:53:05 +02:00
* Amend freshly created DataQuery objects with versioned-specific
* information.
*
* @param SQLSelect
* @param DataQuery
*/
public function augmentDataQueryCreation(SQLSelect &$query, DataQuery &$dataQuery) {
$parts = explode('.', Versioned::get_reading_mode());
if($parts[0] == 'Archive') {
$dataQuery->setQueryParam('Versioned.mode', 'archive');
$dataQuery->setQueryParam('Versioned.date', $parts[1]);
} 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;
}
2014-08-15 08:53:05 +02:00
public function updateInheritableQueryParams(&$params) {
// 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
*/
public function augmentSQL(SQLSelect $query, DataQuery $dataQuery = null) {
if(!$dataQuery || !$dataQuery->getQueryParam('Versioned.mode')) {
return;
}
2013-01-24 07:56:02 +01:00
$baseTable = ClassInfo::baseDataClass($dataQuery->dataClass());
2014-08-15 08:53:05 +02:00
$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;
}
$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':
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');
$this->augmentSQL($query, $dataQuery);
$dataQuery->setQueryParam('Versioned.mode', 'stage_unique');
// 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([static::DRAFT, static::LIVE] as $excluding) {
if ($excluding == $stage) {
continue;
}
$tempName = 'ExclusionarySource_'.$excluding;
$excludingTable = $baseTable . ($excluding && $excluding != static::DRAFT ? "_$excluding" : '');
$query->addWhere('"'.$baseTable.'"."ID" NOT IN (SELECT "ID" FROM "'.$tempName.'")');
$query->renameTable($tempName, $excludingTable);
}
break;
2014-08-15 08:53:05 +02:00
// 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;
}
if($alias != $baseTable) {
// Make sure join includes version as well
$query->setJoinFilter(
$alias,
"\"{$alias}_versions\".\"RecordID\" = \"{$baseTable}_versions\".\"RecordID\""
. " AND \"{$alias}_versions\".\"Version\" = \"{$baseTable}_versions\".\"Version\""
);
}
$query->renameTable($alias, $alias . '_versions');
}
2014-08-15 08:53:05 +02:00
// Add all <basetable>_versions columns
foreach(Config::inst()->get('Versioned', 'db_for_versions_table') as $name => $type) {
$query->selectField(sprintf('"%s_versions"."%s"', $baseTable, $name), $name);
}
2014-08-15 08:53:05 +02:00
// Alias the record ID as the row ID, and ensure ID filters are aliased correctly
$query->selectField("\"{$baseTable}_versions\".\"RecordID\"", "ID");
$query->replaceText("\"{$baseTable}_versions\".\"ID\"", "\"{$baseTable}_versions\".\"RecordID\"");
2014-08-15 08:53:05 +02:00
// However, if doing count, undo rewrite of "ID" column
$query->replaceText(
"count(DISTINCT \"{$baseTable}_versions\".\"RecordID\")",
"count(DISTINCT \"{$baseTable}_versions\".\"ID\")"
);
2014-08-15 08:53:05 +02:00
// 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:
throw new InvalidArgumentException("Bad value for query parameter Versioned.mode: "
. $dataQuery->getQueryParam('Versioned.mode'));
}
}
/**
* Determine if the given versioned table is a part of the sub-tree of the current dataobject
* This helps prevent rewriting of other tables that get joined in, in particular, many_many tables
*
* @param string $table
* @return bool True if this table should be versioned
*/
protected function isTableVersioned($table) {
if(!class_exists($table)) {
return false;
}
$baseClass = ClassInfo::baseDataClass($this->owner);
return is_a($table, $baseClass, true);
}
/**
* For lazy loaded fields requiring extra sql manipulation, ie versioning.
*
* @param SQLSelect $query
* @param DataQuery $dataQuery
* @param DataObject $dataObject
*/
public function augmentLoadLazyFields(SQLSelect &$query, DataQuery &$dataQuery = null, $dataObject) {
2014-08-15 08:53:05 +02:00
// The VersionedMode local variable ensures that this decorator only applies to
// queries that have originated from the Versioned object, and have the Versioned
// metadata set on the query object. This prevents regular queries from
// accidentally querying the *_versions tables.
$versionedMode = $dataObject->getSourceQueryParam('Versioned.mode');
$dataClass = ClassInfo::baseDataClass($dataQuery->dataClass());
$modesToAllowVersioning = array('all_versions', 'latest_versions', 'archive', 'version');
if(
!empty($dataObject->Version) &&
(!empty($versionedMode) && in_array($versionedMode,$modesToAllowVersioning))
) {
// This will ensure that augmentSQL will select only the same version as the owner,
// regardless of how this object was initially selected
$dataQuery->where([
"\"$dataClass\".\"Version\"" => $dataObject->Version
]);
$dataQuery->setQueryParam('Versioned.mode', 'all_versions');
}
}
2014-08-15 08:53:05 +02:00
/**
* Called by {@link SapphireTest} when the database is reset.
*
* @todo Reduce the coupling between this and SapphireTest, somehow.
*/
public static function on_db_reset() {
// Drop all temporary tables
$db = DB::get_conn();
foreach(static::$archive_tables as $tableName) {
if(method_exists($db, 'dropTable')) $db->dropTable($tableName);
else $db->query("DROP TABLE \"$tableName\"");
}
// Remove references to them
static::$archive_tables = array();
}
2014-08-15 08:53:05 +02:00
public function augmentDatabase() {
$owner = $this->owner;
$classTable = $owner->class;
2014-08-15 08:53:05 +02:00
$isRootClass = ($owner->class == ClassInfo::baseDataClass($owner->class));
// Build a list of suffixes whose tables need versioning
$allSuffixes = array();
$versionableExtensions = $owner->config()->versionableExtensions;
2016-02-23 21:53:52 +01:00
if(count($versionableExtensions)){
foreach ($versionableExtensions as $versionableExtension => $suffixes) {
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,'');
foreach ($allSuffixes as $key => $suffix) {
// check that this is a valid suffix
if (!is_int($key)) continue;
2014-08-15 08:53:05 +02:00
if ($suffix) $table = "{$classTable}_$suffix";
else $table = $classTable;
$fields = DataObject::database_fields($owner->class);
unset($fields['ID']);
if($fields) {
$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($owner);
$fields = $ext->fieldsInExtraTables($suffix);
$ext->clearOwner();
$indexes = $fields['indexes'];
$fields = $fields['db'];
}
2014-08-15 08:53:05 +02:00
// Create tables for other stages
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);
DB::require_table($liveTable, $fields, $indexes, false, $options);
}
2014-08-15 08:53:05 +02:00
if($isRootClass) {
// Create table for all versions
$versionFields = array_merge(
Config::inst()->get('Versioned', 'db_for_versions_table'),
(array)$fields
);
2014-08-15 08:53:05 +02:00
$versionIndexes = array_merge(
Config::inst()->get('Versioned', 'indexes_for_versions_table'),
(array)$indexes
);
} else {
// Create fields for any tables of subclasses
$versionFields = array_merge(
array(
"RecordID" => "Int",
"Version" => "Int",
),
(array)$fields
);
2014-08-15 08:53:05 +02:00
//Unique indexes will not work on versioned tables, so we'll convert them to standard indexes:
$indexes = $this->uniqueToIndex($indexes);
$versionIndexes = array_merge(
array(
'RecordID_Version' => array('type' => 'unique', 'value' => '"RecordID","Version"'),
'RecordID' => true,
'Version' => true,
),
(array)$indexes
);
}
2014-08-15 08:53:05 +02:00
if(DB::get_schema()->hasTable("{$table}_versions")) {
// Fix data that lacks the uniqueness constraint (since this was added later and
// bugs meant that the constraint was validated)
2014-08-15 08:53:05 +02:00
$duplications = DB::query("SELECT MIN(\"ID\") AS \"ID\", \"RecordID\", \"Version\"
FROM \"{$table}_versions\" GROUP BY \"RecordID\", \"Version\"
HAVING COUNT(*) > 1");
2014-08-15 08:53:05 +02:00
foreach($duplications as $dup) {
DB::alteration_message("Removing {$table}_versions duplicate data for "
."{$dup['RecordID']}/{$dup['Version']}" ,"deleted");
DB::prepared_query(
"DELETE FROM \"{$table}_versions\" WHERE \"RecordID\" = ?
AND \"Version\" = ? AND \"ID\" != ?",
array($dup['RecordID'], $dup['Version'], $dup['ID'])
);
}
2014-08-15 08:53:05 +02:00
// Remove junk which has no data in parent classes. Only needs to run the following
2014-08-15 08:53:05 +02:00
// when versioned data is spread over multiple tables
if(!$isRootClass && ($versionedTables = ClassInfo::dataClassesFor($table))) {
2014-08-15 08:53:05 +02:00
foreach($versionedTables as $child) {
if($table === $child) break; // only need subclasses
// Select all orphaned version records
$orphanedQuery = SQLSelect::create()
->selectField("\"{$table}_versions\".\"ID\"")
->setFrom("\"{$table}_versions\"");
// If we have a parent table limit orphaned records
// to only those that exist in this
if(DB::get_schema()->hasTable("{$child}_versions")) {
$orphanedQuery
->addLeftJoin(
"{$child}_versions",
"\"{$child}_versions\".\"RecordID\" = \"{$table}_versions\".\"RecordID\"
AND \"{$child}_versions\".\"Version\" = \"{$table}_versions\".\"Version\""
)
->addWhere("\"{$child}_versions\".\"ID\" IS NULL");
}
$count = $orphanedQuery->count();
if($count > 0) {
DB::alteration_message("Removing {$count} orphaned versioned records", "deleted");
$ids = $orphanedQuery->execute()->column();
foreach($ids as $id) {
DB::prepared_query(
"DELETE FROM \"{$table}_versions\" WHERE \"ID\" = ?",
array($id)
);
}
}
}
}
}
DB::require_table("{$table}_versions", $versionFields, $versionIndexes, true, $options);
} else {
DB::dont_require_table("{$table}_versions");
if($this->hasStages()) {
$liveTable = $this->stageTable($table, static::LIVE);
DB::dont_require_table($liveTable);
}
}
}
}
2014-08-15 08:53:05 +02:00
/**
* Helper for augmentDatabase() to find unique indexes and convert them to non-unique
2014-08-15 08:53:05 +02:00
*
* @param array $indexes The indexes to convert
* @return array $indexes
*/
private function uniqueToIndex($indexes) {
$unique_regex = '/unique/i';
$results = array();
foreach ($indexes as $key => $index) {
$results[$key] = $index;
// support string descriptors
if (is_string($index)) {
if (preg_match($unique_regex, $index)) {
$results[$key] = preg_replace($unique_regex, 'index', $index);
}
}
// canonical, array-based descriptors
elseif (is_array($index)) {
if (strtolower($index['type']) == 'unique') {
$results[$key]['type'] = 'index';
}
}
}
return $results;
}
2014-08-28 01:29:38 +02:00
/**
* Generates a ($table)_version DB manipulation and injects it into the current $manipulation
*
* @param array $manipulation Source manipulation data
* @param string $table Name of table
* @param int $recordID ID of record to version
*/
protected function augmentWriteVersioned(&$manipulation, $table, $recordID) {
$baseDataClass = ClassInfo::baseDataClass($table);
2014-08-28 01:29:38 +02:00
// Set up a new entry in (table)_versions
$newManipulation = array(
"command" => "insert",
"fields" => isset($manipulation[$table]['fields']) ? $manipulation[$table]['fields'] : null
);
// Add any extra, unchanged fields to the version record.
$data = DB::prepared_query("SELECT * FROM \"$table\" WHERE \"ID\" = ?", array($recordID))->record();
if ($data) {
$fields = DataObject::database_fields($table);
if (is_array($fields)) {
$data = array_intersect_key($data, $fields);
foreach ($data as $k => $v) {
if (!isset($newManipulation['fields'][$k])) {
$newManipulation['fields'][$k] = $v;
}
}
2014-08-28 01:29:38 +02:00
}
}
// Ensure that the ID is instead written to the RecordID field
$newManipulation['fields']['RecordID'] = $recordID;
unset($newManipulation['fields']['ID']);
// Generate next version ID to use
$nextVersion = 0;
if($recordID) {
$nextVersion = DB::prepared_query("SELECT MAX(\"Version\") + 1
FROM \"{$baseDataClass}_versions\" WHERE \"RecordID\" = ?",
array($recordID)
)->value();
}
$nextVersion = $nextVersion ?: 1;
if($table === $baseDataClass) {
// Write AuthorID for baseclass
2014-08-28 01:29:38 +02:00
$userID = (Member::currentUser()) ? Member::currentUser()->ID : 0;
$newManipulation['fields']['AuthorID'] = $userID;
// Update main table version if not previously known
$manipulation[$table]['fields']['Version'] = $nextVersion;
2014-08-28 01:29:38 +02:00
}
// Update _versions table manipulation
$newManipulation['fields']['Version'] = $nextVersion;
2014-08-28 01:29:38 +02:00
$manipulation["{$table}_versions"] = $newManipulation;
}
/**
* Rewrite the given manipulation to update the selected (non-default) stage
*
* @param array $manipulation Source manipulation data
* @param string $table Name of table
* @param int $recordID ID of record to version
*/
protected function augmentWriteStaged(&$manipulation, $table, $recordID) {
// If the record has already been inserted in the (table), get rid of it.
if($manipulation[$table]['command'] == 'insert') {
DB::prepared_query(
"DELETE FROM \"{$table}\" WHERE \"ID\" = ?",
array($recordID)
);
}
$newTable = $this->stageTable($table, Versioned::get_stage());
2014-08-28 01:29:38 +02:00
$manipulation[$newTable] = $manipulation[$table];
unset($manipulation[$table]);
}
public function augmentWrite(&$manipulation) {
// get Version number from base data table on write
$version = null;
$owner = $this->owner;
$baseDataClass = ClassInfo::baseDataClass($owner->class);
if(isset($manipulation[$baseDataClass]['fields'])) {
if ($this->migratingVersion) {
$manipulation[$baseDataClass]['fields']['Version'] = $this->migratingVersion;
}
if (isset($manipulation[$baseDataClass]['fields']['Version'])) {
$version = $manipulation[$baseDataClass]['fields']['Version'];
}
}
// Update all tables
$tables = array_keys($manipulation);
foreach($tables as $table) {
2014-08-15 08:53:05 +02:00
// Make sure that the augmented write is being applied to a table that can be versioned
if( !$this->canBeVersioned($table) ) {
unset($manipulation[$table]);
continue;
}
2014-08-28 01:29:38 +02:00
// Get ID field
$id = $manipulation[$table]['id']
? $manipulation[$table]['id']
: $manipulation[$table]['fields']['ID'];
if(!$id) {
user_error("Couldn't find ID in " . var_export($manipulation[$table], true), E_USER_ERROR);
}
2014-08-15 08:53:05 +02:00
if($version < 0 || $this->_nextWriteWithoutVersion) {
// Putting a Version of -1 is a signal to leave the version table alone, despite their being no version
unset($manipulation[$table]['fields']['Version']);
} elseif(empty($version)) {
// If we haven't got a version #, then we're creating a new version.
// Otherwise, we're just copying a version to another table
2014-08-28 01:29:38 +02:00
$this->augmentWriteVersioned($manipulation, $table, $id);
}
2014-08-15 08:53:05 +02:00
// Remove "Version" column from subclasses of baseDataClass
if(!$this->hasVersionField($table)) {
unset($manipulation[$table]['fields']['Version']);
}
// Grab a version number - it should be the same across all tables.
if(isset($manipulation[$table]['fields']['Version'])) {
$thisVersion = $manipulation[$table]['fields']['Version'];
}
2014-08-15 08:53:05 +02:00
// If we're editing Live, then use (table)_Live instead of (table)
if($this->hasStages() && static::get_stage() === static::LIVE) {
2014-08-28 01:29:38 +02:00
$this->augmentWriteStaged($manipulation, $table, $id);
}
}
2014-08-15 08:53:05 +02:00
// Clear the migration flag
if($this->migratingVersion) {
$this->migrateVersion(null);
}
2014-08-15 08:53:05 +02:00
// Add the new version # back into the data object, for accessing
// after this write
if(isset($thisVersion)) {
$owner->Version = str_replace("'","", $thisVersion);
}
}
/**
* Perform a write without affecting the version table.
* On objects without versioning.
*
* @return int The ID of the record
*/
public function writeWithoutVersion() {
$this->_nextWriteWithoutVersion = true;
return $this->owner->write();
}
/**
*
*/
public function onAfterWrite() {
$this->_nextWriteWithoutVersion = false;
}
/**
2014-08-15 08:53:05 +02:00
* If a write was skipped, then we need to ensure that we don't leave a
* migrateVersion() value lying around for the next write.
*/
public function onAfterSkippedWrite() {
$this->migrateVersion(null);
}
2014-08-15 08:53:05 +02:00
/**
* Find all objects owned by the current object.
* Note that objects will only be searched in the same stage as the given record.
*
* @param bool $recursive True if recursive
* @param ArrayList $list Optional list to add items to
* @return ArrayList list of objects
*/
public function findOwned($recursive = true, $list = null)
{
// Find objects in these relationships
return $this->findRelatedObjects('owns', $recursive, $list);
}
/**
* Find objects which own this object.
* Note that objects will only be searched in the same stage as the given record.
*
* @param bool $recursive True if recursive
* @param ArrayList $list Optional list to add items to
* @return ArrayList list of objects
*/
public function findOwners($recursive = true, $list = null)
{
// Find objects in these relationships
return $this->findRelatedObjects('owned_by', $recursive, $list);
}
/**
* Find objects in the given relationships, merging them into the given list
*
* @param array $source Config property to extract relationships from
* @param bool $recursive True if recursive
* @param ArrayList $list Optional list to add items to
* @return ArrayList The list
*/
public function findRelatedObjects($source, $recursive = true, $list = null)
{
if (!$list) {
$list = new ArrayList();
}
// Skip search for unsaved records
$owner = $this->owner;
if(!$owner->isInDB()) {
return $list;
}
$relationships = $owner->config()->{$source};
foreach($relationships as $relationship) {
// Warn if invalid config
if(!$owner->hasMethod($relationship)) {
trigger_error(sprintf(
"Invalid %s config value \"%s\" on object on class \"%s\"",
$source,
$relationship,
$owner->class
), E_USER_WARNING);
continue;
}
// Inspect value of this relationship
$items = $owner->{$relationship}();
if(!$items) {
continue;
}
if($items instanceof DataObject) {
$items = array($items);
}
/** @var Versioned|DataObject $item */
foreach($items as $item) {
// Identify item
$itemKey = $item->class . '/' . $item->ID;
// Skip unsaved, unversioned, or already checked objects
if(!$item->isInDB() || !$item->has_extension('Versioned') || isset($list[$itemKey])) {
continue;
}
// Save record
$list[$itemKey] = $item;
if($recursive) {
$item->findRelatedObjects($source, true, $list);
};
}
}
return $list;
}
/**
* This function should return true if the current user can publish this record.
* It can be overloaded to customise the security model for an application.
*
* Denies permission if any of the following conditions is true:
* - canPublish() on any extension returns false
* - canEdit() returns false
*
* @param Member $member
* @return bool True if the current user can publish this record.
*/
public function canPublish($member = null) {
// Skip if invoked by extendedCan()
if(func_num_args() > 4) {
return null;
}
if(!$member) {
$member = Member::currentUser();
}
if(Permission::checkMember($member, "ADMIN")) {
return true;
}
// Standard mechanism for accepting permission changes from extensions
$owner = $this->owner;
$extended = $owner->extendedCan('canPublish', $member);
if($extended !== null) {
return $extended;
}
// Default to relying on edit permission
return $owner->canEdit($member);
}
/**
* Check if the current user can delete this record from live
*
* @param null $member
* @return mixed
*/
public function canUnpublish($member = null) {
// Skip if invoked by extendedCan()
if(func_num_args() > 4) {
return null;
}
if(!$member) {
$member = Member::currentUser();
}
if(Permission::checkMember($member, "ADMIN")) {
return true;
}
// Standard mechanism for accepting permission changes from extensions
$owner = $this->owner;
$extended = $owner->extendedCan('canUnpublish', $member);
if($extended !== null) {
return $extended;
}
// Default to relying on canPublish
return $owner->canPublish($member);
}
/**
* Check if the current user is allowed to archive this record.
* If extended, ensure that both canDelete and canUnpublish are extended also
*
* @param Member $member
* @return bool
*/
public function canArchive($member = null) {
// Skip if invoked by extendedCan()
if(func_num_args() > 4) {
return null;
}
if(!$member) {
$member = Member::currentUser();
}
if(Permission::checkMember($member, "ADMIN")) {
return true;
}
// Standard mechanism for accepting permission changes from extensions
$owner = $this->owner;
$extended = $owner->extendedCan('canArchive', $member);
if($extended !== null) {
return $extended;
}
// Check if this record can be deleted from stage
if(!$owner->canDelete($member)) {
return false;
}
// Check if we can delete from live
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.
*
* @param Member $member
* @return bool|null
*/
public function canView($member = null) {
// Invoke default version-gnostic canView
if ($this->owner->canViewVersioned($member) === false) {
return false;
}
}
/**
* Determine if there are any additional restrictions on this object for the given reading version.
*
* Override this in a subclass to customise any additional effect that Versioned applies to canView.
*
* This is expected to be called by canView, and thus is only responsible for denying access if
* the default canView would otherwise ALLOW access. Thus it should not be called in isolation
* as an authoritative permission check.
*
* This has the following extension points:
* - canViewDraft is invoked if Mode = stage and Stage = stage
* - canViewArchived is invoked if Mode = archive
*
* @param Member $member
* @return bool False is returned if the current viewing mode denies visibility
*/
public function canViewVersioned($member = null) {
// Bypass when live stage
$owner = $this->owner;
$mode = $owner->getSourceQueryParam("Versioned.mode");
$stage = $owner->getSourceQueryParam("Versioned.stage");
if ($mode === 'stage' && $stage === static::LIVE) {
return true;
}
// Bypass if site is unsecured
if (Session::get('unsecuredDraftSite')) {
return true;
}
// Bypass if record doesn't have a live stage
if(!$this->hasStages()) {
return true;
}
// If we weren't definitely loaded from live, and we can't view non-live content, we need to
// check to make sure this version is the live version and so can be viewed
$latestVersion = Versioned::get_versionnumber_by_stage($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 = $owner->extendedCan('canViewNonLive', $member);
if($extended !== null) {
return (bool)$extended;
}
// Fall back to default permission check
$permissions = Config::inst()->get($owner->class, 'non_live_permissions', Config::FIRST_SET);
$check = Permission::checkMember($member, $permissions);
return (bool)$check;
}
/**
* Determines canView permissions for the latest version of this object on a specific stage.
* Usually the stage is read from {@link Versioned::current_stage()}.
*
* This method should be invoked by user code to check if a record is visible in the given stage.
*
* This method should not be called via ->extend('canViewStage'), but rather should be
* overridden in the extended class.
*
* @param string $stage
* @param Member $member
* @return bool
*/
public function canViewStage($stage = 'Live', $member = null) {
$oldMode = Versioned::get_reading_mode();
Versioned::set_stage($stage);
$owner = $this->owner;
$versionFromStage = DataObject::get($owner->class)->byID($owner->ID);
Versioned::set_reading_mode($oldMode);
return $versionFromStage ? $versionFromStage->canView($member) : false;
}
/**
2014-08-15 08:53:05 +02:00
* Determine if a table is supporting the Versioned extensions (e.g.
* $table_versions does exists).
*
* @param string $table Table name
* @return boolean
*/
public function canBeVersioned($table) {
2014-08-15 08:53:05 +02:00
return ClassInfo::exists($table)
&& is_subclass_of($table, 'DataObject')
&& DataObject::has_own_table($table);
}
2014-08-15 08:53:05 +02:00
/**
* Check if a certain table has the 'Version' field.
*
* @param string $table Table name
*
* @return boolean Returns false if the field isn't in the table, true otherwise
*/
public function hasVersionField($table) {
// Strip "_Live" from end of table
$live = static::LIVE;
if($this->hasStages() && preg_match("/^(?<table>.*)_{$live}$/", $table, $matches)) {
$table = $matches['table'];
}
// Base table has version field
return $table === ClassInfo::baseDataClass($table);
}
/**
* @param string $table
*
* @return string
*/
public function extendWithSuffix($table) {
$owner = $this->owner;
$versionableExtensions = $owner->config()->versionableExtensions;
2016-02-23 21:53:52 +01:00
if(count($versionableExtensions)){
foreach ($versionableExtensions as $versionableExtension => $suffixes) {
if ($owner->hasExtension($versionableExtension)) {
$ext = $owner->getExtensionInstance($versionableExtension);
$ext->setOwner($owner);
$table = $ext->extendWithSuffix($table);
$ext->clearOwner();
}
}
}
return $table;
}
/**
* Get the latest published DataObject.
*
* @return DataObject
*/
public function latestPublished() {
// Get the root data object class - this will have the version field
$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($owner->ID)
)->value();
}
2014-08-15 08:53:05 +02:00
/**
* Provides a simple doPublish action for Versioned dataobjects
*
* @return bool True if publish was successful
*/
public function doPublish() {
$owner = $this->owner;
if(!$owner->canPublish()) {
return false;
}
$owner->invokeWithExtensions('onBeforePublish');
$owner->write();
$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
*
* @return bool Success
*/
public function doArchive() {
$owner = $this->owner;
if(!$owner->canArchive()) {
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
*/
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 = static::get_stage();
static::set_stage(static::LIVE);
// This way our ID won't be unset
$clone = clone $owner;
$clone->delete();
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.
*
* @param int|string $fromStage Place to copy from. Can be either a stage name or a version number.
* @param string $toStage Place to copy to. Must be a stage name.
* @param bool $createNewVersion Set this to true to create a new version number.
* By default, the existing version number will be copied over.
*/
public function publish($fromStage, $toStage, $createNewVersion = false) {
$owner = $this->owner;
$owner->invokeWithExtensions('onBeforeVersionedPublish', $fromStage, $toStage, $createNewVersion);
2014-08-15 08:53:05 +02:00
$baseClass = ClassInfo::baseDataClass($owner->class);
2014-08-15 08:53:05 +02:00
/** @var Versioned|DataObject $from */
if(is_numeric($fromStage)) {
$from = Versioned::get_version($baseClass, $owner->ID, $fromStage);
} else {
$owner->flushCache();
$from = Versioned::get_one_by_stage($baseClass, $fromStage, array(
"\"{$baseClass}\".\"ID\" = ?" => $owner->ID
));
}
if(!$from) {
throw new InvalidArgumentException("Can't find {$baseClass}#{$owner->ID} in stage {$fromStage}");
}
2014-08-15 08:53:05 +02:00
$from->forceChange();
if($createNewVersion) {
// Clear version to be automatically created on write
$from->Version = null;
} else {
$from->migrateVersion($from->Version);
2014-08-15 08:53:05 +02:00
// 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)
);
}
// Change to new stage, write, and revert state
$oldMode = Versioned::get_reading_mode();
Versioned::set_stage($toStage);
2016-02-25 03:41:32 +01:00
// Migrate stage prior to write
$from->setSourceQueryParam('Versioned.mode', 'stage');
$from->setSourceQueryParam('Versioned.stage', $toStage);
$conn = DB::get_conn();
if(method_exists($conn, 'allowPrimaryKeyEditing')) {
$conn->allowPrimaryKeyEditing($baseClass, true);
$from->write();
$conn->allowPrimaryKeyEditing($baseClass, false);
} else {
$from->write();
}
$from->destroy();
2014-08-15 08:53:05 +02:00
Versioned::set_reading_mode($oldMode);
$owner->invokeWithExtensions('onAfterVersionedPublish', $fromStage, $toStage, $createNewVersion);
}
2014-08-15 08:53:05 +02:00
/**
* Set the migrating version.
*
* @param string $version The version.
*/
public function migrateVersion($version) {
$this->migratingVersion = $version;
}
2014-08-15 08:53:05 +02:00
/**
* Compare two stages to see if they're different.
*
* Only checks the version numbers, not the actual content.
*
* @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);
2014-08-15 08:53:05 +02:00
$owner = $this->owner;
if(!is_numeric($owner->ID)) {
return true;
}
2014-08-15 08:53:05 +02:00
// We test for equality - if one of the versions doesn't exist, this
// will be false.
// TODO: DB Abstraction: if statement here:
$stagesAreEqual = DB::prepared_query(
"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($owner->ID)
)->value();
return !$stagesAreEqual;
}
2014-08-15 08:53:05 +02:00
/**
2014-08-15 08:53:05 +02:00
* @param string $filter
* @param string $sort
* @param string $limit
* @param string $join Deprecated, use leftJoin($table, $joinClause) instead
2014-08-15 08:53:05 +02:00
* @param string $having
* @return ArrayList
*/
public function Versions($filter = "", $sort = "", $limit = "", $join = "", $having = "") {
return $this->allVersions($filter, $sort, $limit, $join, $having);
}
2014-08-15 08:53:05 +02:00
/**
* Return a list of all the versions available.
2014-08-15 08:53:05 +02:00
*
* @param string $filter
* @param string $sort
* @param string $limit
* @param string $join Deprecated, use leftJoin($table, $joinClause) instead
2014-08-15 08:53:05 +02:00
* @param string $having
* @return ArrayList
*/
public function allVersions($filter = "", $sort = "", $limit = "", $join = "", $having = "") {
// Make sure the table names are not postfixed (e.g. _Live)
$oldMode = static::get_reading_mode();
static::set_stage('Stage');
2014-08-15 08:53:05 +02:00
$owner = $this->owner;
$list = DataObject::get(get_class($owner), $filter, $sort, $join, $limit);
if($having) {
$list->having($having);
}
2014-08-15 08:53:05 +02:00
$query = $list->dataQuery()->query();
foreach($query->getFrom() as $table => $tableJoin) {
if(is_string($tableJoin) && $tableJoin[0] == '"') {
$baseTable = str_replace('"','',$tableJoin);
} elseif(is_string($tableJoin) && substr($tableJoin,0,5) != 'INNER') {
$query->setFrom(array(
$table => "LEFT JOIN \"$table\" ON \"$table\".\"RecordID\"=\"{$baseTable}_versions\".\"RecordID\""
. " AND \"$table\".\"Version\" = \"{$baseTable}_versions\".\"Version\""
));
}
$query->renameTable($table, $table . '_versions');
}
2014-08-15 08:53:05 +02:00
// Add all <basetable>_versions columns
foreach(Config::inst()->get('Versioned', 'db_for_versions_table') as $name => $type) {
$query->selectField(sprintf('"%s_versions"."%s"', $baseTable, $name), $name);
}
2014-08-15 08:53:05 +02:00
$query->addWhere(array(
"\"{$baseTable}_versions\".\"RecordID\" = ?" => $owner->ID
));
2014-08-15 08:53:05 +02:00
$query->setOrderBy(($sort) ? $sort
: "\"{$baseTable}_versions\".\"LastEdited\" DESC, \"{$baseTable}_versions\".\"Version\" DESC");
$records = $query->execute();
$versions = new ArrayList();
foreach($records as $record) {
$versions->push(new Versioned_Version($record));
}
2014-08-15 08:53:05 +02:00
Versioned::set_reading_mode($oldMode);
return $versions;
}
2014-08-15 08:53:05 +02:00
/**
* Compare two version, and return the diff between them.
*
* @param string $from The version to compare from.
* @param string $to The version to compare to.
*
* @return DataObject
*/
public function compareVersions($from, $to) {
$owner = $this->owner;
$fromRecord = Versioned::get_version($owner->class, $owner->ID, $from);
$toRecord = Versioned::get_version($owner->class, $owner->ID, $to);
2014-08-15 08:53:05 +02:00
$diff = new DataDifferencer($fromRecord, $toRecord);
return $diff->diffedData();
}
2014-08-15 08:53:05 +02:00
/**
* Return the base table - the class that directly extends DataObject.
*
* @param string $stage
* @return string
*/
public function baseTable($stage = null) {
$baseClass = ClassInfo::baseDataClass($this->owner);
return $this->stageTable($baseClass, $stage);
}
/**
* 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 $class;
}
2014-08-15 08:53:05 +02:00
//-----------------------------------------------------------------------------------------------//
2014-08-15 08:53:05 +02:00
/**
* Determine if the current user is able to set the given site stage / archive
*
* @param SS_HTTPRequest $request
* @return bool
*/
public static function can_choose_site_stage($request) {
// Request is allowed if stage isn't being modified
if((!$request->getVar('stage') || $request->getVar('stage') === static::LIVE)
&& !$request->getVar('archiveDate')
) {
return true;
}
// Check permissions with member ID in session.
$member = Member::currentUser();
$permissions = Config::inst()->get(get_called_class(), 'non_live_permissions');
return $member && Permission::checkMember($member, $permissions);
}
/**
* Choose the stage the site is currently on.
*
2014-08-15 08:53:05 +02:00
* If $_GET['stage'] is set, then it will use that stage, and store it in
* the session.
*
2014-08-15 08:53:05 +02:00
* if $_GET['archiveDate'] is set, it will use that date, and store it in
* the session.
*
2014-08-15 08:53:05 +02:00
* If neither of these are set, it checks the session, otherwise the stage
* is set to 'Live'.
*/
public static function choose_site_stage() {
// Check any pre-existing session mode
$preexistingMode = Session::get('readingMode');
2014-08-15 08:53:05 +02:00
// Determine the reading mode
if(isset($_GET['stage'])) {
$stage = ucfirst(strtolower($_GET['stage']));
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 = static::DEFAULT_MODE;
}
2014-08-15 08:53:05 +02:00
// Save reading mode
Versioned::set_reading_mode($mode);
2014-08-15 08:53:05 +02:00
// Try not to store the mode in the session if not needed
if(($preexistingMode && $preexistingMode !== $mode)
|| (!$preexistingMode && $mode !== static::DEFAULT_MODE)
) {
Session::set('readingMode', $mode);
}
if(!headers_sent() && !Director::is_cli()) {
if(Versioned::get_stage() == 'Live') {
// clear the cookie if it's set
NEW Cookie_Backend for managing cookie state I've decoupled `Cookie` from the actual act of setting and getting cookies. Currently there are a few limitations to how Cookie works that this change mitigates: 0. `Cookie` currently changes the super global `$_COOKIE` when setting to make the state of an application a bit more managable, but this is bad because we shouldn't be modifying super globals 0. One can't actually change the `$cookie_class` once the `Cookie::$inst` has been instantiated 0. One can't test cookies as there is no class that holds the state of the cookies (it's just held in the super global which is reset as part of `Director::test()` 0. One can't tell the origin of a cookie (eg: did the application set it and it needs to be sent, or did we receive it from the browser?) 0. `time()` was used, so testing was made difficult 0. There was no way to get all the cookies at once (without accessing the super global) Todos are on the phpdoc and I'd like to write some tests for the backend as well as update the docs (if there are any) around cookies. DOCS Adding `Cookie` docs Explains basic usage of `Cookie` as well as how the `Cookie_Backend` controls the setting and getting of cookies and manages state of sent vs received cookies Fixing `Cookie` usage `Cookie` is being used inconsistently with the API throughout framework. Either by not using `force_expiry` to expire cookies or setting them to null and then expiring them (which is redundant). NEW `Director::test()` takes `Cookie_Backend` rather than `array` for `$cookies` param
2014-05-04 15:34:58 +02:00
if(Cookie::get('bypassStaticCache')) {
Cookie::force_expiry('bypassStaticCache', null, null, false, true /* httponly */);
}
} else {
// set the cookie if it's cleared
NEW Cookie_Backend for managing cookie state I've decoupled `Cookie` from the actual act of setting and getting cookies. Currently there are a few limitations to how Cookie works that this change mitigates: 0. `Cookie` currently changes the super global `$_COOKIE` when setting to make the state of an application a bit more managable, but this is bad because we shouldn't be modifying super globals 0. One can't actually change the `$cookie_class` once the `Cookie::$inst` has been instantiated 0. One can't test cookies as there is no class that holds the state of the cookies (it's just held in the super global which is reset as part of `Director::test()` 0. One can't tell the origin of a cookie (eg: did the application set it and it needs to be sent, or did we receive it from the browser?) 0. `time()` was used, so testing was made difficult 0. There was no way to get all the cookies at once (without accessing the super global) Todos are on the phpdoc and I'd like to write some tests for the backend as well as update the docs (if there are any) around cookies. DOCS Adding `Cookie` docs Explains basic usage of `Cookie` as well as how the `Cookie_Backend` controls the setting and getting of cookies and manages state of sent vs received cookies Fixing `Cookie` usage `Cookie` is being used inconsistently with the API throughout framework. Either by not using `force_expiry` to expire cookies or setting them to null and then expiring them (which is redundant). NEW `Director::test()` takes `Cookie_Backend` rather than `array` for `$cookies` param
2014-05-04 15:34:58 +02:00
if(!Cookie::get('bypassStaticCache')) {
Cookie::set('bypassStaticCache', '1', 0, null, null, false, true /* httponly */);
}
}
}
}
2014-08-15 08:53:05 +02:00
/**
* Set the current reading mode.
*
* @param string $mode
*/
public static function set_reading_mode($mode) {
self::$reading_mode = $mode;
}
2014-08-15 08:53:05 +02:00
/**
* Get the current reading mode.
*
* @return string
*/
public static function get_reading_mode() {
return self::$reading_mode;
}
2014-08-15 08:53:05 +02:00
/**
* Get the current reading stage.
*
* @return string
*/
public static function get_stage() {
$parts = explode('.', Versioned::get_reading_mode());
if($parts[0] == 'Stage') {
return $parts[1];
}
}
2014-08-15 08:53:05 +02:00
/**
* Get the current archive date.
*
* @return string
*/
public static function current_archived_date() {
$parts = explode('.', Versioned::get_reading_mode());
if($parts[0] == 'Archive') {
return $parts[1];
}
}
2014-08-15 08:53:05 +02:00
/**
* Set the reading stage.
*
* @param string $stage New reading stage.
*/
public static function set_stage($stage) {
static::set_reading_mode('Stage.' . $stage);
}
2014-08-15 08:53:05 +02:00
/**
* Set the reading archive date.
*
* @param string $date New reading archived date.
*/
public static function reading_archived_date($date) {
Versioned::set_reading_mode('Archive.' . $date);
}
2014-08-15 08:53:05 +02:00
/**
* Get a singleton instance of a class in the given stage.
2014-08-15 08:53:05 +02:00
*
* @param string $class The name of the class.
* @param string $stage The name of the stage.
* @param string $filter A filter to be inserted into the WHERE clause.
* @param boolean $cache Use caching.
* @param string $sort A sort expression to be inserted into the ORDER BY clause.
*
* @return DataObject
*/
public static function get_one_by_stage($class, $stage, $filter = '', $cache = true, $sort = '') {
// TODO: No identity cache operating
$items = static::get_by_stage($class, $stage, $filter, $sort, null, 1);
return $items->First();
}
2014-08-15 08:53:05 +02:00
/**
* Gets the current version number of a specific record.
2014-08-15 08:53:05 +02:00
*
* @param string $class
* @param string $stage
* @param int $id
* @param boolean $cache
*
* @return int
*/
public static function get_versionnumber_by_stage($class, $stage, $id, $cache = true) {
$baseClass = ClassInfo::baseDataClass($class);
$stageTable = ($stage == 'Stage') ? $baseClass : "{$baseClass}_{$stage}";
// cached call
if($cache && isset(self::$cache_versionnumber[$baseClass][$stage][$id])) {
return self::$cache_versionnumber[$baseClass][$stage][$id];
}
// get version as performance-optimized SQL query (gets called for each record in the sitetree)
$version = DB::prepared_query(
"SELECT \"Version\" FROM \"$stageTable\" WHERE \"ID\" = ?",
array($id)
)->value();
2014-08-15 08:53:05 +02:00
// cache value (if required)
if($cache) {
if(!isset(self::$cache_versionnumber[$baseClass])) {
self::$cache_versionnumber[$baseClass] = array();
}
if(!isset(self::$cache_versionnumber[$baseClass][$stage])) {
self::$cache_versionnumber[$baseClass][$stage] = array();
}
self::$cache_versionnumber[$baseClass][$stage][$id] = $version;
}
2014-08-15 08:53:05 +02:00
return $version;
}
2014-08-15 08:53:05 +02:00
/**
2014-08-15 08:53:05 +02:00
* Pre-populate the cache for Versioned::get_versionnumber_by_stage() for
* a list of record IDs, for more efficient database querying. If $idList
* is null, then every record will be pre-cached.
*
* @param string $class
* @param string $stage
* @param array $idList
*/
public static function prepopulate_versionnumber_cache($class, $stage, $idList = null) {
if (!Config::inst()->get('Versioned', 'prepopulate_versionnumber_cache')) {
return;
}
$filter = "";
$parameters = array();
if($idList) {
// Validate the ID list
foreach($idList as $id) {
if(!is_numeric($id)) {
user_error("Bad ID passed to Versioned::prepopulate_versionnumber_cache() in \$idList: " . $id,
E_USER_ERROR);
}
}
$filter = 'WHERE "ID" IN ('.DB::placeholders($idList).')';
$parameters = $idList;
}
2014-08-15 08:53:05 +02:00
$baseClass = ClassInfo::baseDataClass($class);
$stageTable = ($stage == 'Stage') ? $baseClass : "{$baseClass}_{$stage}";
$versions = DB::prepared_query("SELECT \"ID\", \"Version\" FROM \"$stageTable\" $filter", $parameters)->map();
foreach($versions as $id => $version) {
self::$cache_versionnumber[$baseClass][$stage][$id] = $version;
}
}
2014-08-15 08:53:05 +02:00
/**
* Get a set of class instances by the given stage.
2014-08-15 08:53:05 +02:00
*
* @param string $class The name of the class.
* @param string $stage The name of the stage.
* @param string $filter A filter to be inserted into the WHERE clause.
* @param string $sort A sort expression to be inserted into the ORDER BY clause.
* @param string $join Deprecated, use leftJoin($table, $joinClause) instead
* @param int $limit A limit on the number of records returned from the database.
* @param string $containerClass The container class for the result set (default is DataList)
*
* @return DataList A modified DataList designated to the specified stage
*/
public static function get_by_stage(
$class, $stage, $filter = '', $sort = '', $join = '', $limit = null, $containerClass = 'DataList'
) {
$result = DataObject::get($class, $filter, $sort, $join, $limit, $containerClass);
return $result->setDataQueryParam(array(
'Versioned.mode' => 'stage',
'Versioned.stage' => $stage
));
}
2014-08-15 08:53:05 +02:00
/**
* Delete this record from the given stage
*
* @param string $stage
*/
public function deleteFromStage($stage) {
$oldMode = Versioned::get_reading_mode();
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($owner->class);
self::$cache_versionnumber[$baseClass][$stage][$owner->ID] = null;
}
2014-08-15 08:53:05 +02:00
/**
* Write the given record to the draft stage
*
* @param string $stage
* @param boolean $forceInsert
* @return int The ID of the record
*/
public function writeToStage($stage, $forceInsert = false) {
$oldMode = Versioned::get_reading_mode();
Versioned::set_stage($stage);
$owner = $this->owner;
$owner->forceChange();
$result = $owner->write(false, $forceInsert);
Versioned::set_reading_mode($oldMode);
return $result;
}
/**
* Roll the draft version of this record to match the published record.
* Caution: Doesn't overwrite the object properties with the rolled back version.
2014-08-15 08:53:05 +02:00
*
* {@see doRevertToLive()} to reollback to live
*
* @param int $version Version number
*/
public function doRollbackTo($version) {
$owner = $this->owner;
$owner->extend('onBeforeRollback', $version);
$owner->publish($version, "Stage", true);
$owner->writeWithoutVersion();
$owner->extend('onAfterRollback', $version);
}
2014-08-15 08:53:05 +02:00
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.
2014-08-15 08:53:05 +02:00
*
* @param string $class
* @param int $id
* @return DataObject
*/
public static function get_latest_version($class, $id) {
$baseClass = ClassInfo::baseDataClass($class);
$list = DataList::create($baseClass)
->setDataQueryParam("Versioned.mode", "latest_versions");
return $list->byID($id);
}
2014-08-15 08:53:05 +02:00
/**
* Returns whether the current record is the latest one.
*
* @todo Performance - could do this directly via SQL.
*
* @see get_latest_version()
* @see latestPublished
*
* @return boolean
*/
public function isLatestVersion() {
$owner = $this->owner;
if(!$owner->isInDB()) {
return false;
}
2014-08-15 08:53:05 +02:00
$version = static::get_latest_version($owner->class, $owner->ID);
return ($version->Version == $owner->Version);
}
/**
* Check if this record exists on live
*
* @return bool
*/
public function isPublished() {
$owner = $this->owner;
if(!$owner->isInDB()) {
return false;
}
// 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($owner->ID)
);
return (bool)$result->value();
}
/**
* Check if this record exists on the draft stage
*
* @return bool
*/
public function isOnDraft() {
$owner = $this->owner;
if(!$owner->isInDB()) {
return false;
}
$table = ClassInfo::baseDataClass($owner->class);
$result = DB::prepared_query(
"SELECT COUNT(*) FROM \"{$table}\" WHERE \"{$table}\".\"ID\" = ?",
array($owner->ID)
);
return (bool)$result->value();
}
/**
* Return the equivalent of a DataList::create() call, querying the latest
* version of each record stored in the (class)_versions tables.
*
* In particular, this will query deleted records as well as active ones.
*
* @param string $class
* @param string $filter
* @param string $sort
* @return DataList
*/
public static function get_including_deleted($class, $filter = "", $sort = "") {
$list = DataList::create($class)
->where($filter)
->sort($sort)
->setDataQueryParam("Versioned.mode", "latest_versions");
return $list;
}
2014-08-15 08:53:05 +02:00
/**
2012-06-27 16:03:08 +02:00
* Return the specific version of the given id.
*
2014-08-15 08:53:05 +02:00
* Caution: The record is retrieved as a DataObject, but saving back
* modifications via write() will create a new version, rather than
* modifying the existing one.
2014-08-15 08:53:05 +02:00
*
* @param string $class
* @param int $id
* @param int $version
*
* @return DataObject
*/
public static function get_version($class, $id, $version) {
$baseClass = ClassInfo::baseDataClass($class);
$list = DataList::create($baseClass)
->setDataQueryParam([
"Versioned.mode" => 'version',
"Versioned.version" => $version
]);
return $list->byID($id);
}
/**
* Return a list of all versions for a given id.
*
* @param string $class
* @param int $id
*
* @return DataList
*/
public static function get_all_versions($class, $id) {
$list = DataList::create($class)
->filter('ID', $id)
->setDataQueryParam('Versioned.mode', 'all_versions');
return $list;
}
/**
* @param array $labels
*/
public function updateFieldLabels(&$labels) {
$labels['Versions'] = _t('Versioned.has_many_Versions', 'Versions', 'Past Versions of this record');
}
2014-08-15 08:53:05 +02:00
/**
* @param FieldList
*/
public function updateCMSFields(FieldList $fields) {
2014-08-15 08:53:05 +02:00
// remove the version field from the CMS as this should be left
// entirely up to the extension (not the cms user).
$fields->removeByName('Version');
}
/**
* Ensure version ID is reset to 0 on duplicate
*
* @param DataObject $source Record this was duplicated from
* @param bool $doWrite
*/
public function onBeforeDuplicate($source, $doWrite) {
$this->owner->Version = 0;
}
public function flushCache() {
self::$cache_versionnumber = array();
}
/**
* Return a piece of text to keep DataObject cache keys appropriately specific.
*
* @return string
*/
public function cacheKeyComponent() {
return 'versionedmode-'.static::get_reading_mode();
}
/**
* Returns an array of possible stages.
*
* @return array
*/
public function getVersionedStages() {
if($this->hasStages()) {
return [static::DRAFT, static::LIVE];
} else {
return [static::DRAFT];
}
}
public static function get_template_global_variables() {
return array(
'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;
}
}
/**
* Represents a single version of a record.
*
* @package framework
* @subpackage model
*
* @see Versioned
*/
class Versioned_Version extends ViewableData {
/**
* @var array
*/
protected $record;
/**
* @var DataObject
*/
protected $object;
2014-08-15 08:53:05 +02:00
/**
* Create a new version from a database row
*
* @param array $record
*/
public function __construct($record) {
$this->record = $record;
$record['ID'] = $record['RecordID'];
$className = $record['ClassName'];
2014-08-15 08:53:05 +02:00
$this->object = ClassInfo::exists($className) ? new $className($record) : new DataObject($record);
$this->failover = $this->object;
2014-08-15 08:53:05 +02:00
parent::__construct();
}
2014-08-15 08:53:05 +02:00
/**
* Either 'published' if published, or 'internal' if not.
*
* @return string
*/
public function PublishedClass() {
return $this->record['WasPublished'] ? 'published' : 'internal';
}
2014-08-15 08:53:05 +02:00
/**
* Author of this DataObject
*
* @return Member
*/
public function Author() {
return Member::get()->byId($this->record['AuthorID']);
}
2014-08-15 08:53:05 +02:00
/**
* Member object of the person who last published this record
*
* @return Member
*/
public function Publisher() {
if (!$this->record['WasPublished']) {
return null;
}
2014-08-15 08:53:05 +02:00
return Member::get()->byId($this->record['PublisherID']);
}
2014-08-15 08:53:05 +02:00
/**
* True if this record is published via publish() method
*
* @return boolean
*/
public function Published() {
return !empty($this->record['WasPublished']);
}
/**
* Traverses to a field referenced by relationships between data objects, returning the value
* The path to the related field is specified with dot separated syntax (eg: Parent.Child.Child.FieldName)
*
* @param $fieldName string
* @return string | null - will return null on a missing value
*/
public function relField($fieldName) {
$component = $this;
// We're dealing with relations here so we traverse the dot syntax
if(strpos($fieldName, '.') !== false) {
$relations = explode('.', $fieldName);
$fieldName = array_pop($relations);
foreach($relations as $relation) {
// Inspect $component for element $relation
if($component->hasMethod($relation)) {
// Check nested method
$component = $component->$relation();
} elseif($component instanceof SS_List) {
// Select adjacent relation from DataList
$component = $component->relation($relation);
} elseif($component instanceof DataObject
&& ($dbObject = $component->dbObject($relation))
) {
// Select db object
$component = $dbObject;
} else {
user_error("$relation is not a relation/field on ".get_class($component), E_USER_ERROR);
}
}
}
// Bail if the component is null
if(!$component) {
return null;
}
if ($component->hasMethod($fieldName)) {
return $component->$fieldName();
}
return $component->$fieldName;
}
}