Revision Behavior - Revision control made easy

by alkemann
Take full control of any changes your users makes, while also giving them features like undo. Keep a history of previous versions of any database model, allowing you to undo, revert to an older version (or a specific time), manage and inspect changes and even get a difference array for seeing changes over time to any (or all) fields.
RevisionBehavior is a solution for adding undo and other versioning functionality
to your database models. It is set up for easy application to your project,
ease of use and to not get in the way of your other model activity.
It is also intended to work well with it's sibling, LogableBehavior.

Feature list

  1. Easy to install
  2. Automagically save revision on model save
  3. Able to ignore model saves which only contain certain fields
  4. Limit number of revisions to keep, will delete oldest
  5. Undo functionality (or update to any revision directly)
  6. Revert to a datetime (and even do so cascading)
  7. Get a diff model array to compare two or more revisions
  8. Inspect any or all revisions of a model

Install instructions

  1. Place the newest version of RevisionBehavior in your app/models/behaviors folder
  2. Add the behavior to AppModel (or single models if you prefer)
  3. For each model that you want revision for, create a shadow table
  4. Behavior will gracefully do nothing for models that has behavior, but not shadow table
  5. If adding Revision to an existing project, run the initializeRevisions() method once for each model.

About shadow tables


You should make these AFTER you have baked your ordinary tables as they may interfer. By default
the tables should be named rev_[normal table name]. If you wish to change the prefix you may
do so in the property called $revision_prefix found in the behavior. Also by default the behavior expects
the shadow tables to be in the same dbconfig as the model, but you may change this on a per
model basis with the useDbConfig config option.

Add the same fields as in the live table, with 3 important differences.

  1. The 'id' field should NOT be the primary key, nor auto increment.
  2. Add the fields 'version_id' (int, primary key, autoincrement) and 'version_created' (datetime).
  3. Skipp fields that should not be saved in shadowtable (lft,right,weight for instance).

Configuration

When adding 'Revision' the a model's actsAs array, you may configure the behavior with these options:

  1. limit : int number of revisions to keep, must be at least 2 (as current is 1).
  2. ignore : array containing the name of fields to ignore.
  3. auto : boolean when false the behavior will NOT generate revisions in afterSave.
  4. useDbConfig : string/null Name of dbConfig to use. Null to use Model's.

Limit functionality

The shadow table will save a revision copy when it saves live data, so the newest
row in the shadow table will (in most cases) be the same as the current live data.
The exception is when the ignore field functionality is used and the live data is
updated only in those fields.

Ignore field(s) functionality

If you wish to be able to update certain fields without generating new revisions,
you can add those fields to the configuration ignore array. Any time the behavior's
afterSave is called with just primary key and these fields, it will NOT generate
a new revision. It WILL however save these fields together with other fields when it
does save a revision. You will probably want to set up cron or otherwise call
createRevision() to update these fields at some points.

Auto functionality

By default the behavior will insert itself into the Model's save process by implementing
beforeSave and afterSave. In afterSave, the behavior will save a new revision of the dataset
that is now the live data. If you do NOT want this automatic behavior, you may set the config
option 'auto' to false. Then the shadow table will remain empty unless you call createRevisions
manually.




As an example of how to use this behavior, I will demostrate parts of a blog app bellow. I assume you already have enough cake understanding to complete a basic blog app with cake, so I will only include the code parts needed for the example.

The Blob Blog

This blog contains articles that may be posted by more than one user. We also wish to use revision behavior on the articles. This gives us the following 3 tables; users, posts, posts_revs.

posts
id (int) primary key, autoincrement
user_id (int)
title (varchar)
content (text)
publish (date) ALLOW_NULL

posts_revs
version_id (int) primary key, autoincrement
version_created (datetime)
id (int)
user_id (int)
title (varchar)
content (text)
publish (date) ALLOW_NULL

Model Class:

