2007-07-19 12:40:28 +02:00
< ? php
/**
* The Versioned decorator 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 .
*/
class Versioned extends DataObjectDecorator {
/**
* An array of possible stages .
* @ var array
*/
protected $stages ;
/**
* The 'default' stage .
* @ var string
*/
protected $defaultStage ;
/**
* The 'live' stage .
* @ var string
*/
protected $liveStage ;
/**
* 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 ;
/**
* 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 augmentSQL ( SQLQuery & $query ) {
// Get the content at a specific date
if ( $date = Versioned :: $reading_archived_date ) {
foreach ( $query -> from as $table => $dummy ) {
if ( ! isset ( $baseTable )) {
$baseTable = $table ;
}
$query -> renameTable ( $table , $table . '_versions' );
$query -> replaceText ( " .ID " , " .RecordID " );
$query -> select [] = " ` { $baseTable } _versions`.RecordID AS ID " ;
if ( $table != $baseTable ) {
$query -> from [ $table ] .= " AND ` { $table } _versions`.Version = ` { $baseTable } _versions`.Version " ;
}
}
// Link to the version archived on that date
$this -> requireArchiveTempTable ( $baseTable , $date );
$query -> from [ " _Archive $baseTable " ] = " INNER JOIN `_Archive $baseTable `
ON `_Archive$baseTable` . RecordID = `{$baseTable}_versions` . RecordID
AND `_Archive$baseTable` . Version = `{$baseTable}_versions` . Version " ;
// Get a specific stage
} else if ( Versioned :: $reading_stage && Versioned :: $reading_stage != $this -> defaultStage ) {
foreach ( $query -> from as $table => $dummy ) {
$query -> renameTable ( $table , $table . '_' . Versioned :: $reading_stage );
}
}
}
/**
* Temporary table mapping each database record to its version on the given date .
* Created by requireArchiveTempTable () .
* @ var array
*/
protected static $createdArchiveTempTable = 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 .
*/
protected function requireArchiveTempTable ( $baseTable , $date ) {
if ( ! isset ( self :: $createdArchiveTempTable [ $baseTable ])) {
self :: $createdArchiveTempTable [ $baseTable ] = true ;
DB :: query ( " CREATE TEMPORARY TABLE _Archive $baseTable (
RecordID INT NOT NULL PRIMARY KEY ,
Version INT NOT NULL
) " );
DB :: query ( " INSERT INTO _Archive $baseTable
SELECT RecordID , max ( Version ) FROM { $baseTable } _versions
WHERE LastEdited <= '$date'
GROUP BY RecordID " );
}
}
function augmentDatabase () {
$table = $this -> owner -> class ;
if ( $fields = $this -> owner -> databaseFields ()) {
$indexes = $this -> owner -> databaseIndexes ();
if ( $this -> owner -> parentClass () == " DataObject " ) {
$rootTable = true ;
}
// Create tables for other stages
foreach ( $this -> stages as $stage ) {
// Extra tables for _Live, etc.
if ( $stage != $this -> defaultStage ) {
DB :: requireTable ( " { $table } _ $stage " , $fields , $indexes );
2007-08-15 12:13:27 +02:00
/*
2007-07-19 12:40:28 +02:00
if ( ! DB :: query ( " SELECT * FROM { $table } _ $stage " ) -> value ()) {
$fieldList = implode ( " , " , array_keys ( $fields ));
DB :: query ( " INSERT INTO ` { $table } _ $stage ` (ID, $fieldList )
SELECT ID , $fieldList FROM `$table` " );
}
2007-08-15 12:13:27 +02:00
*/
2007-07-19 12:40:28 +02:00
}
// Version fields on each root table (including Stage)
if ( isset ( $rootTable )) {
$stageTable = ( $stage == $this -> defaultStage ) ? $table : " { $table } _ $stage " ;
DB :: requireField ( $stageTable , " Version " , " int(11) not null default '0' " );
}
}
// Create table for all versions
$versionFields = array_merge (
array (
" RecordID " => " Int " ,
" Version " => " Int " ,
" WasPublished " => " Boolean " ,
" AuthorID " => " Int " ,
" PublisherID " => " Int "
),
( array ) $fields
);
$versionIndexes = array_merge (
array (
'RecordID_Version' => '(RecordID, Version)' ,
'RecordID' => true ,
'Version' => true ,
),
( array ) $indexes
);
DB :: requireTable ( " { $table } _versions " , $versionFields , $versionIndexes );
2007-08-15 12:13:27 +02:00
/*
2007-07-19 12:40:28 +02:00
if ( ! DB :: query ( " SELECT * FROM { $table } _versions " ) -> value ()) {
$fieldList = implode ( " , " , array_keys ( $fields ));
DB :: query ( " INSERT INTO ` { $table } _versions` ( $fieldList , RecordID, Version)
SELECT $fieldList , ID AS RecordID , 1 AS Version FROM `$table` " );
}
2007-08-15 12:13:27 +02:00
*/
2007-07-19 12:40:28 +02:00
} 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 );
foreach ( $tables as $table ) {
$dbFields = singleton ( $table ) -> databaseFields ();
// Make sure that the augmented write is being applied to a table that can be versioned
if ( ! ClassInfo :: exists ( $table ) || ! is_subclass_of ( $table , 'DataObject' ) || empty ( $dbFields ) ) {
// Debug::message( "$table doesn't exist or has no database fields" );
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_expo * rt ( $manipulation [ $table ], true ), E_USER_ERROR );
$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 ) {
$newManipulation [ 'fields' ][ $k ] = " ' " . addslashes ( $v ) . " ' " ;
}
// Set up a new entry in (table)_versions
$newManipulation [ 'fields' ][ 'RecordID' ] = $id ;
unset ( $newManipulation [ 'fields' ][ 'ID' ]);
// Create a new version #
if ( $id && ! isset ( $nextVersion )) $nextVersion = DB :: query ( " SELECT MAX(Version) + 1 FROM { $table } _versions WHERE RecordID = $id " ) -> value ();
$newManipulation [ 'fields' ][ 'Version' ] = $nextVersion ? $nextVersion : 1 ;
$newManipulation [ 'fields' ][ 'AuthorID' ] = Member :: currentUserID () ? Member :: currentUserID () : 0 ;
$manipulation [ " { $table } _versions " ] = $newManipulation ;
// Add the version number to this data
$manipulation [ $table ][ 'fields' ][ 'Version' ] = $newManipulation [ 'fields' ][ 'Version' ];
}
// 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 ( get_parent_class ( $table ) != " DataObject " ) 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 :: $reading_stage && Versioned :: $reading_stage != $this -> defaultStage ) {
$newTable = $table . '_' . Versioned :: $reading_stage ;
$manipulation [ $newTable ] = $manipulation [ $table ];
unset ( $manipulation [ $table ]);
}
}
// Add the new version # back into the data object, for accessing after this write
if ( $thisVersion ) $this -> owner -> Version = str_replace ( " ' " , " " , $thisVersion );
}
//-----------------------------------------------------------------------------------------------//
/**
* 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 ) {
$baseClass = $this -> owner -> class ;
while ( ( $p = get_parent_class ( $baseClass )) != " DataObject " ) $baseClass = $p ;
if ( is_numeric ( $fromStage )) {
$from = Versioned :: get_version ( $this -> owner -> class , $this -> owner -> ID , $fromStage );
} else {
$this -> owner -> flushCache ();
$from = Versioned :: get_one_by_stage ( $this -> owner -> class , $fromStage , " ` { $baseClass } `.ID = { $this -> owner -> ID } " );
}
2007-07-20 01:15:05 +02:00
$publisherID = isset ( Member :: currentUser () -> ID ) ? Member :: currentUser () -> ID : 0 ;
2007-07-19 12:40:28 +02:00
if ( $from ) {
$from -> forceChange ();
if ( ! $createNewVersion ) $from -> migrateVersion ( $from -> Version );
// Mark this version as having been published at some stage
DB :: query ( " UPDATE ` { $baseClass } _versions` SET WasPublished = 1, PublisherID = $publisherID WHERE RecordID = $from->ID AND Version = $from->Version " );
$oldStage = Versioned :: $reading_stage ;
Versioned :: $reading_stage = $toStage ;
$from -> write ();
$from -> destroy ();
Versioned :: $reading_stage = $oldStage ;
} 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
$stagesAreEqual = DB :: query ( " SELECT if(` $table1 `.Version=` $table2 `.Version,1,0) FROM ` $table1 ` INNER JOIN ` $table2 ` ON ` $table1 `.ID = ` $table2 `.ID AND ` $table1 `.ID = { $this -> owner -> ID } " ) -> value ();
return ! $stagesAreEqual ;
}
/**
* Return a list of all the versions available .
* @ param string $filter
*/
function allVersions ( $filter = " " ) {
$query = $this -> owner -> buildSQL ( $filter , " " );
foreach ( $query -> from as $table => $join ) {
if ( $join [ 0 ] == '`' ) $baseTable = str_replace ( '`' , '' , $join );
else $query -> from [ $table ] = " LEFT JOIN ` $table ` ON ` $table `.RecordID = ` { $baseTable } _versions`.RecordID AND ` $table `.Version = ` { $baseTable } _versions`.Version " ;
$query -> renameTable ( $table , $table . '_versions' );
}
$query -> select [] = " ` { $baseTable } _versions`.AuthorID, ` { $baseTable } _versions`.Version, ` { $baseTable } _versions`.RecordID " ;
$query -> where [] = " ` { $baseTable } _versions`.RecordID = ' { $this -> owner -> ID } ' " ;
$query -> orderby = " ` { $baseTable } _versions`.LastEdited DESC, ` { $baseTable } _versions`.Version DESC " ;
$records = $query -> execute ();
$versions = new DataObjectSet ();
foreach ( $records as $record ) {
$versions -> push ( new Versioned_Version ( $record ));
}
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 );
$fields = array_keys ( $fromRecord -> getAllFields ());
foreach ( $fields as $field ) {
if ( in_array ( $field , array ( " ID " , " Version " , " RecordID " , " AuthorID " ))) continue ;
$fromRecord -> $field = Diff :: compareHTML ( $fromRecord -> $field , $toRecord -> $field );
}
return $fromRecord ;
}
/**
* 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' ])) {
$_GET [ 'stage' ] = ucfirst ( strtolower ( $_GET [ 'stage' ]));
Session :: set ( 'currentStage' , $_GET [ 'stage' ]);
Session :: clear ( 'archiveDate' );
}
if ( isset ( $_GET [ 'archiveDate' ]))
Session :: set ( 'archiveDate' , $_GET [ 'archiveDate' ]);
if ( Session :: get ( 'archiveDate' )) {
Versioned :: reading_archived_date ( Session :: get ( 'archiveDate' ));
} else if ( Session :: get ( 'currentStage' )) {
Versioned :: reading_stage ( Session :: get ( 'currentStage' ));
} else {
Versioned :: reading_stage ( " Live " );
}
}
/**
* 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 () {
return Versioned :: $reading_stage ;
}
/**
* Get the current archive date .
* @ return string
*/
static function current_archived_date () {
return Versioned :: $reading_archived_date ;
}
/**
* Set the reading stage .
* @ param string $stage New reading stage .
*/
static function reading_stage ( $stage ) {
Versioned :: $reading_stage = $stage ;
}
/**
* Set the reading archive date .
* @ param string $date New reading archived date .
*/
static function reading_archived_date ( $date ) {
Versioned :: $reading_archived_date = $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 .
* @ return DataObject
*/
static function get_one_by_stage ( $class , $stage , $filter ) {
$oldStage = Versioned :: $reading_stage ;
Versioned :: $reading_stage = $stage ;
singleton ( $class ) -> flushCache ();
$result = DataObject :: get_one ( $class , $filter );
singleton ( $class ) -> flushCache ();
Versioned :: $reading_stage = $oldStage ;
return $result ;
}
static function get_by_stage ( $class , $stage , $filter , $sort ) {
$oldStage = Versioned :: $reading_stage ;
Versioned :: $reading_stage = $stage ;
$result = DataObject :: get ( $class , $filter , $sort );
Versioned :: $reading_stage = $oldStage ;
return $result ;
}
function deleteFromStage ( $stage ) {
$oldStage = Versioned :: $reading_stage ;
Versioned :: $reading_stage = $stage ;
$result = $this -> owner -> delete ();
Versioned :: $reading_stage = $oldStage ;
return $result ;
}
function writeToStage ( $stage , $forceInsert = false ) {
$oldStage = Versioned :: $reading_stage ;
Versioned :: $reading_stage = $stage ;
$result = $this -> owner -> write ( false , $forceInsert );
Versioned :: $reading_stage = $oldStage ;
return $result ;
}
/**
* Build a SQL query to get data from the _version table .
* This function is similar in style to { @ link DataObject :: buildSQL }
*/
function buildVersionSQL ( $filter = " " , $sort = " " ) {
$query = $this -> owner -> buildSQL ( " " , " " );
foreach ( $query -> from as $table => $join ) {
if ( $join [ 0 ] == '`' ) $baseTable = str_replace ( '`' , '' , $join );
else $query -> from [ $table ] = " LEFT JOIN ` $table ` ON ` $table `.RecordID = ` { $baseTable } _versions`.RecordID AND ` $table `.Version = ` { $baseTable } _versions`.Version " ;
$query -> renameTable ( $table , $table . '_versions' );
}
$query -> select [] = " ` { $baseTable } _versions`.AuthorID, ` { $baseTable } _versions`.Version, ` { $baseTable } _versions`.RecordID AS ID " ;
if ( $filter ) $query -> where [] = $filter ;
if ( $sort ) $query -> orderby = $sort ;
return $query ;
}
static function build_version_sql ( $className , $filter = " " , $sort = " " ) {
$query = singleton ( $className ) -> buildSQL ( " " , " " );
foreach ( $query -> from as $table => $join ) {
if ( $join [ 0 ] == '`' ) $baseTable = str_replace ( '`' , '' , $join );
else $query -> from [ $table ] = " LEFT JOIN ` $table ` ON ` $table `.RecordID = ` { $baseTable } _versions`.RecordID AND ` $table `.Version = ` { $baseTable } _versions`.Version " ;
$query -> renameTable ( $table , $table . '_versions' );
}
$query -> select [] = " ` { $baseTable } _versions`.AuthorID, ` { $baseTable } _versions`.Version, ` { $baseTable } _versions`.RecordID AS ID " ;
if ( $filter ) $query -> where [] = $filter ;
if ( $sort ) $query -> orderby = $sort ;
return $query ;
}
/**
* Return the latest version of the given page
*/
static function get_latest_version ( $class , $id ) {
$baseTable = ClassInfo :: baseDataClass ( $class );
$query = singleton ( $class ) -> buildVersionSQL ( " ` { $baseTable } _versions`.RecordID = $id " , " ` { $baseTable } _versions`.Version DESC " );
$query -> limit = 1 ;
$record = $query -> execute () -> record ();
$className = $record [ 'ClassName' ];
if ( ! $className ) {
Debug :: show ( $query -> sql ());
Debug :: show ( $record );
user_error ( " Versioned::get_version: Couldn't get $class . $id , version $version " , E_USER_ERROR );
}
return new $className ( $record );
}
static function get_version ( $class , $id , $version ) {
$baseTable = ClassInfo :: baseDataClass ( $class );
$query = singleton ( $class ) -> buildVersionSQL ( " ` { $baseTable } _versions`.RecordID = $id AND ` { $baseTable } _versions`.Version = $version " );
$record = $query -> execute () -> record ();
$className = $record [ 'ClassName' ];
if ( ! $className ) {
Debug :: show ( $query -> sql ());
Debug :: show ( $record );
user_error ( " Versioned::get_version: Couldn't get $class . $id , version $version " , E_USER_ERROR );
}
return new $className ( $record );
}
static function get_all_versions ( $class , $id , $version ) {
$baseTable = ClassInfo :: baseDataClass ( $class );
$query = singleton ( $class ) -> buildVersionSQL ( " ` { $baseTable } _versions`.RecordID = $id AND ` { $baseTable } _versions`.Version = $version " );
$record = $query -> execute () -> record ();
$className = $record [ ClassName ];
if ( ! $className ) {
Debug :: show ( $query -> sql ());
Debug :: show ( $record );
user_error ( " Versioned::get_version: Couldn't get $class . $id , version $version " , E_USER_ERROR );
}
return new $className ( $record );
}
2007-08-15 12:13:27 +02:00
function contentcontrollerInit ( $controller ) {
self :: choose_site_stage ();
}
function modelascontrollerInit ( $controller ) {
self :: choose_site_stage ();
}
2007-07-19 12:40:28 +02:00
protected static $reading_stage = null ;
protected static $reading_archived_date = null ;
}
/**
* Represents a single version of a record .
*/
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' ] );
}
}
?>