Version: current

       1 <?php
       2 /**
       3  * The Versioned decorator allows your DataObjects to have several versions, allowing
       4  * you to rollback changes and view history. An example of this is the pages used in the CMS.
       5  * @package sapphire
       6  * @subpackage model
       7  */
       8 class Versioned extends DataObjectDecorator {
       9     /**
      10      * An array of possible stages.
      11      * @var array
      12      */
      13     protected $stages;
      14
      15     /**
      16      * The 'default' stage.
      17      * @var string
      18      */
      19     protected $defaultStage;
      20
      21     /**
      22      * The 'live' stage.
      23      * @var string
      24      */
      25     protected $liveStage;
      26
      27     /**
      28      * A version that a DataObject should be when it is 'migrating',
      29      * that is, when it is in the process of moving from one stage to another.
      30      * @var string
      31      */
      32     public $migratingVersion;
      33
      34     /**
      35      * A cache used by get_versionnumber_by_stage().
      36      * Clear through {@link flushCache()}.
      37      *
      38      * @var array
      39      */
      40     protected static $cache_versionnumber;
      41
      42     /**
      43      * Additional database columns for the new
      44      * "_versions" table. Used in {@link augmentDatabase()}
      45      * and all Versioned calls decorating or creating
      46      * SELECT statements.
      47      *
      48      * @var array $db_for_versions_table
      49      */
      50     static $db_for_versions_table = array(
      51         "RecordID" => "Int",
      52         "Version" => "Int",
      53         "WasPublished" => "Boolean",
      54         "AuthorID" => "Int",
      55         "PublisherID" => "Int"
      56     );
      57
      58     /**
      59      * Additional database indexes for the new
      60      * "_versions" table. Used in {@link augmentDatabase()}.
      61      *
      62      * @var array $indexes_for_versions_table
      63      */
      64     static $indexes_for_versions_table = array(
      65         'RecordID_Version' => '(RecordID,Version)',
      66         'RecordID' => true,
      67         'Version' => true,
      68         'AuthorID' => true,
      69         'PublisherID' => true,
      70     );
      71
      72     static $versions_ttl = 20;
      73
      74     /**
      75      * Reset static configuration variables to their default values
      76      */
      77     static function reset() {
      78         self::$reading_mode = '';
      79
      80         Session::clear('readingMode');
      81     }
      82
      83     /**
      84      * Construct a new Versioned object.
      85      * @var array $stages The different stages the versioned object can be.
      86      * The first stage is consiedered the 'default' stage, the last stage is
      87      * considered the 'live' stage.
      88      */
      89     function __construct($stages) {
      90         parent::__construct();
      91
      92         if(!is_array($stages)) {
      93             $stages = func_get_args();
      94         }
      95         $this->stages = $stages;
      96         $this->defaultStage = reset($stages);
      97         $this->liveStage = array_pop($stages);
      98     }
      99
     100     function extraStatics() {
     101         return array(
     102             'db' => array(
     103                 'Version' => 'Int',
     104             ),
     105             'has_many' => array(
     106                 'Versions' => 'SiteTree',
     107             )
     108         );
     109     }
     110
     111     function augmentSQL(SQLQuery &$query) {
     112         // Get the content at a specific date
     113         if($date = Versioned::current_archived_date()) {
     114             foreach($query->from as $table => $dummy) {
     115                 if(!isset($baseTable)) {
     116                     $baseTable = $table;
     117                 }
     118                 $query->renameTable($table, $table . '_versions');
     119                 $query->replaceText("\"$table\".\"ID\"", "\"$table\".\"RecordID\"");
     120
     121                 // Add all <basetable>_versions columns
     122                 foreach(self::$db_for_versions_table as $name => $type) {
     123                     $query->select[] = sprintf('"%s_versions"."%s"', $baseTable, $name);
     124                 }
     125                 $query->select[] = sprintf('"%s_versions"."%s" AS "ID"', $baseTable, 'RecordID');
     126
     127                 if($table != $baseTable) {
     128                     $query->from[$table] .= " AND \"{$table}_versions\".\"Version\" = \"{$baseTable}_versions\".\"Version\"";
     129                 }
     130             }
     131
     132             // Link to the version archived on that date
     133             $archiveTable = $this->requireArchiveTempTable($baseTable, $date);
     134             $query->from[$archiveTable] = "INNER JOIN \"$archiveTable\"
     135                 ON \"$archiveTable\".\"ID\" = \"{$baseTable}_versions\".\"RecordID\"
     136                 AND \"$archiveTable\".\"Version\" = \"{$baseTable}_versions\".\"Version\"";
     137
     138         // Get a specific stage
     139         } else if(Versioned::current_stage() && Versioned::current_stage() != $this->defaultStage
     140                     && array_search(Versioned::current_stage(), $this->stages) !== false) {
     141             foreach($query->from as $table => $dummy) {
     142                 $query->renameTable($table, $table . '_' . Versioned::current_stage());
     143             }
     144         }
     145     }
     146
     147     /**
     148      * Keep track of the archive tables that have been created
     149      */
     150     private static $archive_tables = array();
     151
     152     /**
     153      * Called by {@link SapphireTest} when the database is reset.
     154      * @todo Reduce the coupling between this and SapphireTest, somehow.
     155      */
     156     public static function on_db_reset() {
     157         // Drop all temporary tables
     158         $db = DB::getConn();
     159         foreach(self::$archive_tables as $tableName) {
     160             if(method_exists($db, 'dropTable')) $db->dropTable($tableName);
     161             else $db->query("DROP TABLE \"$tableName\"");
     162         }
     163
     164         // Remove references to them
     165         self::$archive_tables = array();
     166     }
     167
     168     /**
     169      * Create a temporary table mapping each database record to its version on the given date.
     170      * This is used by the versioning system to return database content on that date.
     171      * @param string $baseTable The base table.
     172      * @param string $date The date.  If omitted, then the latest version of each page will be returned.
     173      * @todo Ensure that this is DB abstracted
     174      */
     175     protected static function requireArchiveTempTable($baseTable, $date = null) {
     176         if(!isset(self::$archive_tables[$baseTable])) {
     177             self::$archive_tables[$baseTable] = DB::createTable("_Archive$baseTable", array(
     178                 "ID" => "INT NOT NULL",
     179                 "Version" => "INT NOT NULL",
     180             ), null, array('temporary' => true));
     181         }
     182
     183         if(!DB::query("SELECT COUNT(*) FROM \"" . self::$archive_tables[$baseTable] . "\"")->value()) {
     184             if($date) $dateClause = "WHERE \"LastEdited\" <= '$date'";
     185             else $dateClause = "";
     186
     187             DB::query("INSERT INTO \"" . self::$archive_tables[$baseTable] . "\"
     188                 SELECT \"RecordID\", max(\"Version\") FROM \"{$baseTable}_versions\"
     189                 $dateClause
     190                 GROUP BY \"RecordID\"");
     191         }
     192
     193         return self::$archive_tables[$baseTable];
     194     }
     195
     196     /**
     197      * An array of DataObject extensions that may require versioning for extra tables
     198      * The array value is a set of suffixes to form these table names, assuming a preceding '_'.
     199      * E.g. if Extension1 creates a new table 'Class_suffix1'
     200      * and Extension2 the tables 'Class_suffix2' and 'Class_suffix3':
     201      *
     202      *  $versionableExtensions = array(
     203      *      'Extension1' => 'suffix1',
     204      *      'Extension2' => array('suffix2', 'suffix3'),
     205      *  );
     206      *
     207      * Make sure your extension has a static $enabled-property that determines if it is
     208      * processed by Versioned.
     209      *
     210      * @var array
     211      */
     212     protected static $versionableExtensions = array('Translatable' => 'lang');
     213
     214     function augmentDatabase() {
     215         $classTable = $this->owner->class;
     216
     217         $isRootClass = ($this->owner->class == ClassInfo::baseDataClass($this->owner->class));
     218
     219         // Build a list of suffixes whose tables need versioning
     220         $allSuffixes = array();
     221         foreach (Versioned::$versionableExtensions as $versionableExtension => $suffixes) {
     222             if ($this->owner->hasExtension($versionableExtension)) {
     223                 $allSuffixes = array_merge($allSuffixes, (array)$suffixes);
     224                 foreach ((array)$suffixes as $suffix) {
     225                     $allSuffixes[$suffix] = $versionableExtension;
     226                 }
     227             }
     228         }
     229
     230         // Add the default table with an empty suffix to the list (table name = class name)
     231         array_push($allSuffixes,'');
     232
     233         foreach ($allSuffixes as $key => $suffix) {
     234             // check that this is a valid suffix
     235             if (!is_int($key)) continue;
     236
     237             if ($suffix) $table = "{$classTable}_$suffix";
     238             else $table = $classTable;
     239
     240             if($fields = DataObject::database_fields($this->owner->class)) {
     241                 $indexes = $this->owner->databaseIndexes();
     242                 if ($suffix && ($ext = $this->owner->getExtensionInstance($allSuffixes[$suffix]))) {
     243                     if (!$ext->isVersionedTable($table)) continue;
     244                     $ext->setOwner($this->owner);
     245                     $fields = $ext->fieldsInExtraTables($suffix);
     246                     $ext->clearOwner();
     247                     $indexes = $fields['indexes'];
     248                     $fields = $fields['db'];
     249                 }
     250
     251                 // Create tables for other stages
     252                 foreach($this->stages as $stage) {
     253                     // Extra tables for _Live, etc.
     254                     if($stage != $this->defaultStage) {
     255                         DB::requireTable("{$table}_$stage", $fields, $indexes, false);
     256                     }
     257
     258                     // Version fields on each root table (including Stage)
     259                     /*
     260                     if($isRootClass) {
     261                         $stageTable = ($stage == $this->defaultStage) ? $table : "{$table}_$stage";
     262                         $parts=Array('datatype'=>'int', 'precision'=>11, 'null'=>'not null', 'default'=>(int)0);
     263                         $values=Array('type'=>'int', 'parts'=>$parts);
     264                         DB::requireField($stageTable, 'Version', $values);
     265                     }
     266                     */
     267                 }
     268
     269                 if($isRootClass) {
     270                     // Create table for all versions
     271                     $versionFields = array_merge(
     272                         self::$db_for_versions_table,
     273                         (array)$fields
     274                     );
     275
     276                     $versionIndexes = array_merge(
     277                         self::$indexes_for_versions_table,
     278                         (array)$indexes
     279                     );
     280                 } else {
     281                     // Create fields for any tables of subclasses
     282                     $versionFields = array_merge(
     283                         array(
     284                             "RecordID" => "Int",
     285                             "Version" => "Int",
     286                         ),
     287                         (array)$fields
     288                     );
     289
     290                     $versionIndexes = array_merge(
     291                         array(
     292                             'RecordID_Version' => array('type' => 'unique', 'value' => 'RecordID,Version'),
     293                             'RecordID' => true,
     294                             'Version' => true,
     295                         ),
     296                         (array)$indexes
     297                     );
     298                 }
     299
     300                 if(DB::getConn()->hasTable("{$table}_versions")) {
     301                     // Fix data that lacks the uniqueness constraint (since this was added later and
     302                     // bugs meant that the constraint was validated)
     303                     $duplications = DB::query("SELECT MIN(\"ID\") AS \"ID\", \"RecordID\", \"Version\"
     304                         FROM \"{$table}_versions\" GROUP BY \"RecordID\", \"Version\"
     305                         HAVING COUNT(*) > 1");
     306
     307                     foreach($duplications as $dup) {
     308                         DB::alteration_message("Removing {$table}_versions duplicate data for "
     309                             ."{$dup['RecordID']}/{$dup['Version']}" ,"deleted");
     310                         DB::query("DELETE FROM \"{$table}_versions\" WHERE \"RecordID\" = {$dup['RecordID']}
     311                             AND \"Version\" = {$dup['Version']} AND \"ID\" != {$dup['ID']}");
     312                     }
     313
     314                     // Remove junk which has no data in parent classes. Only needs to run the following
     315                     // when versioned data is spread over multiple tables
     316                     if(!$isRootClass && ($versionedTables = ClassInfo::dataClassesFor($table))) {
     317
     318                         foreach($versionedTables as $child) {
     319                             if($table == $child) break; // only need subclasses
     320                             $count = DB::query("
     321                                 SELECT COUNT(*) FROM \"{$table}_versions\"
     322                                 LEFT JOIN \"{$child}_versions\"
     323                                     ON \"{$child}_versions\".\"RecordID\" = \"{$table}_versions\".\"RecordID\"
     324                                     AND \"{$child}_versions\".\"Version\" = \"{$table}_versions\".\"Version\"
     325                                 WHERE \"{$child}_versions\".\"ID\" IS NULL
     326                             ")->value();
     327
     328                             if($count > 0) {
     329                                 DB::alteration_message("Removing orphaned versioned records", "deleted");
     330
     331                                 $effectedIDs = DB::query("
     332                                     SELECT \"{$table}_versions\".\"ID\" FROM \"{$table}_versions\"
     333                                     LEFT JOIN \"{$child}_versions\"
     334                                         ON \"{$child}_versions\".\"RecordID\" = \"{$table}_versions\".\"RecordID\"
     335                                         AND \"{$child}_versions\".\"Version\" = \"{$table}_versions\".\"Version\"
     336                                     WHERE \"{$child}_versions\".\"ID\" IS NULL
     337                                 ")->column();
     338
     339                                 if(is_array($effectedIDs)) {
     340                                     foreach($effectedIDs as $key => $value) {
     341                                         DB::query("DELETE FROM \"{$table}_versions\" WHERE \"{$table}_versions\".\"ID\" = '$value'");
     342                                     }
     343                                 }
     344                             }
     345                         }
     346                     }
     347                 }
     348
     349                 DB::requireTable("{$table}_versions", $versionFields, $versionIndexes);
     350             } else {
     351                 DB::dontRequireTable("{$table}_versions");
     352                 foreach($this->stages as $stage) {
     353                     if($stage != $this->defaultStage) DB::dontrequireTable("{$table}_$stage");
     354                 }
     355             }
     356         }
     357     }
     358
     359     /**
     360      * Augment a write-record request.
     361      * @param SQLQuery $manipulation Query to augment.
     362      */
     363     function augmentWrite(&$manipulation) {
     364         $tables = array_keys($manipulation);
     365         $version_table = array();
     366         foreach($tables as $table) {
     367             $baseDataClass = ClassInfo::baseDataClass($table);
     368
     369             $isRootClass = ($table == $baseDataClass);
     370
     371             // Make sure that the augmented write is being applied to a table that can be versioned
     372             if( !$this->canBeVersioned($table) ) {
     373                 unset($manipulation[$table]);
     374                 continue;
     375             }
     376             $id = $manipulation[$table]['id'] ? $manipulation[$table]['id'] : $manipulation[$table]['fields']['ID'];;
     377             if(!$id) user_error("Couldn't find ID in " . var_export($manipulation[$table], true), E_USER_ERROR);
     378
     379             $rid = isset($manipulation[$table]['RecordID']) ? $manipulation[$table]['RecordID'] : $id;
     380
     381             $newManipulation = array(
     382                 "command" => "insert",
     383                 "fields" => isset($manipulation[$table]['fields']) ? $manipulation[$table]['fields'] : null
     384             );
     385
     386             if($this->migratingVersion) {
     387                 $manipulation[$table]['fields']['Version'] = $this->migratingVersion;
     388             }
     389
     390             // If we haven't got a version #, then we're creating a new version.
     391             // Otherwise, we're just copying a version to another table
     392             if(!isset($manipulation[$table]['fields']['Version'])) {
     393                 // Add any extra, unchanged fields to the version record.
     394                 $data = DB::query("SELECT * FROM \"$table\" WHERE \"ID\" = $id")->record();
     395                 if($data) foreach($data as $k => $v) {
     396                     if (!isset($newManipulation['fields'][$k])) $newManipulation['fields'][$k] = "'" . DB::getConn()->addslashes($v) . "'";
     397                 }
     398
     399                 // Set up a new entry in (table)_versions
     400                 $newManipulation['fields']['RecordID'] = $rid;
     401                 unset($newManipulation['fields']['ID']);
     402
     403                 // Create a new version #
     404                 if (isset($version_table[$table])) $nextVersion = $version_table[$table];
     405                 else unset($nextVersion);
     406
     407                 if($rid && !isset($nextVersion)) $nextVersion = DB::query("SELECT MAX(\"Version\") + 1 FROM \"{$baseDataClass}_versions\" WHERE \"RecordID\" = $rid")->value();
     408
     409                 $newManipulation['fields']['Version'] = $nextVersion ? $nextVersion : 1;
     410
     411                 if($isRootClass) {
     412                     $userID = (Member::currentUser()) ? Member::currentUser()->ID : 0;
     413                     $newManipulation['fields']['AuthorID'] = $userID;
     414                 }
     415
     416
     417
     418                 $manipulation["{$table}_versions"] = $newManipulation;
     419
     420                 // Add the version number to this data
     421                 $manipulation[$table]['fields']['Version'] = $newManipulation['fields']['Version'];
     422                 $version_table[$table] = $nextVersion;
     423             }
     424
     425             // Putting a Version of -1 is a signal to leave the version table alone, despite their being no version
     426             if($manipulation[$table]['fields']['Version'] < 0) unset($manipulation[$table]['fields']['Version']);
     427
     428             if(!$this->hasVersionField($table)) unset($manipulation[$table]['fields']['Version']);
     429
     430             // Grab a version number - it should be the same across all tables.
     431             if(isset($manipulation[$table]['fields']['Version'])) $thisVersion = $manipulation[$table]['fields']['Version'];
     432
     433             // If we're editing Live, then use (table)_Live instead of (table)
     434             if(Versioned::current_stage() && Versioned::current_stage() != $this->defaultStage) {
     435                 // If the record has already been inserted in the (table), get rid of it.
     436                 if($manipulation[$table]['command']=='insert') {
     437                     DB::query("DELETE FROM \"{$table}\" WHERE \"ID\"='$id'");
     438                 }
     439
     440                 $newTable = $table . '_' . Versioned::current_stage();
     441                 $manipulation[$newTable] = $manipulation[$table];
     442                 unset($manipulation[$table]);
     443             }
     444         }
     445
     446         // Clear the migration flag
     447         if($this->migratingVersion) $this->migrateVersion(null);
     448
     449         // Add the new version # back into the data object, for accessing after this write
     450         if(isset($thisVersion)) $this->owner->Version = str_replace("'","",$thisVersion);
     451     }
     452
     453     /**
     454      * If a write was skipped, then we need to ensure that we don't leave a migrateVersion()
     455      * value lying around for the next write.
     456      */
     457     function onAfterSkippedWrite() {
     458         $this->migrateVersion(null);
     459     }
     460
     461     /**
     462      * Determine if a table is supporting the Versioned extensions (e.g. $table_versions does exists)
     463      *
     464      * @param string $table Table name
     465      * @return boolean
     466      */
     467     function canBeVersioned($table) {
     468         return ClassInfo::exists($table)
     469             && ClassInfo::is_subclass_of($table, 'DataObject')
     470             && DataObject::has_own_table($table);
     471     }
     472
     473     /**
     474      * Check if a certain table has the 'Version' field
     475      *
     476      * @param string $table Table name
     477      * @return boolean Returns false if the field isn't in the table, true otherwise
     478      */
     479     function hasVersionField($table) {
     480         $rPos = strrpos($table,'_');
     481         if(($rPos !== false) && in_array(substr($table,$rPos), $this->stages)) {
     482             $tableWithoutStage = substr($table,0,$rPos);
     483         } else {
     484             $tableWithoutStage = $table;
     485         }
     486         return ('DataObject' == get_parent_class($tableWithoutStage));
     487     }
     488     function extendWithSuffix($table) {
     489         foreach (Versioned::$versionableExtensions as $versionableExtension => $suffixes) {
     490             if ($this->owner->hasExtension($versionableExtension)) {
     491                 $ext = $this->owner->getExtensionInstance($versionableExtension);
     492                 $ext->setOwner($this->owner);
     493                 $table = $ext->extendWithSuffix($table);
     494                 $ext->clearOwner();
     495             }
     496         }
     497         return $table;
     498     }
     499
     500     //-----------------------------------------------------------------------------------------------//
     501
     502     /**
     503      * Get the latest published DataObject.
     504      * @return DataObject
     505      */
     506     function latestPublished() {
     507         // Get the root data object class - this will have the version field
     508         $table1 = $this->owner->class;
     509         while( ($p = get_parent_class($table1)) != "DataObject") $table1 = $p;
     510
     511         $table2 = $table1 . "_$this->liveStage";
     512
     513         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();
     514     }
     515
     516     /**
     517      * Move a database record from one stage to the other.
     518      * @param fromStage Place to copy from.  Can be either a stage name or a version number.
     519      * @param toStage Place to copy to.  Must be a stage name.
     520      * @param createNewVersion Set this to true to create a new version number.  By default, the existing version number will be copied over.
     521      */
     522     function publish($fromStage, $toStage, $createNewVersion = false) {
     523         $baseClass = $this->owner->class;
     524         while( ($p = get_parent_class($baseClass)) != "DataObject") $baseClass = $p;
     525         $extTable = $this->extendWithSuffix($baseClass);
     526
     527         if(is_numeric($fromStage)) {
     528             $from = Versioned::get_version($baseClass, $this->owner->ID, $fromStage);
     529         } else {
     530             $this->owner->flushCache();
     531             $from = Versioned::get_one_by_stage($baseClass, $fromStage, "\"{$baseClass}\".\"ID\" = {$this->owner->ID}");
     532         }
     533
     534         $publisherID = isset(Member::currentUser()->ID) ? Member::currentUser()->ID : 0;
     535         if($from) {
     536             $from->forceChange();
     537             if($createNewVersion) {
     538                 $latest = self::get_latest_version($baseClass, $this->owner->ID);
     539                 $this->owner->Version = $latest->Version + 1;
     540             } else {
     541                 $from->migrateVersion($from->Version);
     542             }
     543
     544             // Mark this version as having been published at some stage
     545             DB::query("UPDATE \"{$extTable}_versions\" SET \"WasPublished\" = '1', \"PublisherID\" = $publisherID WHERE \"RecordID\" = $from->ID AND \"Version\" = $from->Version");
     546             $this->clearOldVersions();
     547
     548             $oldMode = Versioned::get_reading_mode();
     549             Versioned::reading_stage($toStage);
     550
     551             $conn = DB::getConn();
     552             if(method_exists($conn, 'allowPrimaryKeyEditing')) $conn->allowPrimaryKeyEditing($baseClass, true);
     553             $from->write();
     554             if(method_exists($conn, 'allowPrimaryKeyEditing')) $conn->allowPrimaryKeyEditing($baseClass, false);
     555
     556             $from->destroy();
     557
     558             Versioned::set_reading_mode($oldMode);
     559         } else {
     560             user_error("Can't find {$this->owner->URLSegment}/{$this->owner->ID} in stage $fromStage", E_USER_WARNING);
     561         }
     562     }
     563
     564     function clearOldVersions($ttl = 0) {
     565         $baseClass = $this->owner->class;
     566         $tables = array($baseClass);
     567         while( ($p = get_parent_class($baseClass)) != "DataObject") {
     568             $baseClass = $p;
     569             $tables[] = $baseClass;
     570         }
     571
     572         if ($ttl <= 0)
     573             $ttl = self::$versions_ttl;
     574
     575         $id = $this->owner->ID;
     576         $extTable = $this->extendWithSuffix($baseClass);
     577         $versionsToSave = DB::query("SELECT Version FROM \"{$extTable}_versions\" WHERE WasPublished=1 AND RecordID={$id} ORDER BY ID DESC LIMIT {$ttl}")->column();
     578         $versionsToSave = array_merge($versionsToSave, DB::query("SELECT Version FROM \"{$extTable}_versions\" WHERE WasPublished=0 AND RecordID={$id} ORDER BY ID DESC LIMIT {$ttl}")->column());
     579         $deleted = 0;
     580         if (count($versionsToSave)) {
     581             $versionsToSave = join(',', $versionsToSave);
     582             foreach ($tables as $table) {
     583                 $extTable = $this->extendWithSuffix($table);
     584                 if (DB::getConn()->hasTable("{$extTable}_versions")) {
     585                     DB::query("DELETE FROM \"{$extTable}_versions\" WHERE RecordID={$id} AND Version not in ({$versionsToSave})");
     586                     $deleted += DB::affectedRows();
     587
     588                     $duplications = DB::query("SELECT MIN(\"ID\") AS \"ID\", \"RecordID\", \"Version\"
     589                         FROM \"{$table}_versions\" WHERE \"RecordID\"={$id} GROUP BY \"RecordID\", \"Version\"
     590                         HAVING COUNT(*) > 1");
     591
     592                     foreach($duplications as $dup) {
     593                         DB::query("DELETE FROM \"{$table}_versions\" WHERE \"RecordID\" = {$dup['RecordID']} AND \"Version\" = {$dup['Version']} AND \"ID\" != {$dup['ID']}");
     594                     }
     595                 }
     596             }
     597         }
     598         return $deleted;
     599     }
     600
     601     /**
     602      * Set the migrating version.
     603      * @param string $version The version.
     604      */
     605     function migrateVersion($version) {
     606         $this->migratingVersion = $version;
     607     }
     608
     609     /**
     610      * Compare two stages to see if they're different.
     611      * Only checks the version numbers, not the actual content.
     612      * @param string $stage1 The first stage to check.
     613      * @param string $stage2
     614      */
     615     function stagesDiffer($stage1, $stage2) {
     616         $table1 = $this->baseTable($stage1);
     617         $table2 = $this->baseTable($stage2);
     618
     619         if(!is_numeric($this->owner->ID)) {
     620             return true;
     621         }
     622
     623         // We test for equality - if one of the versions doesn't exist, this will be false
     624         //TODO: DB Abstraction: if statement here:
     625         $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();
     626         return !$stagesAreEqual;
     627     }
     628
     629     function Versions($filter = "", $sort = "", $limit = "", $join = "", $having = "") {
     630         return $this->allVersions($filter, $sort, $limit, $join, $having);
     631     }
     632
     633     /**
     634      * Return a list of all the versions available.
     635      * @param string $filter
     636      */
     637     function allVersions($filter = "", $sort = "", $limit = "", $join = "", $having = "") {
     638         // Make sure the table names are not postfixed (e.g. _Live)
     639         $oldMode = self::get_reading_mode();
     640         self::reading_stage('Stage');
     641
     642         $query = $this->owner->extendedSQL($filter, $sort, $limit, $join, $having);
     643
     644         foreach($query->from as $table => $tableJoin) {
     645             if($tableJoin[0] == '"') $baseTable = str_replace('"','',$tableJoin);
     646             else if (substr($tableJoin,0,5) != 'INNER') $query->from[$table] = "LEFT JOIN \"$table\" ON \"$table\".\"RecordID\" = \"{$baseTable}_versions\".\"RecordID\" AND \"$table\".\"Version\" = \"{$baseTable}_versions\".\"Version\"";
     647             $query->renameTable($table, $table . '_versions');
     648         }
     649
     650         // Add all <basetable>_versions columns
     651         foreach(self::$db_for_versions_table as $name => $type) {
     652             $query->select[] = sprintf('"%s_versions"."%s"', $baseTable, $name);
     653         }
     654
     655         $query->where[] = "\"{$baseTable}_versions\".\"RecordID\" = '{$this->owner->ID}'";
     656         $query->orderby = ($sort) ? $sort : "\"{$baseTable}_versions\".\"LastEdited\" DESC, \"{$baseTable}_versions\".\"Version\" DESC";
     657         $query->distinct = true;
     658
     659         $records = $query->execute();
     660         $versions = new DataObjectSet();
     661
     662         foreach($records as $record) {
     663             $versions->push(new Versioned_Version($record));
     664         }
     665
     666         Versioned::set_reading_mode($oldMode);
     667         return $versions;
     668     }
     669
     670     /**
     671      * Compare two version, and return the diff between them.
     672      * @param string $from The version to compare from.
     673      * @param string $to The version to compare to.
     674      * @return DataObject
     675      */
     676     function compareVersions($from, $to) {
     677         $fromRecord = Versioned::get_version($this->owner->class, $this->owner->ID, $from);
     678         $toRecord = Versioned::get_version($this->owner->class, $this->owner->ID, $to);
     679
     680         $diff = new DataDifferencer($fromRecord, $toRecord);
     681         return $diff->diffedData();
     682     }
     683
     684     /**
     685      * Return the base table - the class that directly extends DataObject.
     686      * @return string
     687      */
     688     function baseTable($stage = null) {
     689         $tableClasses = ClassInfo::dataClassesFor($this->owner->class);
     690         $baseClass = array_shift($tableClasses);
     691         return (!$stage || $stage == $this->defaultStage) ? $baseClass : $baseClass . "_$stage";
     692     }
     693
     694     //-----------------------------------------------------------------------------------------------//
     695
     696     /**
     697      * Choose the stage the site is currently on.
     698      * If $_GET['stage'] is set, then it will use that stage, and store it in the session.
     699      * if $_GET['archiveDate'] is set, it will use that date, and store it in the session.
     700      * If neither of these are set, it checks the session, otherwise the stage is set to 'Live'.
     701      */
     702     static function choose_site_stage() {
     703         if(isset($_GET['stage'])) {
     704             $stage = ucfirst(strtolower($_GET['stage']));
     705
     706             if(!in_array($stage, array('Stage', 'Live'))) $stage = 'Live';
     707
     708             Session::set('readingMode', 'Stage.' . $stage);
     709         }
     710         if(isset($_GET['archiveDate'])) {
     711             Session::set('readingMode', 'Archive.' . $_GET['archiveDate']);
     712         }
     713
     714         if($mode = Session::get('readingMode')) {
     715             Versioned::set_reading_mode($mode);
     716         } else {
     717             Versioned::reading_stage("Live");
     718         }
     719
     720         if(!headers_sent()) {
     721             if(Versioned::current_stage() == 'Live') {
     722                 Cookie::set('bypassStaticCache', null, 0, null, null, false, true /* httponly */);
     723             } else {
     724                 Cookie::set('bypassStaticCache', '1', 0, null, null, false, true /* httponly */);
     725             }
     726         }
     727     }
     728
     729     /**
     730      * Set the current reading mode.
     731      */
     732     static function set_reading_mode($mode) {
     733         Versioned::$reading_mode = $mode;
     734     }
     735
     736     /**
     737      * Get the current reading mode.
     738      * @return string
     739      */
     740     static function get_reading_mode() {
     741         return Versioned::$reading_mode;
     742     }
     743
     744     /**
     745      * Get the name of the 'live' stage.
     746      * @return string
     747      */
     748     static function get_live_stage() {
     749         return "Live";
     750     }
     751
     752     /**
     753      * Get the current reading stage.
     754      * @return string
     755      */
     756     static function current_stage() {
     757         $parts = explode('.', Versioned::get_reading_mode());
     758         if($parts[0] == 'Stage') return $parts[1];
     759     }
     760
     761     /**
     762      * Get the current archive date.
     763      * @return string
     764      */
     765     static function current_archived_date() {
     766         $parts = explode('.', Versioned::get_reading_mode());
     767         if($parts[0] == 'Archive') return $parts[1];
     768     }
     769
     770     /**
     771      * Set the reading stage.
     772      * @param string $stage New reading stage.
     773      */
     774     static function reading_stage($stage) {
     775         Versioned::set_reading_mode('Stage.' . $stage);
     776     }
     777
     778     /**
     779      * Set the reading archive date.
     780      * @param string $date New reading archived date.
     781      */
     782     static function reading_archived_date($date) {
     783         Versioned::set_reading_mode('Archive.' . $date);
     784     }
     785
     786
     787     /**
     788      * Get a singleton instance of a class in the given stage.
     789      *
     790      * @param string $class The name of the class.
     791      * @param string $stage The name of the stage.
     792      * @param string $filter A filter to be inserted into the WHERE clause.
     793      * @param boolean $cache Use caching.
     794      * @param string $orderby A sort expression to be inserted into the ORDER BY clause.
     795      * @return DataObject
     796      */
     797     static function get_one_by_stage($class, $stage, $filter = '', $cache = true, $orderby = '') {
     798         $oldMode = Versioned::get_reading_mode();
     799         Versioned::reading_stage($stage);
     800
     801         singleton($class)->flushCache();
     802         $result = DataObject::get_one($class, $filter, $cache, $orderby);
     803         singleton($class)->flushCache();
     804
     805         Versioned::set_reading_mode($oldMode);
     806         return $result;
     807     }
     808
     809     /**
     810      * Gets the current version number of a specific record.
     811      *
     812      * @param string $class
     813      * @param string $stage
     814      * @param int $id
     815      * @param boolean $cache
     816      * @return int
     817      */
     818     static function get_versionnumber_by_stage($class, $stage, $id, $cache = true) {
     819         $baseClass = ClassInfo::baseDataClass($class);
     820         $stageTable = ($stage == 'Stage') ? $baseClass : "{$baseClass}_{$stage}";
     821
     822         // cached call
     823         if($cache && isset(self::$cache_versionnumber[$baseClass][$stage][$id])) {
     824             return self::$cache_versionnumber[$baseClass][$stage][$id];
     825         }
     826
     827         // get version as performance-optimized SQL query (gets called for each page in the sitetree)
     828         $version = DB::query("SELECT \"Version\" FROM \"$stageTable\" WHERE \"ID\" = $id")->value();
     829
     830         // cache value (if required)
     831         if($cache) {
     832             if(!isset(self::$cache_versionnumber[$baseClass])) self::$cache_versionnumber[$baseClass] = array();
     833             if(!isset(self::$cache_versionnumber[$baseClass][$stage])) self::$cache_versionnumber[$baseClass][$stage] = array();
     834             self::$cache_versionnumber[$baseClass][$stage][$id] = $version;
     835         }
     836
     837         return $version;
     838     }
     839
     840     /**
     841      * Pre-populate the cache for Versioned::get_versionnumber_by_stage() for a list of record IDs,
     842      * for more efficient database querying.  If $idList is null, then every page will be pre-cached.
     843      */
     844     static function prepopulate_versionnumber_cache($class, $stage, $idList = null) {
     845         $filter = "";
     846         if($idList) {
     847             // Validate the ID list
     848             foreach($idList as $id) if(!is_numeric($id)) user_error("Bad ID passed to Versioned::prepopulate_versionnumber_cache() in \$idList: " . $id, E_USER_ERROR);
     849             $filter = "WHERE \"ID\" IN(" .implode(", ", $idList) . ")";
     850         }
     851
     852         $baseClass = ClassInfo::baseDataClass($class);
     853         $stageTable = ($stage == 'Stage') ? $baseClass : "{$baseClass}_{$stage}";
     854
     855         $versions = DB::query("SELECT \"ID\", \"Version\" FROM \"$stageTable\" $filter")->map();
     856         foreach($versions as $id => $version) {
     857             self::$cache_versionnumber[$baseClass][$stage][$id] = $version;
     858         }
     859     }
     860
     861     /**
     862      * Get a set of class instances by the given stage.
     863      *
     864      * @param string $class The name of the class.
     865      * @param string $stage The name of the stage.
     866      * @param string $filter A filter to be inserted into the WHERE clause.
     867      * @param string $sort A sort expression to be inserted into the ORDER BY clause.
     868      * @param string $join A join expression, such as LEFT JOIN or INNER JOIN
     869      * @param int $limit A limit on the number of records returned from the database.
     870      * @param string $containerClass The container class for the result set (default is DataObjectSet)
     871      * @return DataObjectSet
     872      */
     873     static function get_by_stage($class, $stage, $filter = '', $sort = '', $join = '', $limit = '', $containerClass = 'DataObjectSet') {
     874         $oldMode = Versioned::get_reading_mode();
     875         Versioned::reading_stage($stage);
     876         $result = DataObject::get($class, $filter, $sort, $join, $limit, $containerClass);
     877         Versioned::set_reading_mode($oldMode);
     878         return $result;
     879     }
     880
     881     function deleteFromStage($stage) {
     882         $oldMode = Versioned::get_reading_mode();
     883         Versioned::reading_stage($stage);
     884         $clone = clone $this->owner;
     885         $result = $clone->delete();
     886         Versioned::set_reading_mode($oldMode);
     887
     888         // Fix the version number cache (in case you go delete from stage and then check ExistsOnLive)
     889         $baseClass = ClassInfo::baseDataClass($this->owner->class);
     890         self::$cache_versionnumber[$baseClass][$stage][$this->owner->ID] = null;
     891
     892         return $result;
     893     }
     894
     895     function writeToStage($stage, $forceInsert = false) {
     896         $oldMode = Versioned::get_reading_mode();
     897         Versioned::reading_stage($stage);
     898         $result = $this->owner->write(false, $forceInsert);
     899         Versioned::set_reading_mode($oldMode);
     900         return $result;
     901     }
     902
     903     /**
     904      * Build a SQL query to get data from the _version table.
     905      * This function is similar in style to {@link DataObject::buildSQL}
     906      */
     907     function buildVersionSQL($filter = "", $sort = "") {
     908         $query = $this->owner->extendedSQL($filter,$sort);
     909         foreach($query->from as $table => $join) {
     910             if($join[0] == '"') $baseTable = str_replace('"','',$join);
     911             else $query->from[$table] = "LEFT JOIN \"$table\" ON \"$table\".\"RecordID\" = \"{$baseTable}_versions\".\"RecordID\" AND \"$table\".\"Version\" = \"{$baseTable}_versions\".\"Version\"";
     912             $query->renameTable($table, $table . '_versions');
     913         }
     914
     915         // Add all <basetable>_versions columns
     916         foreach(self::$db_for_versions_table as $name => $type) {
     917             $query->select[] = sprintf('"%s_versions"."%s"', $baseTable, $name);
     918         }
     919         $query->select[] = sprintf('"%s_versions"."%s" AS "ID"', $baseTable, 'RecordID');
     920
     921         return $query;
     922     }
     923
     924     static function build_version_sql($className, $filter = "", $sort = "") {
     925         $query = singleton($className)->extendedSQL($filter,$sort);
     926         foreach($query->from as $table => $join) {
     927             if($join[0] == '"') $baseTable = str_replace('"','',$join);
     928             else $query->from[$table] = "LEFT JOIN \"$table\" ON \"$table\".\"RecordID\" = \"{$baseTable}_versions\".\"RecordID\" AND \"$table\".\"Version\" = \"{$baseTable}_versions\".\"Version\"";
     929             $query->renameTable($table, $table . '_versions');
     930         }
     931
     932         // Add all <basetable>_versions columns
     933         foreach(self::$db_for_versions_table as $name => $type) {
     934             $query->select[] = sprintf('"%s_versions"."%s"', $baseTable, $name);
     935         }
     936         $query->select[] = sprintf('"%s_versions"."%s" AS "ID"', $baseTable, 'RecordID');
     937
     938         return $query;
     939     }
     940
     941     /**
     942      * Return the latest version of the given page.
     943      *
     944      * @return DataObject
     945      */
     946     static function get_latest_version($class, $id) {
     947         $oldMode = Versioned::get_reading_mode();
     948         Versioned::set_reading_mode('');
     949
     950         $baseTable = ClassInfo::baseDataClass($class);
     951         $query = singleton($class)->buildVersionSQL("\"{$baseTable}\".\"RecordID\" = $id", "\"{$baseTable}\".\"Version\" DESC");
     952         $query->limit = 1;
     953         $record = $query->execute()->record();
     954         Versioned::set_reading_mode($oldMode);
     955
     956         if(!$record) return;
     957
     958         $className = $record['ClassName'];
     959         if(!$className) {
     960             Debug::show($query->sql());
     961             Debug::show($record);
     962             user_error("Versioned::get_version: Couldn't get $class.$id", E_USER_ERROR);
     963         }
     964
     965         return new $className($record);
     966     }
     967
     968     /**
     969      * Return the equivalent of a DataObject::get() call, querying the latest
     970      * version of each page stored in the (class)_versions tables.
     971      *
     972      * In particular, this will query deleted records as well as active ones.
     973      */
     974     static function get_including_deleted($class, $filter = "", $sort = "") {
     975         $query = self::get_including_deleted_query($class, $filter, $sort);
     976         $query->distinct = true;
     977         // Process into a DataObjectSet
     978         $SNG = singleton($class);
     979         return $SNG->buildDataObjectSet($query->execute(), 'DataObjectSet', null, $class);
     980     }
     981
     982     /**
     983      * Return the query for the equivalent of a DataObject::get() call, querying the latest
     984      * version of each page stored in the (class)_versions tables.
     985      *
     986      * In particular, this will query deleted records as well as active ones.
     987      */
     988     static function get_including_deleted_query($class, $filter = "", $sort = "") {
     989         $oldMode = Versioned::get_reading_mode();
     990         Versioned::set_reading_mode('');
     991
     992         $SNG = singleton($class);
     993
     994         // Build query
     995         $query = $SNG->buildVersionSQL($filter, $sort);
     996         $baseTable = ClassInfo::baseDataClass($class);
     997         $archiveTable = self::requireArchiveTempTable($baseTable);
     998         $query->from[$archiveTable] = "INNER JOIN \"$archiveTable\"
     999             ON \"$archiveTable\".\"ID\" = \"{$baseTable}_versions\".\"RecordID\"
    1000             AND \"$archiveTable\".\"Version\" = \"{$baseTable}_versions\".\"Version\"";
    1001
    1002         Versioned::set_reading_mode($oldMode);
    1003         return $query;
    1004     }
    1005
    1006     /**
    1007      * @return DataObject
    1008      */
    1009     static function get_version($class, $id, $version) {
    1010         $oldMode = Versioned::get_reading_mode();
    1011         Versioned::set_reading_mode('');
    1012
    1013         $baseTable = ClassInfo::baseDataClass($class);
    1014         $query = singleton($class)->buildVersionSQL("\"{$baseTable}\".\"RecordID\" = $id AND \"{$baseTable}\".\"Version\" = $version");
    1015         $record = $query->execute()->record();
    1016         $className = $record['ClassName'];
    1017         if(!$className) {
    1018             Debug::show($query->sql());
    1019             Debug::show($record);
    1020             user_error("Versioned::get_version: Couldn't get $class.$id, version $version", E_USER_ERROR);
    1021         }
    1022
    1023         Versioned::set_reading_mode($oldMode);
    1024         return new $className($record);
    1025     }
    1026
    1027     /**
    1028      * @return DataObject
    1029      */
    1030     static function get_all_versions($class, $id, $version) {
    1031         $baseTable = ClassInfo::baseDataClass($class);
    1032         $query = singleton($class)->buildVersionSQL("\"{$baseTable}\".\"RecordID\" = $id AND \"{$baseTable}\".\"Version\" = $version");
    1033         $record = $query->execute()->record();
    1034         $className = $record['ClassName'];
    1035         if(!$className) {
    1036             Debug::show($query->sql());
    1037             Debug::show($record);
    1038             user_error("Versioned::get_version: Couldn't get $class.$id, version $version", E_USER_ERROR);
    1039         }
    1040         return new $className($record);
    1041     }
    1042
    1043     function contentcontrollerInit($controller) {
    1044         self::choose_site_stage();
    1045     }
    1046     function modelascontrollerInit($controller) {
    1047         self::choose_site_stage();
    1048     }
    1049
    1050     protected static $reading_mode = null;
    1051
    1052     function updateFieldLabels(&$labels) {
    1053         $labels['Versions'] = _t('Versioned.has_many_Versions', 'Versions', PR_MEDIUM, 'Past Versions of this page');
    1054     }
    1055
    1056     function flushCache() {
    1057         self::$cache_versionnumber = array();
    1058     }
    1059
    1060     /**
    1061      * Return a piece of text to keep DataObject cache keys appropriately specific
    1062      */
    1063     function cacheKeyComponent() {
    1064         return 'stage-'.self::current_stage();
    1065     }
    1066 }
    1067
    1068 /**
    1069  * Represents a single version of a record.
    1070  * @package sapphire
    1071  * @subpackage model
    1072  * @see Versioned
    1073  */
    1074 class Versioned_Version extends ViewableData {
    1075     protected $record;
    1076     protected $object;
    1077
    1078     function __construct($record) {
    1079         $this->record = $record;
    1080         $record['ID'] = $record['RecordID'];
    1081         $className = $record['ClassName'];
    1082
    1083         $this->object = new $className($record);
    1084         $this->failover = $this->object;
    1085
    1086         parent::__construct();
    1087     }
    1088
    1089     function PublishedClass() {
    1090         return $this->record['WasPublished'] ? 'published' : 'internal';
    1091     }
    1092
    1093     function Author() {
    1094         return DataObject::get_by_id("Member", $this->record['AuthorID']);
    1095     }
    1096
    1097     function Publisher() {
    1098         if( !$this->record['WasPublished'] )
    1099             return null;
    1100
    1101         return DataObject::get_by_id("Member", $this->record['PublisherID']);
    1102     }
    1103
    1104     function Published() {
    1105         return !empty( $this->record['WasPublished'] );
    1106     }
    1107 }
    1108
    1109 ?>
    1110

[Raise a SilverStripe Framework issue/bug](https://github.com/silverstripe/silverstripe-framework/issues/new)
- [Raise a SilverStripe CMS issue/bug](https://github.com/silverstripe/silverstripe-cms/issues/new)
- Please use the Silverstripe Forums to ask development related questions. -