<?php 
class Post extends AppModel {
var 
$name 'Post';
var 
$belongsTo = array('User');
var 
$actsAs = array('Revision' => array('limit'=>10,'ignore'=>array('publish') );
}
?>
I configure the behavior to only keep 9 old versions and to not generate a new revision if only the publish field is updated

Parts of app/views/posts/view.ctp
<?php 
// [..]
echo 'Previous version was made by '.$users[$undo_rev['Post']['user_id']].' at '.$time->nice($undo_rev['Post']['version_created']);
echo 
$html->link('Undo', array('action'=>'undo',$post['Post']['id']));
// [..]
echo '<h4>Revision history</h4><ul>';
$nr_of_revs sizeof($history);
foreach (
$history as $k => $rev) {
    echo 
'<li>'.($nr_of_revs-$k).' '.$rev['Post']['version_created'].' '.
       
$html->link('make current', array('action'=>'make_current',$post['Post']['id'],$rev['Post']['version_id']);

// [..]

Parts of app/views/posts/edit.ctp
<?php 
// [..]
echo '<h4>Revision history</h4><ul>';
$nr_of_revs sizeof($history);
foreach (
$history as $k => $rev) {
    echo 
'<li>'.($nr_of_revs-$k).' '.$rev['Post']['version_created'].' '.
       
$html->link('load revision', array('action'=>'edit',$rev['Post']['id'],$rev['Post']['version_id']);
//Puts selected revision in the form, user can save it as it is, edit it and then save or discard.
// [..]

Controller Class:

<?php  
class PostsController extends AppController {
// [..]
function view($id) {
    
$this->Post->id $id;
    
$post $this->Post->read();
    
$undo_rev $this->Post->previous();
    
$history $this->Post->revisions();
    
$users $this->Post->User->find('list');
    
$this->set(compact('post','undo_rev','history','users');
}
// [..]
function edit($id$version_id null) {
    
$this->Post->id $id//important for read,shadow and revisions call bellow
    // [..]
    
if (empty($this->data)) {
        if (
is_numeric($version_id)) {
            
$this->data $this->Post->shadow('first',array('conditions' => array('version_id' => $version_id)));
        } else {
            
$this->data $this->Post->read();
        }
    }
    
$users $this->Post->User->find('list');
    
$history $this->Post->revisions();
    
$this->set(compact('users','history'));
}
// [..]
function undo($id) {
    
$this->Post->id $id;
    
$this->Post->undo();
    
$this->redirect(array('action'=>'view',$id));
}
// [..]
function make_current($id$version_id) {
    
$this->Post->id $id;
    
$this->Post->revertTo($version_id);
    
$this->redirect(array('action'=>'view',$id);
}
?>
Skipped crud actions and code parts that check valid paremeters etc.



createRevision()

  • No parameters

Manually create a revision of the current record of Model->id

Example: <?php
$this
->Post->id 5$this->Post->createRevision();


diff($from_version_id = null, $to_version_id = null, $options = array())

  • from_version_id : Shadow id of first version
  • to_version_id : Shadow id of last version
  • options : Extra options for the ShadowModel->find()

Returns an array that maps to the Model, only with multiple values for fields that has been changed

Example: <?php
$this
->Post->id 4$changes $this->Post->diff();
$this->Post->id 4$my_changes $this->Post->diff(nullnull, array('conditions' => array('user_id'=>4)));
$this->Post->id 4$difference $this->Post->diff(45,192);
Result example :
array( 'Post' => array(
  'version_id' => array(
    0 => 192,
    1 => 67,
    2 => 45),
  'version_created => array(
    0 => '2008-12-03 12:03:00',
    1 => '2008-12-02 11:02:00',
    2 => '2008-12-01 10:01:00',
  'id' => 4,
  'title' => array(
    0 => 'New title',
    1 => 'Edited title',
    2 => 'Original title'
  ),
  'content' => 'Lorem ipsum'
);


initializeRevisions()

  • No parameters

Will create a current revision of all rows in Model, if none exist. Use this if you add the revision to a model that allready has data in the DB.

Example: <?php
$this
->Post->initializeRevisions();


newest($options = array())

  • options : extra options to the shadow find

Finds the newest revision, including the current one. Use with caution, the live model may be different depending on the usage of ignore fields.

Example: <?php
$this
->Post->id 6$newest_revision $this->Post->newest();


oldest($options = array())

  • options : extra options to the shadow find

Find the oldest revision for the current Model->id. If no limit is used on revision and revision has been enabled for the model since start, this call will return the original first record.

Example: <?php
$this
->Post->id 2$original $this->Post->oldest();


previous($options = array())

  • options : extra options to the shadow find

Find the second newest revisions, including the current one.

Example: <?php
$this
->Post->id 6$undo_revision $this->Post->previous();


revertTo($version_id)

  • version_id : Shadow id of the revision to revert to.

Revert current Model->id to the given revision id. Will return false if version id is invalid or save fails

Example: <?php
$this
->Post->id 3$this->Post->revertTo(12);


revertToDate($datetime, $cascade = false)

  • datetime : Date to revert to
  • cascade : If true, revert cascades to hasOne and hasMany related models

Example: <?php
$this
->Post->id 3$this->Post->revertToDate(date('Y-m-d H:i:s',strtotime('Yesterday')));
$this->Post->id 4$this->Post->revertToDate('2008-09-01',true);


revisions($options = array())

  • options : extra options to the shadow find

Returns a comeplete list of revisions for the current Model->id. The options array may include Model::find parameters to narrow down result. Alias for shadow('all',array('conditions'=>array($Model->primaryKey => $Model->id)));

Example: <?php
$this
->Post->id 4$history $this->Post->revisions(); 
$this->Post->id 4$today $this->Post->revisions(array('conditions'=>array('version_create >'=>'2008-12-10')));


shadow($type = 'first', $options = array())

  • type : 'first','all','count'
  • options : extra options to the shadow find

Runs a find on the models shadow table. Basicaly : ShadowModel->find('first',$options)

Example: <?php
$specific_version 
$this->Post->shadow('first',array('conditions' => array('version_id'=>4)));
$my_revs $this->Post->shadow('all',array('conditions' => array('id'=>4,'user_id'=>5)));


undelete()

  • No parameters

Undoes an delete by saving the last revision to the Model. Will return false if this Model->id exist in the live table. Callbacks Model::beforeUndelete and Model::afterUndelete

Example: <?php
$this
->Post->id 7$this->Post->undelete(); 


undo()

  • No parameters

Update to previous revision. If there are 7 revisions including current, undo will create version 8 with data from version 6.

Example: <?php
$this
->Post->id 2$this->Post->undo();


Download php5 version (and also more frequently updated) here : http://code.google.com/p/alkemann/downloads/list
SVN checkout version here : http://alkemann.googlecode.com/svn/trunk/models/behaviors/revision.php

Behavior Class:

<?php 
/**
 * Revision Behavior 1.1.1
 * 
 * Revision is a solution for adding undo and other versioning functionality
 * to your database models. It is set up to be easy to apply to your project,
 * to be easy to use and not get in the way of your other model activity.
 * It is also intended to work well with it's sibling, LogableBehavior.
 * 
 * Feature list :
 * 
 *  - Easy to install
 *  - Automagically save revision on model save
 *  - Able to ignore model saves which only contain certain fields
 *  - Limit number of revisions to keep, will delete oldest
 *  - Undo functionality (or update to any revision directly)
 *  - Revert to a datetime (and even do so cascading)
 *  - Get a diff model array to compare two or more revisions
 *  - Inspect any or all revisions of a model
 *
 * Install instructions :
 * 
 *  - Place the newest version of RevisionBehavior in your app/models/behaviors folder
 *  - Add the behavior to AppModel (or single models if you prefer)
 *  - Create a shadow table for each model that you want revision for.
 *  - Behavior will gracefully do nothing for models that has behavior, without table
 *  - If adding to an existing project, run the initializeRevisions() method once for each model.
 * 
 * About shadow tables :
 * 
 * You should make these AFTER you have baked your ordinary tables as they may interfer. By default
 * the tables should be named "[prefix][model_table_name]_revs" If you wish to change the suffix you may
 * do so in the property called $revision_suffix found bellow. Also by default the behavior expects
 * the revision tables to be in the same dbconfig as the model, but you may change this on a per 
 * model basis with the useDbConfig config option.
 * 
 * Add the same fields as in the live table, with 3 important differences. 
 *  - The 'id' field should NOT be the primary key, nor auto increment
 *  - Add the fields 'version_id' (int, primary key, autoincrement) and 
 *    'version_created' (datetime)
 *  - Skipp fields that should not be saved in shadowtable (lft,right,weight for instance)
 * 
 * Configuration :
 * 
 *  - 'limit' : number of revisions to keep, must be at least 2 
 *  - 'ignore' : array containing the name of fields to ignore
 *  - 'auto' : boolean when false the behavior will NOT generate revisions in afterSave
 *  - 'useDbConfig' : string/null Name of dbConfig to use. Null to use Model's
 * 
 * Limit functionality : 
 * The shadow table will save a revision copy when it saves live data, so the newest
 * row in the shadow table will (in most cases) be the same as the current live data.
 * The exception is when the ignore field functionality is used and the live data is 
 * updated only in those fields. 
 * 
 * Ignore field(s) functionality :
 * If you wish to be able to update certain fields without generating new revisions,
 * you can add those fields to the configuration ignore array. Any time the behavior's 
 * afterSave is called with just primary key and these fields, it will NOT generate
 * a new revision. It WILL however save these fields together with other fields when it
 * does save a revision. You will probably want to set up cron or otherwise call
 * createRevision() to update these fields at some points.
 * 
 * Auto functionality :
 * By default the behavior will insert itself into the Model's save process by implementing
 * beforeSave and afterSave. In afterSave, the behavior will save a new revision of the dataset
 * that is now the live data. If you do NOT want this automatic behavior, you may set the config
 * option 'auto' to false. Then the shadow table will remain empty unless you call createRevisions
 * manually.
 * 
 * @author Ronny Vindenes
 * @author Alexander 'alkemann' Morland
 * @license MIT
 * @modifed 27. desemeber 2008
 * @version 1.1.1
 */
class RevisionBehavior extends ModelBehavior {

    
/**
     * Behavior settings
     * 
     * @access public
     * @var array
     */
    
public $settings = array();
    
/**
     * Shadow table prefix
     * Only change this value if it causes table name crashes
     *
     * @access private
     * @var string
     */
    
private $revision_suffix '_revs';
    
/**
     * Defaul setting values
     *
     * @access private
     * @var array
     */
    
private $defaults = array(
        
'limit' => false,
        
'auto' => true,
        
'ignore' => array(),
        
'useDbConfig' => null
    
);

    
/**
     * Configure the behavior through the Model::actsAs property
     *
     * @param object $Model
     * @param array $config
     */
    
public function setup(&$Model$config null) {    
        if (
is_array($config)) {
            
$this->settings[$Model->alias] = array_merge($this->defaults$config);            
        } else {
            
$this->settings[$Model->alias] = $this->defaults;
        }        
        
$Model->ShadowModel $this->createShadowModel($Model);    
    }

    
/**
     * Manually create a revision of the current record of Model->id
     *
     * @example $this->Post->id = 5; $this->Post->createRevision();
     * @param object $Model
     * @return boolean success
     */
    
public function createRevision(&$Model) {    
        if (
is_null($Model->id)) {
            
trigger_error('RevisionBehavior: Model::id must be set'E_USER_WARNING); return null;
        }
        
$Model->read();
        
$data $Model->data;        
        
$data[$Model->ShadowModel->alias]['version_created'] = date('Y-m-d h:i:s');
        return 
$Model->ShadowModel->save($data,false);
    }
    
    
/**
     * Returns an array that maps to the Model, only with multiple values for fields that has been changed
     *
     * @example $this->Post->id = 4; $changes = $this->Post->diff();
     * @example $this->Post->id = 4; $my_changes = $this->Post->diff(null,nul,array('conditions'=>array('user_id'=>4)));
     * @example $this->Post->id = 4; $difference = $this->Post->diff(45,192);
     * @param Object $Model
     * @param int $from_version_id
     * @param int $to_version_id
     * @param array $options
     * @return array
     */
    
public function diff(&$Model$from_version_id null$to_version_id null$options = array()) {
        if (
is_null($Model->id)) {
            
trigger_error('RevisionBehavior: Model::id must be set'E_USER_WARNING); return null;
        }
        if (isset(
$options['conditions'])) {
            
$conditions am($options['conditions'],array($Model->primaryKey => $Model->id));    
        } else {
            
$conditions = array( $Model->primaryKey => $Model->id);    
        }    
        
$conditions = array($Model->primaryKey     => $Model->id);        
        if (
is_numeric($from_version_id)) {
            
$conditions['version_id >='] = $from_version_id;            
        }
        if (
is_numeric($to_version_id)) {
            
$conditions['version_id <='] = $to_version_id;        
        }
        
$options['conditions'] = $conditions;
        
$all $this->revisions($Model,$options);
        if (
sizeof($all) == 0) {
            return 
null;
        }
        
$unified = array();
        
$keys array_keys($all[0][$Model->alias]);
        foreach (
$keys as $field) {
            
$all_values Set::extract($all,'/'.$Model->alias.'/'.$field);
            
$all_values array_unique($all_values);
            if (
sizeof($all_values) == 1) {
                
$unified[$field] = $all_values[0];
            } else {
                
$unified[$field] = $all_values;
            }            
        }         
        return array(
$Model->alias => $unified);
    }    

    
/**
     * Will create a current revision of all rows in Model, if none exist.
     * Use this if you add the revision to a model that allready has data in
     * the DB.
     *
     * @example $this->Post->initializeRevisions();
     * @param object $Model
     * @return boolean 
     */
    
public function initializeRevisions($Model) {
        if (
$Model->ShadowModel->useTable == false) {
            die(
'RevisionBehavior: Missing shadowtable : '.$this->revision_prefix.$Model->table);
        }
        if (
$Model->ShadowModel->find('count') != 0) {
            return 
false;
        }
        
$all $Model->find('all');
        
$version_created date('Y-m-d h:i:s');
        foreach (
$all as $data) {
            
$data[$Model->ShadowModel->alias]['version_created'] = $version_created;
            
$Model->ShadowModel->create($data);
            
$Model->ShadowModel->save();
        }
        return 
true;
    }

    
/**
     * Finds the newest revision, including the current one.
     * Use with caution, the live model may be different depending on the usage
     * of ignore fields.
     *
     * @example $this->Post->id = 6; $newest_revision = $this->Post->newest();
     * @param object $Model
     * @param array $options
     * @return array
     */
    
public function newest(&$Model$options = array()) {
        if (
is_null($Model->id)) {
            
trigger_error('RevisionBehavior: Model::id must be set'E_USER_WARNING); return null;
        }
        if (isset(
$options['conditions'])) {
            
$options['conditions'] = am($options['conditions'],array($Model->primaryKey => $Model->id));    
        } else {
            
$options['conditions'] = array( $Model->primaryKey => $Model->id);    
        }            
        return 
$this->shadow($Model,'first',$options);
    }

    
/**
     * Find the oldest revision for the current Model->id
     * If no limit is used on revision and revision has been enabled for the model
     * since start, this call will return the original first record.
     *
     * @example $this->Post->id = 2; $original = $this->Post->oldest();
     * @param object $Model
     * @param array $options
     * @return array
     */
    
public function oldest(&$Model$options = array()) {
        if (
is_null($Model->id)) {
            
trigger_error('RevisionBehavior: Model::id must be set'E_USER_WARNING); return null;
        }
        if (isset(
$options['conditions'])) {
            
$options['conditions'] = am($options['conditions'],array($Model->primaryKey => $Model->id));    
        } else {
            
$options['conditions'] = array( $Model->primaryKey => $Model->id);    
        }            
        
$options['order'] = 'version_created ASC, version_id ASC';
        return 
$this->shadow($Model,'first',$options);
    }

    
/**
     * Find the second newest revisions, including the current one.
     *
     * @example $this->Post->id = 6; $undo_revision = $this->Post->previous();
     * @param object $Model
     * @param array $options
     * @return array
     */
    
public function previous(&$Model$options = array()) {
        if (
is_null($Model->id)) {
            
trigger_error('RevisionBehavior: Model::id must be set'E_USER_WARNING); return null;
        }
        
$options['limit'] = 1;
        
$options['page'] = 2;        
        if (isset(
$options['conditions'])) {
            
$options['conditions'] = am($options['conditions'],array($Model->primaryKey => $Model->id));    
        } else {
            
$options['conditions'] = array( $Model->primaryKey => $Model->id);    
        }        
        
$revisions $this->shadow($Model,'all',$options);
        if (!
$revisions) {
            return 
null;
        }
        return 
$revisions[0]; 
    }

    
/**
     * Revert current Model->id to the given revision id
     * Will return false if version id is invalid or save fails
     *
     * @example $this->Post->id = 3; $this->Post->revertTo(12); 
     * @param object $Model
     * @param int $version_id
     * @return boolean
     */
    
public function revertTo(&$Model$version_id) {
        if (
is_null($Model->id)) {
            
trigger_error('RevisionBehavior: Model::id must be set'E_USER_WARNING); return null;
        }
        
$data $this->shadow($Model,'first',array('conditions'=>array('version_id'=>$version_id)));
        if (
sizeof($data) != 1) {
            return 
false;
        }
        return 
$Model->save($data);
    }
    
    
/**
     * Revert to the oldest revision after the given datedate.
     * Will cascade to hasOne and hasMany associeted models if $cascade is true.
     * Will return false if no change is made on the main model 
     *
     * @example $this->Post->id = 3; $this->Post->revertToDate(date('Y-m-d H:i:s',strtotime('Yesterday')));
     * @example $this->Post->id = 4; $this->Post->revertToDate('2008-09-01',true);
     * @param object $Model
     * @param string $datetime
     * @param boolean $cascade
     * @return boolean
     */
    
public function revertToDate(&$Model$datetime$cascade false) {
        if (
is_null($Model->id)) {
            
trigger_error('RevisionBehavior: Model::id must be set'E_USER_WARNING); return null;
        }
        if (
$cascade) {        
            
$associated array_merge($Model->hasMany$Model->hasOne);
            foreach (
$associated as $assoc => $data) {
                
$children $Model->$assoc->find('list', array('conditions'=>array($data['foreignKey']=>$Model->id),'recursive'=>-1));
                
$ids array_keys($children);
                foreach (
$ids as $id) {
                    
$Model->$assoc->id $id;
                    
$Model->$assoc->revertToDate($datetime,true);
                }
            }            
        }         
        
$changes $this->revisions($Model,array('conditions'=>array('version_created >'=>$datetime),'order'=>'version_created ASC, version_id ASC'));    
        if (
sizeof($changes) == 0) {
            return 
false;
        }
        return 
$Model->save($changes[0]);
    }
    
    
/**
     * Returns a comeplete list of revisions for the current Model->id. 
     * The options array may include Model::find parameters to narrow down result
     * Alias for shadow('all',array('conditions'=>array($Model->primaryKey => $Model->id)));
     * 
     * @example $this->Post->id = 4; $history = $this->Post->revisions(); 
     * @example $this->Post->id = 4; $today = $this->Post->revisions(array('conditions'=>array('version_create >'=>'2008-12-10'))); 
     * @param object $Model
     * @param array $options
     * @return array
     */
    
public function revisions(&$Model$options = array()) {
        if (
is_null($Model->id)) {
            
trigger_error('RevisionBehavior: Model::id must be set'E_USER_WARNING); return null;
        }
        if (isset(
$options['conditions'])) {
            
$options['conditions'] = am($options['conditions'],array($Model->primaryKey => $Model->id));    
        } else {
            
$options['conditions'] = array( $Model->primaryKey => $Model->id);    
        }        
        return 
$this->shadow($Model,'all',$options);        
    }
        
    
/**
     * Runs a find on the models shadow table. Basicaly : ShadowModel->find('first',$options)
     * 
     * @example $specific_version = $this->Post->shadow('first',array('conditions' => array('version_id'=>4)));
     * @example $my_revs = $this->Post->shadow('all',array('conditions' => array('id'=>4,'user_id'=>5)));
     * @param object $Model
     * @param array $options
     * @return array
     */
    
public function shadow(&$Model$type 'first'$options = array()) {
        return 
$Model->ShadowModel->find($type,$options);
    }

    
/**
     * Undoes an delete by saving the last revision to the Model
     * Will return false if this Model->id exist in the live table.
     * Calls Model::beforeUndelete and Model::afterUndelete
     *
     * @example $this->Post->id = 7; $this->Post->undelete(); 
     * @param object $Model
     * @return boolean
     */
    
public function undelete(&$Model) {
        if (
is_null($Model->id)) {
            
trigger_error('RevisionBehavior: Model::id must be set'E_USER_WARNING); return null;
        }
        if  (
$Model->read()) {
            return 
false;
        }
        
$data $this->newest($Model);
        if (!
$data) {
            return 
false;
        }
        foreach (
$this->settings[$Model->alias]['ignore'] as $field) {
            
$data[$Model->alias][$field] = NULL
        }
        
$Model->create($data);
        
$beforeUndeleteSuccess true;
        if (
method_exists($Model,'beforeUndelete')) {
            
$beforeUndeleteSuccess $Model->beforeUndelete();
        }
        if (!
$beforeUndeleteSuccess) {
            return 
false;
        }
        
$save_success =  $Model->save();
        if (!
$save_success) {
            return 
false;
        }
        
$afterUndeleteSuccess true;
        if (
method_exists($Model,'afterUndelete')) {
            
$afterUndeleteSuccess $Model->afterUndelete();
        }
        return 
$afterUndeleteSuccess;
    }
    
    
/**
     * Update to previous revision
     *
     * @example $this->Post->id = 2; $this->Post->undo();
     * @param object $Model
     * @return boolean
     */
    
public function undo(&$Model) {    
        if (
is_null($Model->id)) {
            
trigger_error('RevisionBehavior: Model::id must be set'E_USER_WARNING); return null;
        }
        
$data $this->previous($Model);
        return 
$Model->save($data);
    }    
    
    
/**
     * Will create a new revision if changes have been made in the models non-ignore fields. 
     * Also deletes oldest revision if limit is (active and) reached.
     *
     * @param object $Model
     * @param boolean $created
     * @return boolean
     */
    
public function afterSave(&$Model) {
        if (
$this->settings[$Model->alias]['auto'] === false) {
            return 
true;
        }        
        if (!
$Model->ShadowModel) {
            return 
true;
        }   
        
$data $Model->findById($Model->id);
        
$changeDetected false;
        foreach (
$data[$Model->alias] as $key => $value) {
               if (isset(
$data[$Model->alias][$Model->primaryKey]) && !empty($this->old) && isset($this->old[$Model->alias][$key])) {
                   
$old $this->old[$Model->alias][$key];
               } else {
                   
$old '';
               }
               if (
$value != $old && !in_array($key,$this->settings[$Model->alias]['ignore'])) {
                   
$changeDetected true;                
               }
           }
           if (!
$changeDetected) {
               return 
true;
           }
        
$data[$Model->ShadowModel->alias]['version_created'] = date('Y-m-d h:i:s');
        
$Model->ShadowModel->save($data,false);
        
$Model->version_id $Model->ShadowModel->id;
        if (
is_numeric($this->settings[$Model->alias]['limit'])) {
            
$conditions = array('conditions'=>array($Model->alias.'.'.$Model->primaryKey => $Model->id));
            
$count $Model->ShadowModel->find('count'$conditions);
            if (
$count $this->settings[$Model->alias]['limit']) {
                
$conditions['order'] = $Model->alias.'.version_created ASC, '.$Model->alias.'.version_id ASC';
                
$oldest $Model->ShadowModel->find('first',$conditions);
                
$Model->ShadowModel->id null;
                
$Model->ShadowModel->del($oldest[$Model->alias][$Model->ShadowModel->primaryKey]);    
            }            
        }
        return 
true;
    }
        
    
/**
     * Revision uses the beforeSave callback to remember the old data for comparison in afterSave
     *
     * @param object $Model
     * @return boolean
     */
    
public function beforeSave(&$Model) {
        if (
$this->settings[$Model->alias]['auto'] === false) {
            return 
true;
        }
        if (!
$Model->ShadowModel) {
            return 
true;
        }   
        
$Model->ShadowModel->id null;
        
$Model->ShadowModel->create();
           
$this->old $Model->find('first', array(
               
'recursive' => -1,
               
'conditions'=>array($Model->alias.'.'.$Model->primaryKey => $Model->id)));
        return 
true;
    }

    
/**
     * Returns a generic model that maps to the current $Model's shadow table.
     *
     * @param object $Model
     * @return object
     */
    
private function createShadowModel(&$Model) {    
        if (
is_null($this->settings[$Model->alias]['useDbConfig'])) {
            
$dbConfig $Model->useDbConfig;
        } else {
            
$dbConfig $this->settings[$Model->alias]['useDbConfig'];            
        }    
        
$table $Model->useTable .$this->revision_suffix;    
        
$db = & ConnectionManager::getDataSource($dbConfig);
        
$prefix $Model->tablePrefix $Model->tablePrefix $db->config['prefix'];
        
$tables $db->listSources();
        
$full_table_name $prefix.$table;
        if (
$prefix && empty($db->config['prefix'])) {
            
$table $full_table_name;
        }
        if (!
in_array($full_table_name$tables)) {
            return 
false;
        }     
        
$Model->ShadowModel = new Model(false$table$dbConfig);    
        
$Model->ShadowModel->alias $Model->alias;
        
$Model->ShadowModel->primaryKey 'version_id';
        
$Model->ShadowModel->order 'version_created DESC, version_id DESC';
        return 
$Model->ShadowModel;
    }

}
?>

1 | 2 | 3 | 4

Report

More on Behaviors

Advertising

Comments

  • BrentK posted on 06/21/11 08:54:26 PM
    You should mention in your installation instructions (or at least subtly link to) where to download RevisionBehavior. I searched around quite a bit before I found it in one of the comments to this article.

    http://code.google.com/p/alkemann/downloads/list
    Thanks very much for the program!
  • SuperCake posted on 01/12/11 08:08:45 PM
    On line 460: date('Y-m-d h:i:s') should be date('Y-m-d H:i:s') so it save the proper 24 hour clock time stamp. You get weird results when you try to order things by version_date otherwise.

  • erictr1ck posted on 11/30/10 05:40:50 PM
    This is a pretty old post but has some recent comments. I was wondering up to what version of CakePHP this Behavior will work with. Thanks!
  • shantamg posted on 05/20/10 01:04:40 AM
    Can data be fetched from a previous revision as easily as it can be fetched from the real tables? I'd like to be able to access old data (as if it was current) without changing the real tables.
  • h2o8polo posted on 04/20/10 07:08:18 PM
    I found that when the behavior is set on a model that has a primary key different than ID. In the shadow table, the column is named to be the new primary key name.

    Model: Campaign
    Primary Key: campaign_id

    Shadow table column name: campaign_id

    Change. Line 805

    $Model->ShadowModel->set('id',$Model->id);

    Change to

    $Model->ShadowModel->set($Model->primaryKey,$Model->id);
  • ptberry posted on 04/13/10 02:38:55 PM
    There is a little tweak necessary if you have $Model->cacheQueries=true on the model you are trying to version. If so the find query to get the newly saved data in afterSave() around line 830 will return a cached version of the data, which will cause $changeDetected to remain false because it is comparing identical records. This will in turn prevent a new version from being saved.

    One possible quick fix is to turn off query caching for the find in question:

    $_cacheQueries= $Model->cacheQueries;
    $Model->cacheQueries=false;
    $data = $Model->find('first', array(
    'contain'=> $habtm,
    'conditions'=>array($Model->alias.'.'.$Model->primaryKey => $Model->id)));
    $Model->cacheQueries=$_cacheQueries;

    Using $cacheQueries=true is more trouble than it's worth sometimes, but that's another story.
  • sebastien.barre posted on 03/30/10 09:48:07 AM
    I'm afraid there is a significant bug in diff().

    I have a model under Revision, with a field called 'hours'; I saved it multiple times, using 2 different values. Here is the history for this field (the most recent on top, corresponding to the newest revision):

    hours: Array
    (
    [0] => 3
    [1] => 2
    [2] => 3
    [3] => 2
    [4] => 2
    [5] => 2
    [6] => 2
    )

    Now if you look at diff(), here is what is going on:
    $all_values = Set::extract($all,'/'.$Model->alias.'/'.$field);
    $all_values = array_reverse(array_unique(array_reverse($all_values,true)),true);

    Unfortunately, the code above reduces the history to:

    hours: Array
    (
    [2] => 3
    [6] => 2
    )

    ...which is wrong. Sure thing, 'hours' was at 2 on revision #6, then was set to 3 in revision #2, *but* it was then set back to 2 in #1, and 3 in #0. Those last 2 changes were destroyed because of the call to array_unique(). The expected result was:

    hours: Array
    (
    [0] => 3
    [1] => 2
    [2] => 3
    [6] => 2
    )

    To fix this issue, replace:
    $all_values = array_reverse(array_unique(array_reverse($all_values,true)),true);
    by:
    $all_values = array_reverse($all_values,true);
    list($key, $current) = each($all_values);
    $all_values_reduced = array($key => $current);
    while (list($key, $value) = each($all_values)) {
    if ((string)$value === (string)$current) continue;
    $all_values_reduced[$key] = $value;
    $current = $value;
    }
    $all_values = array_reverse($all_values_reduced, true);

    Longer, slower (?), but correct :)

    Thanks
  • sebastien.barre posted on 03/29/10 01:30:01 PM
    Thanks for the great work.
    I second the previous comment, if you want to use left join or the Containable behavior in methods like diff(), it definitely helps prefixing $Model->primaryKey with $Model->alias . '.', or there will be some ambiguities in the SQL query with respect to the 'id' field.
    In functions:
    - diff
    - oldest
    - revertAll
    - revertToDate
    - undelete
  • alaxos posted on 02/19/10 11:15:17 AM
    Hi ! First thanks a lot for this code. I just tested it and it seems very well written and useful.

    I just came on something that may be a bug or at least a problem in some situations (but maybe I'm doing something wrong ?):

    I tried the undelete() functionality and got an error message saying that an 'id' field was ambiguous. Actually it comes from the fact that my Model is related to another one and when the Behaviour tries to update the 'id' field of the Model to restore its original id, the UPDATE query generated by Cake contains a LEFT JOIN to another table with a second 'id' field.

    A quick fix that seems to work is to prefix the $Model->primaryKey with the $Model->alias around line 707-710:


    $Model->updateAll(
                array($Model->primaryKey => $model_id),
                array($Model->primaryKey => $Model->id)            
            );

    should be:


    $Model->updateAll(
                array($Model->primaryKey => $model_id),
                array($Model->alias . '.' . $Model->primaryKey => $Model->id)            
            );

    Then it seems to work perfectly.

    Thanks again for sharing this code !
  • cmba posted on 11/19/09 04:16:41 PM
    I'm able to get the revision behavior to initialize and track changes but I can't seem to get the HABTM relationship revision to initialize. I followed the instructions to add a text field in the shadow table and included the behavior in both HABTM tables. Does anyone know what I'm doing wrong? Please help.

    TABLES

    articles
    --------
    id (int)

    articles_tags
    --------
    id (int)
    article_id (int)
    tag_id (int)

    tags
    --------
    id (int)
    description (text)

    articles_rev
    --------
    version_id (int)
    version_created (datetime)
    id (int)
    description (text)
    tags (text)


    tag.php
    --------------------------------
    class Tag extends AppModel {

    var $name = 'Tag';
    var $actsAs = array('Revision' => array('limit'=>10));

    //The Associations below have been created with all possible keys, those that are not needed can be removed
    var $hasAndBelongsToMany = array(
    'Articles' => array(
    'className' => 'Article',
    'joinTable' => 'articles_tags',
    'foreignKey' => 'tag_id',
    'associationForeignKey' => 'article_id'
    )
    );

    }
    ?>

    article.php
    -------------------------------
    class Article extends AppModel {

    var $name = 'Article';
    var $actsAs = array('Revision' => array('limit'=>10));

    //The Associations below have been created with all possible keys, those that are not needed can be removed
    var $hasAndBelongsToMany = array(
    'Tags' => array(
    'className' => 'Tag',
    'joinTable' => 'articles_tags',
    'foreignKey' => 'article_id',
    'associationForeignKey' => 'tag_id'
    )
    );

    }
    ?>
  • MarkAlanEvans posted on 08/17/09 06:29:34 PM
    is there a solution for when your model acts as tree ?
    • ronnyvv posted on 08/18/09 02:11:57 AM
      is there a solution for when your model acts as tree ?
      Revision works with TreeBehavior by default, if you don't want it to track the tree structure simply add the lft,rght & parent_id fields to the ignore list e.g. public $actsAs = array('Tree','Revision' => array('ignore'=>array('lft','rght','parent_id')));
  • stebu posted on 08/07/09 02:11:42 AM
    Thank you for sharing this Behavior!

    Does it also work with blob database fields?
    I installed version 2.0.4 of the revision behavior and created an empty shadow table ('xxx_rev' for table 'xxx' with one longblob field).
    When running initializeRevisions() I get the warning:
    'RevisionBehavior: ShadowModel doesnt exist. [APP/models/behaviors/revision.php, line 271]'

    Do you have any ideas?

    Best wishes!
    • stebu posted on 08/07/09 02:53:51 AM
      Got it.
      Shadow table must be named "xxx_revs" and not "xxx_rev"
  • lucasrcosta posted on 07/29/09 12:47:14 PM
    First of all: Great piece of code!

    Just something I notice, when you delete a record and make an undo() the record goes to the revision and does not respect the limit imposed. Not that it ruins the functionality but I figure this bloats a little bit if you delete a record many times.

    Congratulations! Thank you.
  • alex_yul posted on 03/04/09 10:58:06 AM
    something i noticed, diff() always places the most recent version of the field in [0]. this means i cannot determine when the value was changed.

    eg. field1 has init value of x1 and field2 has an init value of y1.
    at time1, you change field1 to x2
    at time2, you change field2 to y2

    diff() returns
    [0] => time2 {field1: x2, field2: y2} <--- x2 is here!?
    [1] => time1 {field1: (blank), field2: (blank)}
    [2] => init {field1: x1, field2: y1}

    looking at this, there is no way to determine when field1 changed from x1 to x2 (since the value is blank for time1).

    what it should return is
    [0] => time2 {field1: (blank), field2: y2}
    [1] => time1 {field1: x2, field2: (blank)}
    [2] => init {field1: x1, field2: y1}
  • alex_yul posted on 03/04/09 05:55:11 AM
    in the function afterSave(), the line:

    $data = $Model->find('first', array('contain'=> $habtm, 'conditions'=>array($Model->alias.'.'.$Model->primaryKey => $Model->id)));

    returns the record before the save, not the record after the save. removing "'contain'=> $habtm" returns the correct record, but obviously includes all related models.

    do you have any idea why this happens? using cake_1.2.1.8004 and revision behavior 2.0.3
    • ronnyvv posted on 03/06/09 07:02:55 AM
      Thanks for the feedback! Since you are using the svn version of the code could you please open a ticket on google code and attach a test case or at least more details, as we are unable to reproduce the problem you describe?

      Your suggested changes to diff() are now implemented in svn
  • alkemann posted on 01/15/09 11:03:23 AM
    Version 2 is done, major features being it's integration with Logable behavior, and that it play's nice with Multilingual (who just achieved version 1 (no article yet)). Grab the new version at the google code site.
  • giulianob posted on 01/01/09 09:02:25 PM
    This behavior is really fantastic. Seems to be very useful and polished. This is a great addition to the tool belt.
  • alkemann posted on 12/26/08 12:09:04 PM
    RevisionBehavior updated to version 1.1 on the SVN checkout: http://code.google.com/p/alkemann/source/checkout
    Direct link to update code : http://code.google.com/p/alkemann/source/browse/trunk/models/behaviors/revision.php
    Important notice : Due to a new requirement of having the ShadowModel inherit the table prefix from the Model, the internal logic of building a shadowtable name changed. In this process a new naming convention was establish, that I believe is an improvement. So the new rule is :

    [any prefix][model_table_name]_revs
    Example: users => users_revs, project_posts => project_posts_revs, model_prefix_comments => model_prefix_comments_revs

    (Make sure you are using version 1.1 or later before renaming your tables to this)
  • alkemann posted on 12/19/08 04:57:25 AM
    It doesnt have a function that answer that question, but since it stores not only old data, but current data as well, you can answer that question by asking for all subcategories with category_id = X and version_created between z and y.
  • stefanb posted on 12/19/08 02:32:36 AM
    Looks nice.

    Does it automagically take snapshots of the related models as well, i.e the true current state of the model?

    E.g., I have a Category model [id, name] that hasMany Subcategories [id, category_id, name]. Can the RevisionBehavior answer questions like "Which Subcategories did a Category have at a specific time?"
  • aidan posted on 12/19/08 02:04:38 AM
    This is exactly what I needed, thanks!
login to post a comment.