You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
PlexShare/fuel/packages/orm/classes/model/temporal.php

675 lines
17 KiB

<?php
/**
* Fuel is a fast, lightweight, community driven PHP 5.4+ framework.
*
* @package Fuel
* @version 1.8.1
* @author Fuel Development Team
* @license MIT License
* @copyright 2010 - 2018 Fuel Development Team
* @link http://fuelphp.com
*/
namespace Orm;
/**
* Allows revisions of database entries to be kept when updates are made.
*
* @package Orm
* @author Fuel Development Team
*/
class Model_Temporal extends Model
{
/**
* Compound primary key that includes the start and end times is required
*/
protected static $_primary_key = array('id', 'temporal_start', 'temporal_end');
/**
* Override to change default temporal paramaters
*/
protected static $_temporal = array();
/**
* Contains cached temporal properties.
*/
protected static $_temporal_cached = array();
/**
* Contains the status of the primary key disable flag for temporal models
*/
protected static $_pk_check_disabled = array();
/**
* Contains the status for classes that defines if primaryKey() should return
* just the ID.
*/
protected static $_pk_id_only = array();
/**
* If the model has been loaded through find_revision then this will be set
* to the timestamp used to find the revision.
*/
protected $_lazy_timestamp = null;
/**
* Contains the filtering status for temporal queries
*/
protected static $_lazy_filtered_classes = array();
public static function _init()
{
\Config::load('orm', true);
}
/**
* Gets the temporal properties.
* Mostly stolen from the parent class properties() function
*
* @return array
*/
public static function temporal_properties()
{
$class = get_called_class();
// If already determined
if (array_key_exists($class, static::$_temporal_cached))
{
return static::$_temporal_cached[$class];
}
$properties = array();
// Try to grab the properties from the class...
if (property_exists($class, '_temporal'))
{
//Load up the info
$properties['start_column'] =
\Arr::get(static::$_temporal, 'start_column', 'temporal_start');
$properties['end_column'] =
\Arr::get(static::$_temporal, 'end_column', 'temporal_end');
$properties['mysql_timestamp'] =
\Arr::get(static::$_temporal, 'mysql_timestamp', false);
$properties['max_timestamp'] = ($properties['mysql_timestamp']) ?
\Config::get('orm.sql_max_timestamp_mysql') :
\Config::get('orm.sql_max_timestamp_unix');
}
// cache the properties for next usage
static::$_temporal_cached[$class] = $properties;
return static::$_temporal_cached[$class];
}
/**
* Fetches temporal property description array, or specific data from
* it.
* Stolen from parent class.
*
* @param string property or property.key
* @param mixed return value when key not present
* @return mixed
*/
public static function temporal_property($key, $default = null)
{
$class = get_called_class();
// If already determined
if (!array_key_exists($class, static::$_temporal_cached))
{
static::temporal_properties();
}
return \Arr::get(static::$_temporal_cached[$class], $key, $default);
}
/**
* Finds a specific revision for the given ID. If a timestamp is specified
* the revision returned will reflect the entity's state at that given time.
* This will also load relations when requested.
*
* @param type $id
* @param int $timestamp Null to get the latest revision (Same as find($id))
* @param array $relations Names of the relations to load.
* @return Subclass of Orm\Model_Temporal
*/
public static function find_revision($id, $timestamp = null, $relations = array())
{
if ($timestamp == null)
{
return parent::find($id);
}
$timestamp_start_name = static::temporal_property('start_column');
$timestamp_end_name = static::temporal_property('end_column');
// Select the next latest revision after the requested one then use that
// to get the revision before.
self::disable_primary_key_check();
$query = static::query()
->where('id', $id)
->where($timestamp_start_name, '<=', $timestamp)
->where($timestamp_end_name, '>', $timestamp);
self::enable_primary_key_check();
//Make sure the temporal stuff is activated
$query->set_temporal_properties($timestamp, $timestamp_end_name, $timestamp_start_name);
foreach ($relations as $relation)
{
$query->related($relation);
}
$query_result = $query->get_one();
// If the query did not return a result but null, then we cannot call
// set_lazy_timestamp on it without throwing errors
if ( $query_result !== null )
{
$query_result->set_lazy_timestamp($timestamp);
}
return $query_result;
}
private function set_lazy_timestamp($timestamp)
{
$this->_lazy_timestamp = $timestamp;
}
/**
* Overrides Model::get() to allow lazy loaded relations to be filtered
* temporaly.
*
* @param string $property
* @return mixed
*/
public function & get($property, array $conditions = array())
{
// if a timestamp is set and that we have a temporal relation
$rel = static::relations($property);
if ($rel && is_subclass_of($rel->model_to, 'Orm\Model_Temporal'))
{
// find a specific revision or the newest if lazy timestamp is null
$lazy_timestamp = $this->_lazy_timestamp ?: static::temporal_property('max_timestamp') - 1;
//add the filtering and continue with the parent's behavour
$class_name = $rel->model_to;
$class_name::make_query_temporal($lazy_timestamp);
$result =& parent::get($property, $conditions);
$class_name::make_query_temporal(null);
return $result;
}
return parent::get($property, $conditions);
}
/**
* When a timestamp is set any query objects produced by this temporal model
* will behave the same as find_revision()
*
* @param array $timestamp
*/
private static function make_query_temporal($timestamp)
{
$class = get_called_class();
static::$_lazy_filtered_classes[$class] = $timestamp;
}
/**
* Overrides Model::query to provide a Temporal_Query
*
* @param array $options
* @return Query_Temporal
*/
public static function query($options = array())
{
$timestamp_start_name = static::temporal_property('start_column');
$timestamp_end_name = static::temporal_property('end_column');
$max_timestamp = static::temporal_property('max_timestamp');
$query = Query_Temporal::forge(get_called_class(), static::connection(), $options)
->set_temporal_properties($max_timestamp, $timestamp_end_name, $timestamp_start_name);
//Check if we need to add filtering
$class = get_called_class();
$timestamp = \Arr::get(static::$_lazy_filtered_classes, $class, null);
if( ! is_null($timestamp))
{
$query->where($timestamp_start_name, '<=', $timestamp)
->where($timestamp_end_name, '>', $timestamp);
}
elseif(static::get_primary_key_status() and ! static::get_primary_key_id_only_status())
{
$query->where($timestamp_end_name, $max_timestamp);
}
return $query;
}
/**
* Returns a list of revisions between the given times with the most recent
* first. This does not load relations.
*
* @param int|string $id
* @param timestamp $earliestTime
* @param timestamp $latestTime
*/
public static function find_revisions_between($id, $earliestTime = null, $latestTime = null)
{
$timestamp_start_name = static::temporal_property('start_column');
$max_timestamp = static::temporal_property('max_timestamp');
if ($earliestTime == null)
{
$earliestTime = 0;
}
if($latestTime == null)
{
$latestTime = $max_timestamp;
}
static::disable_primary_key_check();
//Select all revisions within the given range.
$query = static::query()
->where('id', $id)
->where($timestamp_start_name, '>=', $earliestTime)
->where($timestamp_start_name, '<=', $latestTime);
static::enable_primary_key_check();
$revisions = $query->get();
return $revisions;
}
/**
* Overrides the default find method to allow the latest revision to be found
* by default.
*
* If any new options to find are added the switch statement will have to be
* updated too.
*
* @param type $id
* @param array $options
* @return type
*/
public static function find($id = null, array $options = array())
{
$timestamp_end_name = static::temporal_property('end_column');
$max_timestamp = static::temporal_property('max_timestamp');
switch ($id)
{
case 'all':
case 'first':
case 'last':
break;
default:
$id = (array) $id;
$count = 0;
foreach(static::getNonTimestampPks() as $key)
{
$options['where'][] = array($key, $id[$count]);
$count++;
}
break;
}
$options['where'][] = array($timestamp_end_name, $max_timestamp);
static::enable_id_only_primary_key();
$result = parent::find($id, $options);
static::disable_id_only_primary_key();
return $result;
}
/**
* Returns an array of the primary keys that are not related to temporal
* timestamp information.
*/
public static function getNonTimestampPks()
{
$timestamp_start_name = static::temporal_property('start_column');
$timestamp_end_name = static::temporal_property('end_column');
$pks = array();
foreach(parent::primary_key() as $key)
{
if ($key != $timestamp_start_name && $key != $timestamp_end_name)
{
$pks[] = $key;
}
}
return $pks;
}
/**
* Overrides the save method to allow temporal models to be
* @param boolean $cascade
* @param boolean $use_transaction
* @param boolean $skip_temporal Skips temporal filtering on initial inserts. Should not be used!
* @return boolean
*/
public function save($cascade = null, $use_transaction = false)
{
// Load temporal properties.
$timestamp_start_name = static::temporal_property('start_column');
$timestamp_end_name = static::temporal_property('end_column');
$mysql_timestamp = static::temporal_property('mysql_timestamp');
$max_timestamp = static::temporal_property('max_timestamp');
$current_timestamp = $mysql_timestamp ?
\Date::forge()->format('mysql') :
\Date::forge()->get_timestamp();
// If this is new then just call the parent and let everything happen as normal
if ($this->is_new())
{
static::disable_primary_key_check();
$this->{$timestamp_start_name} = $current_timestamp;
$this->{$timestamp_end_name} = $max_timestamp;
static::enable_primary_key_check();
// Make sure save will populate the PK
static::enable_id_only_primary_key();
$result = parent::save($cascade, $use_transaction);
static::disable_id_only_primary_key();
return $result;
}
// If this is an update then set a new PK, save and then insert a new row
else
{
// run the before save observers before checking the diff
$this->observe('before_save');
// then disable it so it doesn't get executed by parent::save()
$this->disable_event('before_save');
$diff = $this->get_diff();
if (count($diff[0]) > 0)
{
// Take a copy of this model
$revision = clone $this;
// Give that new model an end time of the current time after resetting back to the old data
$revision->set($this->_original);
self::disable_primary_key_check();
$revision->{$timestamp_end_name} = $current_timestamp;
self::enable_primary_key_check();
// Make sure relations stay the same
$revision->_original_relations = $this->_data_relations;
// save that, now we have our archive
self::enable_id_only_primary_key();
$revision_result = $revision->overwrite(false, $use_transaction);
self::disable_id_only_primary_key();
if ( ! $revision_result)
{
// If the revision did not save then stop the process so the user can do something.
return false;
}
// Now that the old data is saved update the current object so its end timestamp is now
self::disable_primary_key_check();
$this->{$timestamp_start_name} = $current_timestamp;
self::enable_primary_key_check();
$result = parent::save($cascade, $use_transaction);
}
else
{
// If nothing has changed call parent::save() to insure relations are saved too
$result = parent::save($cascade, $use_transaction);
}
// make sure the before save event is enabled again
$this->enable_event('before_save');
return $result;
}
}
/**
* ALlows an entry to be updated without having to insert a new row.
* This will not record any changed data as a new revision.
*
* Takes the same options as Model::save()
*/
public function overwrite($cascade = null, $use_transaction = false)
{
return parent::save($cascade, $use_transaction);
}
/**
* Restores the entity to this state.
*
* @return boolean
*/
public function restore()
{
$timestamp_end_name = static::temporal_property('end_column');
$max_timestamp = static::temporal_property('max_timestamp');
// check to see if there is a currently active row, if so then don't
// restore anything.
$activeRow = static::find('first', array(
'where' => array(
array('id', $this->id),
array($timestamp_end_name, $max_timestamp),
),
));
if(is_null($activeRow))
{
// No active row was found so we are ok to go and restore the this
// revision
$timestamp_start_name = static::temporal_property('start_column');
$mysql_timestamp = static::temporal_property('mysql_timestamp');
$max_timestamp = static::temporal_property('max_timestamp');
$current_timestamp = $mysql_timestamp ?
\Date::forge()->format('mysql') :
\Date::forge()->get_timestamp();
// Make sure this is saved as a new entry
$this->_is_new = true;
// Update timestamps
static::disable_primary_key_check();
$this->{$timestamp_start_name} = $current_timestamp;
$this->{$timestamp_end_name} = $max_timestamp;
// Save
$result = parent::save();
static::enable_primary_key_check();
return $result;
}
return false;
}
/**
* Deletes all revisions of this entity permantly.
*/
public function purge()
{
// Get a clean query object so there's no temporal filtering
$query = parent::query();
// Then select and delete
return $query->where('id', $this->id)
->delete();
}
/**
* Overrides update to remove PK checking when performing an update.
*/
public function update()
{
static::disable_primary_key_check();
$result = parent::update();
static::enable_primary_key_check();
return $result;
}
/**
* Allows correct PKs to be added when performing updates
*
* @param Query $query
*/
protected function add_primary_keys_to_where($query)
{
$primary_key = static::$_primary_key;
foreach ($primary_key as $pk)
{
$query->where($pk, '=', $this->_original[$pk]);
}
}
/**
* Overrides the parent primary_key method to allow primaray key enforcement
* to be turned off when updating a temporal model.
*/
public static function primary_key()
{
$id_only = static::get_primary_key_id_only_status();
$pk_status = static::get_primary_key_status();
if ($id_only)
{
return static::getNonTimestampPks();
}
if ($pk_status && ! $id_only)
{
return static::$_primary_key;
}
return array();
}
public function delete($cascade = null, $use_transaction = false)
{
// If we are using a transcation then make sure it's started
if ($use_transaction)
{
$db = \Database_Connection::instance(static::connection(true));
$db->start_transaction();
}
// Call the observers
$this->observe('before_delete');
// Load temporal properties.
$timestamp_end_name = static::temporal_property('end_column');
$mysql_timestamp = static::temporal_property('mysql_timestamp');
// Generate the correct timestamp and save it
$current_timestamp = $mysql_timestamp ?
\Date::forge()->format('mysql') :
\Date::forge()->get_timestamp();
static::disable_primary_key_check();
$this->{$timestamp_end_name} = $current_timestamp;
static::enable_primary_key_check();
// Loop through all relations and delete if we are cascading.
$this->freeze();
foreach ($this->relations() as $rel_name => $rel)
{
// get the cascade delete status
$relCascade = is_null($cascade) ? $rel->cascade_delete : (bool) $cascade;
if ($relCascade)
{
if(get_class($rel) != 'Orm\ManyMany')
{
// Loop through and call delete on all the models
foreach($rel->get($this) as $model)
{
$model->delete($cascade);
}
}
}
}
$this->unfreeze();
parent::save();
$this->observe('after_delete');
// Make sure the transaction is committed if needed
$use_transaction and $db->commit_transaction();
return $this;
}
/**
* Disables PK checking
*/
private static function disable_primary_key_check()
{
$class = get_called_class();
self::$_pk_check_disabled[$class] = false;
}
/**
* Enables PK checking
*/
private static function enable_primary_key_check()
{
$class = get_called_class();
self::$_pk_check_disabled[$class] = true;
}
/**
* Returns true if the PK checking should be performed. Defaults to true
*/
private static function get_primary_key_status()
{
$class = get_called_class();
return \Arr::get(self::$_pk_check_disabled, $class, true);
}
/**
* Returns true if the PK should only contain the ID. Defaults to false
*/
private static function get_primary_key_id_only_status()
{
$class = get_called_class();
return \Arr::get(self::$_pk_id_only, $class, false);
}
/**
* Makes all PKs returned
*/
private static function disable_id_only_primary_key()
{
$class = get_called_class();
self::$_pk_id_only[$class] = false;
}
/**
* Makes only id returned as PK
*/
private static function enable_id_only_primary_key()
{
$class = get_called_class();
self::$_pk_id_only[$class] = true;
}
}