"Int", "Version" => "Int", "WasPublished" => "Boolean", "AuthorID" => "Int", "PublisherID" => "Int" ); /** * Additional database indexes for the new * "_versions" table. Used in {@link augmentDatabase()}. * * @var array $indexes_for_versions_table */ static $indexes_for_versions_table = array( 'RecordID_Version' => '(RecordID,Version)', 'RecordID' => true, 'Version' => true, 'AuthorID' => true, 'PublisherID' => true, ); /** * Reset static configuration variables to their default values */ 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 consiedered the 'default' stage, the last stage is * considered the 'live' stage. */ function __construct($stages) { parent::__construct(); if(!is_array($stages)) { $stages = func_get_args(); } $this->stages = $stages; $this->defaultStage = reset($stages); $this->liveStage = array_pop($stages); } function extraStatics($class) { return array( 'db' => array( 'Version' => 'Int', ), 'has_many' => array( 'Versions' => $class, ) ); } /** * Amend freshly created DataQuery objects with versioned-specific information */ function augmentDataQueryCreation(SQLQuery &$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' && $parts[1] != $this->defaultStage && array_search($parts[1],$this->stages) !== false) { $dataQuery->setQueryParam('Versioned.mode', 'stage'); $dataQuery->setQueryParam('Versioned.stage', $parts[1]); } } /** * Augment the the SQLQuery that is created by the DataQuery * @todo Should this all go into VersionedDataQuery? */ function augmentSQL(SQLQuery &$query, DataQuery &$dataQuery) { $baseTable = ClassInfo::baseDataClass($dataQuery->dataClass()); switch($dataQuery->getQueryParam('Versioned.mode')) { // Noop case '': break; // Reading a specific data from the archive case 'archive': $date = $dataQuery->getQueryParam('Versioned.date'); foreach($query->from as $table => $dummy) { $query->renameTable($table, $table . '_versions'); $query->replaceText("\"$table\".\"ID\"", "\"$table\".\"RecordID\""); // Add all _versions columns foreach(self::$db_for_versions_table as $name => $type) { $query->select[] = sprintf('"%s_versions"."%s"', $baseTable, $name); } $query->select[] = sprintf('"%s_versions"."%s" AS "ID"', $baseTable, 'RecordID'); if($table != $baseTable) { $query->from[$table] .= " AND \"{$table}_versions\".\"Version\" = \"{$baseTable}_versions\".\"Version\""; } } // Link to the version archived on that date $archiveTable = $this->requireArchiveTempTable($baseTable, $date); $query->from[$archiveTable] = "INNER JOIN \"$archiveTable\" ON \"$archiveTable\".\"ID\" = \"{$baseTable}_versions\".\"RecordID\" AND \"$archiveTable\".\"Version\" = \"{$baseTable}_versions\".\"Version\""; break; // Reading a specific stage (Stage or Live) case 'stage': $stage = $dataQuery->getQueryParam('Versioned.stage'); if($stage && ($stage != $this->defaultStage)) { foreach($query->from as $table => $dummy) { // Only rewrite table names that are actually part of the subclass tree // This helps prevent rewriting of other tables that get joined in, in // particular, many_many tables if(class_exists($table) && ($table == $this->owner->class || is_subclass_of($table, $this->owner->class) || is_subclass_of($this->owner->class, $table))) { $query->renameTable($table, $table . '_' . $stage); } } } break; // Return all version instances case 'all_versions': case 'latest_versions': foreach($query->from as $alias => $join) { if($alias != $baseTable) { $query->setJoinFilter($alias, "\"$alias\".\"RecordID\" = \"{$baseTable}_versions\".\"RecordID\" AND \"$alias\".\"Version\" = \"{$baseTable}_versions\".\"Version\""); } $query->renameTable($alias, $alias . '_versions'); } // Add all _versions columns foreach(self::$db_for_versions_table as $name => $type) { $query->selectMore(sprintf('"%s_versions"."%s"', $baseTable, $name)); } $query->selectMore(sprintf('"%s_versions"."%s" AS "ID"', $baseTable, 'RecordID')); // 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') { $archiveTable = self::requireArchiveTempTable($baseTable); $query->innerJoin($archiveTable, "\"$archiveTable\".\"ID\" = \"{$baseTable}_versions\".\"RecordID\" AND \"$archiveTable\".\"Version\" = \"{$baseTable}_versions\".\"Version\""); } break; default: throw new InvalidArgumentException("Bad value for query parameter Versioned.mode: " . $dataQuery->getQueryParam('Versioned.mode')); } } /** * Keep track of the archive tables that have been created */ private static $archive_tables = array(); /** * 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::getConn(); foreach(self::$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(); } /** * Create a temporary table mapping each database record to its version on the given date. * This is used by the versioning system to return database content on that date. * @param string $baseTable The base table. * @param string $date The date. If omitted, then the latest version of each page will be returned. * @todo Ensure that this is DB abstracted */ protected static function requireArchiveTempTable($baseTable, $date = null) { if(!isset(self::$archive_tables[$baseTable])) { self::$archive_tables[$baseTable] = DB::createTable("_Archive$baseTable", array( "ID" => "INT NOT NULL", "Version" => "INT NOT NULL", ), null, array('temporary' => true)); } if(!DB::query("SELECT COUNT(*) FROM \"" . self::$archive_tables[$baseTable] . "\"")->value()) { if($date) $dateClause = "WHERE \"LastEdited\" <= '$date'"; else $dateClause = ""; DB::query("INSERT INTO \"" . self::$archive_tables[$baseTable] . "\" SELECT \"RecordID\", max(\"Version\") FROM \"{$baseTable}_versions\" $dateClause GROUP BY \"RecordID\""); } return self::$archive_tables[$baseTable]; } /** * An array of DataObject extensions that may require versioning for extra tables * The array value is a set of suffixes to form these table names, assuming a preceding '_'. * E.g. if Extension1 creates a new table 'Class_suffix1' * and Extension2 the tables 'Class_suffix2' and 'Class_suffix3': * * $versionableExtensions = array( * 'Extension1' => 'suffix1', * 'Extension2' => array('suffix2', 'suffix3'), * ); * * Make sure your extension has a static $enabled-property that determines if it is * processed by Versioned. * * @var array */ protected static $versionableExtensions = array('Translatable' => 'lang'); function augmentDatabase() { $classTable = $this->owner->class; $isRootClass = ($this->owner->class == ClassInfo::baseDataClass($this->owner->class)); // Build a list of suffixes whose tables need versioning $allSuffixes = array(); foreach (Versioned::$versionableExtensions as $versionableExtension => $suffixes) { if ($this->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; if ($suffix) $table = "{$classTable}_$suffix"; else $table = $classTable; if($fields = DataObject::database_fields($this->owner->class)) { $options = Object::get_static($this->owner->class, 'create_table_options'); $indexes = $this->owner->databaseIndexes(); if ($suffix && ($ext = $this->owner->getExtensionInstance($allSuffixes[$suffix]))) { if (!$ext->isVersionedTable($table)) continue; $ext->setOwner($this->owner); $fields = $ext->fieldsInExtraTables($suffix); $ext->clearOwner(); $indexes = $fields['indexes']; $fields = $fields['db']; } // Create tables for other stages foreach($this->stages as $stage) { // Extra tables for _Live, etc. //Change unique indexes to 'index'. Versioned tables may run into unique indexing difficulties otherwise. foreach($indexes as $key=>$index){ if(is_array($index) && $index['type']=='unique'){ $indexes[$key]['type']='index'; } } if($stage != $this->defaultStage) { DB::requireTable("{$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); } */ } if($isRootClass) { // Create table for all versions $versionFields = array_merge( self::$db_for_versions_table, (array)$fields ); $versionIndexes = array_merge( self::$indexes_for_versions_table, (array)$indexes ); } else { // Create fields for any tables of subclasses $versionFields = array_merge( array( "RecordID" => "Int", "Version" => "Int", ), (array)$fields ); //Unique indexes will not work on versioned tables, so we'll convert them to standard indexes: foreach($indexes as $key=>$index){ if(is_array($index) && strtolower($index['type'])=='unique'){ $indexes[$key]['type']='index'; } } $versionIndexes = array_merge( array( 'RecordID_Version' => array('type' => 'unique', 'value' => 'RecordID,Version'), 'RecordID' => true, 'Version' => true, ), (array)$indexes ); } if(DB::getConn()->hasTable("{$table}_versions")) { // Fix data that lacks the uniqueness constraint (since this was added later and // bugs meant that the constraint was validated) $duplications = DB::query("SELECT MIN(\"ID\") AS \"ID\", \"RecordID\", \"Version\" FROM \"{$table}_versions\" GROUP BY \"RecordID\", \"Version\" HAVING COUNT(*) > 1"); foreach($duplications as $dup) { DB::alteration_message("Removing {$table}_versions duplicate data for " ."{$dup['RecordID']}/{$dup['Version']}" ,"deleted"); DB::query("DELETE FROM \"{$table}_versions\" WHERE \"RecordID\" = {$dup['RecordID']} AND \"Version\" = {$dup['Version']} AND \"ID\" != {$dup['ID']}"); } // Remove junk which has no data in parent classes. Only needs to run the following // when versioned data is spread over multiple tables if(!$isRootClass && ($versionedTables = ClassInfo::dataClassesFor($table))) { foreach($versionedTables as $child) { if($table == $child) break; // only need subclasses $count = DB::query(" SELECT COUNT(*) FROM \"{$table}_versions\" LEFT JOIN \"{$child}_versions\" ON \"{$child}_versions\".\"RecordID\" = \"{$table}_versions\".\"RecordID\" AND \"{$child}_versions\".\"Version\" = \"{$table}_versions\".\"Version\" WHERE \"{$child}_versions\".\"ID\" IS NULL ")->value(); if($count > 0) { DB::alteration_message("Removing orphaned versioned records", "deleted"); $effectedIDs = DB::query(" SELECT \"{$table}_versions\".\"ID\" FROM \"{$table}_versions\" LEFT JOIN \"{$child}_versions\" ON \"{$child}_versions\".\"RecordID\" = \"{$table}_versions\".\"RecordID\" AND \"{$child}_versions\".\"Version\" = \"{$table}_versions\".\"Version\" WHERE \"{$child}_versions\".\"ID\" IS NULL ")->column(); if(is_array($effectedIDs)) { foreach($effectedIDs as $key => $value) { DB::query("DELETE FROM \"{$table}_versions\" WHERE \"{$table}_versions\".\"ID\" = '$value'"); } } } } } } DB::requireTable("{$table}_versions", $versionFields, $versionIndexes, true, $options); } else { DB::dontRequireTable("{$table}_versions"); foreach($this->stages as $stage) { if($stage != $this->defaultStage) DB::dontrequireTable("{$table}_$stage"); } } } } /** * Augment a write-record request. * @param SQLQuery $manipulation Query to augment. */ function augmentWrite(&$manipulation) { $tables = array_keys($manipulation); $version_table = array(); foreach($tables as $table) { $baseDataClass = ClassInfo::baseDataClass($table); $isRootClass = ($table == $baseDataClass); // Make sure that the augmented write is being applied to a table that can be versioned if( !$this->canBeVersioned($table) ) { unset($manipulation[$table]); continue; } $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); $rid = isset($manipulation[$table]['RecordID']) ? $manipulation[$table]['RecordID'] : $id; $newManipulation = array( "command" => "insert", "fields" => isset($manipulation[$table]['fields']) ? $manipulation[$table]['fields'] : null ); if($this->migratingVersion) { $manipulation[$table]['fields']['Version'] = $this->migratingVersion; } // If we haven't got a version #, then we're creating a new version. // Otherwise, we're just copying a version to another table if(!isset($manipulation[$table]['fields']['Version'])) { // Add any extra, unchanged fields to the version record. $data = DB::query("SELECT * FROM \"$table\" WHERE \"ID\" = $id")->record(); if($data) foreach($data as $k => $v) { if (!isset($newManipulation['fields'][$k])) $newManipulation['fields'][$k] = "'" . Convert::raw2sql($v) . "'"; } // Set up a new entry in (table)_versions $newManipulation['fields']['RecordID'] = $rid; unset($newManipulation['fields']['ID']); // Create a new version # if (isset($version_table[$table])) $nextVersion = $version_table[$table]; else unset($nextVersion); if($rid && !isset($nextVersion)) $nextVersion = DB::query("SELECT MAX(\"Version\") + 1 FROM \"{$baseDataClass}_versions\" WHERE \"RecordID\" = $rid")->value(); $newManipulation['fields']['Version'] = $nextVersion ? $nextVersion : 1; if($isRootClass) { $userID = (Member::currentUser()) ? Member::currentUser()->ID : 0; $newManipulation['fields']['AuthorID'] = $userID; } $manipulation["{$table}_versions"] = $newManipulation; // Add the version number to this data $manipulation[$table]['fields']['Version'] = $newManipulation['fields']['Version']; $version_table[$table] = $nextVersion; } // Putting a Version of -1 is a signal to leave the version table alone, despite their being no version if($manipulation[$table]['fields']['Version'] < 0) unset($manipulation[$table]['fields']['Version']); if(!$this->hasVersionField($table)) unset($manipulation[$table]['fields']['Version']); // Grab a version number - it should be the same across all tables. if(isset($manipulation[$table]['fields']['Version'])) $thisVersion = $manipulation[$table]['fields']['Version']; // If we're editing Live, then use (table)_Live instead of (table) if(Versioned::current_stage() && Versioned::current_stage() != $this->defaultStage) { // If the record has already been inserted in the (table), get rid of it. if($manipulation[$table]['command']=='insert') { DB::query("DELETE FROM \"{$table}\" WHERE \"ID\"='$id'"); } $newTable = $table . '_' . Versioned::current_stage(); $manipulation[$newTable] = $manipulation[$table]; unset($manipulation[$table]); } } // Clear the migration flag if($this->migratingVersion) $this->migrateVersion(null); // Add the new version # back into the data object, for accessing after this write if(isset($thisVersion)) $this->owner->Version = str_replace("'","",$thisVersion); } /** * If a write was skipped, then we need to ensure that we don't leave a migrateVersion() * value lying around for the next write. */ function onAfterSkippedWrite() { $this->migrateVersion(null); } /** * Determine if a table is supporting the Versioned extensions (e.g. $table_versions does exists) * * @param string $table Table name * @return boolean */ function canBeVersioned($table) { return ClassInfo::exists($table) && is_subclass_of($table, 'DataObject') && DataObject::has_own_table($table); } /** * 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 */ function hasVersionField($table) { $rPos = strrpos($table,'_'); if(($rPos !== false) && in_array(substr($table,$rPos), $this->stages)) { $tableWithoutStage = substr($table,0,$rPos); } else { $tableWithoutStage = $table; } return ('DataObject' == get_parent_class($tableWithoutStage)); } function extendWithSuffix($table) { foreach (Versioned::$versionableExtensions as $versionableExtension => $suffixes) { if ($this->owner->hasExtension($versionableExtension)) { $ext = $this->owner->getExtensionInstance($versionableExtension); $ext->setOwner($this->owner); $table = $ext->extendWithSuffix($table); $ext->clearOwner(); } } return $table; } //-----------------------------------------------------------------------------------------------// /** * Get the latest published DataObject. * @return DataObject */ 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"; return DB::query("SELECT \"$table1\".\"Version\" = \"$table2\".\"Version\" FROM \"$table1\" INNER JOIN \"$table2\" ON \"$table1\".\"ID\" = \"$table2\".\"ID\" WHERE \"$table1\".\"ID\" = ". $this->owner->ID)->value(); } /** * Move a database record from one stage to the other. * @param fromStage Place to copy from. Can be either a stage name or a version number. * @param toStage Place to copy to. Must be a stage name. * @param createNewVersion Set this to true to create a new version number. By default, the existing version number will be copied over. */ function publish($fromStage, $toStage, $createNewVersion = false) { $this->owner->extend('onBeforeVersionedPublish', $fromStage, $toStage, $createNewVersion); $baseClass = $this->owner->class; while( ($p = get_parent_class($baseClass)) != "DataObject") $baseClass = $p; $extTable = $this->extendWithSuffix($baseClass); if(is_numeric($fromStage)) { $from = Versioned::get_version($baseClass, $this->owner->ID, $fromStage); } else { $this->owner->flushCache(); $from = Versioned::get_one_by_stage($baseClass, $fromStage, "\"{$baseClass}\".\"ID\" = {$this->owner->ID}"); } $publisherID = isset(Member::currentUser()->ID) ? Member::currentUser()->ID : 0; if($from) { $from->forceChange(); if($createNewVersion) { $latest = self::get_latest_version($baseClass, $this->owner->ID); $this->owner->Version = $latest->Version + 1; } else { $from->migrateVersion($from->Version); } // Mark this version as having been published at some stage DB::query("UPDATE \"{$extTable}_versions\" SET \"WasPublished\" = '1', \"PublisherID\" = $publisherID WHERE \"RecordID\" = $from->ID AND \"Version\" = $from->Version"); $oldMode = Versioned::get_reading_mode(); Versioned::reading_stage($toStage); $conn = DB::getConn(); if(method_exists($conn, 'allowPrimaryKeyEditing')) $conn->allowPrimaryKeyEditing($baseClass, true); $from->write(); if(method_exists($conn, 'allowPrimaryKeyEditing')) $conn->allowPrimaryKeyEditing($baseClass, false); $from->destroy(); Versioned::set_reading_mode($oldMode); } else { user_error("Can't find {$this->owner->URLSegment}/{$this->owner->ID} in stage $fromStage", E_USER_WARNING); } } /** * Set the migrating version. * @param string $version The version. */ function migrateVersion($version) { $this->migratingVersion = $version; } /** * 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 */ function stagesDiffer($stage1, $stage2) { $table1 = $this->baseTable($stage1); $table2 = $this->baseTable($stage2); if(!is_numeric($this->owner->ID)) { return true; } // We test for equality - if one of the versions doesn't exist, this will be false //TODO: DB Abstraction: if statement here: $stagesAreEqual = DB::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\" = {$this->owner->ID}")->value(); return !$stagesAreEqual; } function Versions($filter = "", $sort = "", $limit = "", $join = "", $having = "") { return $this->allVersions($filter, $sort, $limit, $join, $having); } /** * Return a list of all the versions available. * @param string $filter */ 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'); $query = $this->owner->extendedSQL($filter, $sort, $limit, $join, $having); foreach($query->from as $table => $tableJoin) { if(is_string($tableJoin) && $tableJoin[0] == '"') { $baseTable = str_replace('"','',$tableJoin); } elseif(is_string($tableJoin) && substr($tableJoin,0,5) != 'INNER') { $query->from[$table] = "LEFT JOIN \"$table\" ON \"$table\".\"RecordID\" = \"{$baseTable}_versions\".\"RecordID\" AND \"$table\".\"Version\" = \"{$baseTable}_versions\".\"Version\""; } $query->renameTable($table, $table . '_versions'); } // Add all _versions columns foreach(self::$db_for_versions_table as $name => $type) { $query->select[] = sprintf('"%s_versions"."%s"', $baseTable, $name); } $query->where[] = "\"{$baseTable}_versions\".\"RecordID\" = '{$this->owner->ID}'"; $query->orderby = ($sort) ? $sort : "\"{$baseTable}_versions\".\"LastEdited\" DESC, \"{$baseTable}_versions\".\"Version\" DESC"; $records = $query->execute(); $versions = new ArrayList(); foreach($records as $record) { $versions->push(new Versioned_Version($record)); } Versioned::set_reading_mode($oldMode); return $versions; } /** * Compare two version, and return the diff between them. * @param string $from The version to compare from. * @param string $to The version to compare to. * @return DataObject */ 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); $diff = new DataDifferencer($fromRecord, $toRecord); return $diff->diffedData(); } /** * Return the base table - the class that directly extends DataObject. * @return string */ function baseTable($stage = null) { $tableClasses = ClassInfo::dataClassesFor($this->owner->class); $baseClass = array_shift($tableClasses); return (!$stage || $stage == $this->defaultStage) ? $baseClass : $baseClass . "_$stage"; } //-----------------------------------------------------------------------------------------------// /** * Choose the stage the site is currently on. * If $_GET['stage'] is set, then it will use that stage, and store it in the session. * if $_GET['archiveDate'] is set, it will use that date, and store it in the session. * If neither of these are set, it checks the session, otherwise the stage is set to 'Live'. */ static function choose_site_stage() { if(isset($_GET['stage'])) { $stage = ucfirst(strtolower($_GET['stage'])); if(!in_array($stage, array('Stage', 'Live'))) $stage = 'Live'; Session::set('readingMode', 'Stage.' . $stage); } if(isset($_GET['archiveDate'])) { Session::set('readingMode', 'Archive.' . $_GET['archiveDate']); } if($mode = Session::get('readingMode')) { Versioned::set_reading_mode($mode); } else { Versioned::reading_stage("Live"); } if(!headers_sent()) { if(Versioned::current_stage() == 'Live') { Cookie::set('bypassStaticCache', null, 0, null, null, false, true /* httponly */); } else { Cookie::set('bypassStaticCache', '1', 0, null, null, false, true /* httponly */); } } } /** * Set the current reading mode. */ static function set_reading_mode($mode) { Versioned::$reading_mode = $mode; } /** * Get the current reading mode. * @return string */ static function get_reading_mode() { return Versioned::$reading_mode; } /** * Get the name of the 'live' stage. * @return string */ static function get_live_stage() { return "Live"; } /** * Get the current reading stage. * @return string */ static function current_stage() { $parts = explode('.', Versioned::get_reading_mode()); if($parts[0] == 'Stage') return $parts[1]; } /** * Get the current archive date. * @return string */ static function current_archived_date() { $parts = explode('.', Versioned::get_reading_mode()); if($parts[0] == 'Archive') return $parts[1]; } /** * Set the reading stage. * @param string $stage New reading stage. */ static function reading_stage($stage) { Versioned::set_reading_mode('Stage.' . $stage); } /** * Set the reading archive date. * @param string $date New reading archived date. */ static function reading_archived_date($date) { Versioned::set_reading_mode('Archive.' . $date); } /** * Get a singleton instance of a class in the given stage. * * @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 $orderby A sort expression to be inserted into the ORDER BY clause. * @return DataObject */ 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); return $items->First(); } /** * Gets the current version number of a specific record. * * @param string $class * @param string $stage * @param int $id * @param boolean $cache * @return int */ 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 page in the sitetree) $version = DB::query("SELECT \"Version\" FROM \"$stageTable\" WHERE \"ID\" = $id")->value(); // 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; } return $version; } /** * 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 page will be pre-cached. */ static function prepopulate_versionnumber_cache($class, $stage, $idList = null) { $filter = ""; 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(" .implode(", ", $idList) . ")"; } $baseClass = ClassInfo::baseDataClass($class); $stageTable = ($stage == 'Stage') ? $baseClass : "{$baseClass}_{$stage}"; $versions = DB::query("SELECT \"ID\", \"Version\" FROM \"$stageTable\" $filter")->map(); foreach($versions as $id => $version) { self::$cache_versionnumber[$baseClass][$stage][$id] = $version; } } /** * Get a set of class instances by the given stage. * * @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 A join expression, such as LEFT JOIN or INNER JOIN * @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 DataObjectSet) * @return DataObjectSet */ static function get_by_stage($class, $stage, $filter = '', $sort = '', $join = '', $limit = '', $containerClass = 'DataList') { $result = DataObject::get($class, $filter, $sort, $join, $limit, $containerClass); $dq = $result->dataQuery(); $dq->setQueryParam('Versioned.mode', 'stage'); $dq->setQueryParam('Versioned.stage', $stage); return $result; } function deleteFromStage($stage) { $oldMode = Versioned::get_reading_mode(); Versioned::reading_stage($stage); $clone = clone $this->owner; $result = $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; return $result; } function writeToStage($stage, $forceInsert = false) { $oldMode = Versioned::get_reading_mode(); Versioned::reading_stage($stage); $result = $this->owner->write(false, $forceInsert); Versioned::set_reading_mode($oldMode); return $result; } /** * Roll the draft version of this page to match the published page. * Caution: Doesn't overwrite the object properties with the rolled back version. * * @param $version Either the string 'Live' or a version number */ function doRollbackTo($version) { $this->publish($version, "Stage", true); $this->owner->writeWithoutVersion(); } /** * Return the latest version of the given page. * * @return DataObject */ static function get_latest_version($class, $id) { $baseClass = ClassInfo::baseDataClass($class); $list = DataList::create($class)->where("\"$baseClass\".\"RecordID\" = $id"); $list->dataQuery()->setQueryParam("Versioned.mode", "latest_versions"); return $list->First(); } /** * Returns whether the current record is the latest one. * * @todo Performance - could do this directly via SQL. * * @see get_latest_version() * @see latestPublished * * @return bool */ function isLatestVersion() { $version = self::get_latest_version($this->owner->class, $this->owner->ID); return ($version->Version == $this->owner->Version); } /** * Return the equivalent of a DataObject::get() call, querying the latest * version of each page stored in the (class)_versions tables. * * In particular, this will query deleted records as well as active ones. */ static function get_including_deleted($class, $filter = "", $sort = "") { $list = DataList::create($class)->where($filter)->sort($sort); $list->dataQuery()->setQueryParam("Versioned.mode", "latest_versions"); return $list; } /** * Return the specific version of the given id * @return DataObject */ static function get_version($class, $id, $version) { $baseClass = ClassInfo::baseDataClass($class); $list = DataList::create($class)->where("\"$baseClass\".\"RecordID\" = $id")->where("\"$baseClass\".\"Version\" = " . (int)$version); $list->dataQuery()->setQueryParam('Versioned.mode', 'all_versions'); return $list->First(); } /** * Return a list of all versions for a given id * @return DataList */ static function get_all_versions($class, $id) { $baseClass = ClassInfo::baseDataClass($class); $list = DataList::create($class)->where("\"$baseClass\".\"RecordID\" = $id"); $list->dataQuery()->setQueryParam('Versioned.mode', 'all_versions'); return $list; } function contentcontrollerInit($controller) { self::choose_site_stage(); } function modelascontrollerInit($controller) { self::choose_site_stage(); } protected static $reading_mode = null; function updateFieldLabels(&$labels) { $labels['Versions'] = _t('Versioned.has_many_Versions', 'Versions', PR_MEDIUM, 'Past Versions of this page'); } function flushCache() { self::$cache_versionnumber = array(); } /** * Return a piece of text to keep DataObject cache keys appropriately specific */ function cacheKeyComponent() { return 'versionedmode-'.self::get_reading_mode(); } } /** * Represents a single version of a record. * * @package sapphire * @subpackage model * * @see Versioned */ class Versioned_Version extends ViewableData { protected $record; protected $object; function __construct($record) { $this->record = $record; $record['ID'] = $record['RecordID']; $className = $record['ClassName']; $this->object = new $className($record); $this->failover = $this->object; parent::__construct(); } function PublishedClass() { return $this->record['WasPublished'] ? 'published' : 'internal'; } function Author() { return DataObject::get_by_id("Member", $this->record['AuthorID']); } function Publisher() { if( !$this->record['WasPublished'] ) return null; return DataObject::get_by_id("Member", $this->record['PublisherID']); } function Published() { return !empty( $this->record['WasPublished'] ); } }