CakePHP ReplaceableBehavior: Store modified data for history/audit use

2010-09-24: Converted the bare behavior to a behavior-only plugin for easy git submoduling. Tweaked controller code appropriately.
2010-09-24: Set up a github project. Clone from git://github.com/tomws/replaceable.git into /path/to/project/app/plugins/

The CakePHP-based project I'm working on has a few models for which I need to store pre-edit data in order to be able to reproduce a change history for anyone wishing to audit the data's change over time. This seems to be a little more than simple logging, so here are the steps I've taken so far to implement the solution.

First, I set up near-duplicate tables for each of those models to store that history in. I've named them replaced_* where the asterisk is the original table name, and they all have models and controllers set up (though not very useful right now). There are some differences in field naming. Obviously, we need to do something with the IDs. On the replaced_* tables, the ID is... well, an ID - a unique row identifier. The original model's ID is moved to a FK field (modelname_id) pointing back to the parent model. My models also contain the Cake-handled created and modified fields. Those names must also be changed in the replaced_* tables or else Cake will "help" you "automagically" when you save to the replaced_* table. So, on those tables, I just prefixed the named with an underscore (_created and _modified) and handle those field inserts manually.

With the tables set up, I needed some logic to handle the work and wanted something as near to automatic-handling as I could get. The initial version was some code in Model::afterSave and worked fine. However, that would have required lots of copy/paste work which creates a maintenance headache. So, I opted for a custom behavior. Using Behavior::afterSave and a little extra condition-checking code in the controller and model, I think I've solved my problem in a nice re-usable package.

Here's the code for ReplaceableBehavior that's working on CakePHP 1.3.2:

<?php

/**
 * Behavior for "moving" a replaced model row to a history/audit table.
 *
 * MUST BE DYNAMICALLY LOADED.  Don't uses the $actsAs variable.  Rather, set it with attach() in the edit methods.
 * This is required because the model ID must be set before use in order to get the data we want.
 *
 * Settings:
 *		history_row_field_name:
 *			required; the field name on the model for storing the row in the history table.
 *		created:
 *			optional; the field name in which to store the original data's created datetime;
 *			can't be 'created' because cake would overwrite that automatically
 *		modified:
 *			optional; the field name in which to store the original data's modified datetime;
 *			can't be 'modified' because cake would overwrite that automatically
 *
 * Runs automatically before Model::afterSave and sets flags on the model for determining
 * success or failure and handling conditions.
 */
class ReplaceableBehavior extends ModelBehavior
{
	/**
	 * Setup
	 * 
	 * @param object $Model
	 * @param array $settings The 'history_row_field_name' is required.  Optionally include 'created' and/or 'modified'.
	 * @return meaningless Instead of using the return value, test for Model->ReplaceableBehaviorIsSetUp 
	 */
	function setup(&$Model, $settings = array())
	{
		$Model->ReplaceableBehaviorIsSetUp = false;
		$Model->ReplaceableBehavior_updatingHistoryRow = false;
		$Model->ReplaceableBehavior_replacementSuccess = false;

		$this->ReplacedModelName = 'Replaced'.$Model->alias;
		$this->UnderscoredModelName = Inflector::underscore($Model->alias);
		$this->HumanizedModelName = Inflector::humanize($this->UnderscoredModelName);
		$this->ModelIdFieldName = $this->UnderscoredModelName.'_id';

		$this->settings = $settings;
		
		if (!isset($this->settings['history_row_field_name']) || empty($this->settings['history_row_field_name']))
		{
			$Model->ReplaceableBehaviorMessages['Error'] = 'ReplaceableBehavior setup failure: The field name for the '.$Model->alias.' table history row must be set.';
			return false;
		}
		else
		{
			if (!$Model->id)
			{
				$Model->ReplaceableBehaviorMessages['Error'] = 'ReplaceableBehavior setup failure: Model ID must be set.';
				return false;
			}
			else
			{
				$Model->recursive = -1;
				// Required: find() instead of read()
				// read() gets the data, but also fetches the modified/created fields, which means they're set back to original on save
				$this->OriginalData = $Model->find('first', array('conditions' => array("{$Model->alias}.id" => $Model->id)));
				if (empty($this->OriginalData))
				{
					$Model->ReplaceableBehaviorMessages['Error'] = 'ReplaceableBehavior setup failure: Failed to capture original '.$Model->alias.' data.';
					return false;
				}
				else
				{
					$Model->ReplaceableBehaviorMessages['Debug'] = 'Original '.$Model->alias.' data captured.';
					$Model->ReplaceableBehaviorIsSetUp = true;
				}
			}
		}
	}

	function afterSave(&$Model, $created)
	{
		// Don't re-enter when saving the history row field - infinite loop!
		if (!$Model->ReplaceableBehavior_updatingHistoryRow)
		{
			$dataReplaced = $this->OriginalData[$Model->alias];
			if (isset($this->settings['created']))
			{
				$dataReplaced[$this->settings['created']] = $dataReplaced['created'];
				unset($dataReplaced['created']);
			}
			if (isset($this->settings['modified']))
			{
				$dataReplaced[$this->settings['modified']] = $dataReplaced['modified'];
				unset($dataReplaced['modified']);
			}
			$id = $dataReplaced['id'];
			unset($dataReplaced['id']);

			$dataReplaced[$this->ModelIdFieldName] = $this->OriginalData[$Model->alias]['id'];
			$dataToSave[$this->ReplacedModelName] = $dataReplaced;

			if (!$Model->{$this->ReplacedModelName}->save($dataToSave))
			{
				$Model->ReplaceableBehaviorMessages['Error'] = 'The '.$this->HumanizedModelName.' was not saved: the audit data could not be saved.';
				// TODO RTMS: Log it
			}
			else
			{
				// update the history row
				$history_row = $Model->{$this->ReplacedModelName}->getInsertId();

				$Model->id = $id;
				$Model->ReplaceableBehavior_updatingHistoryRow = true;
				// Save the model data, otherwise it's lost in the saveField call below.
				$this->originalModelData = $Model->data;
				if (!$Model->saveField($settings['history_row_field_name'], $history_row, true))
				{
					$Model->ReplaceableBehaviorMessages['Error'] = 'The '.$this->HumanizedModelName.' was not saved: the audit row ('.$history_row.') could not be saved.';
					// TODO RTMS: Log it
				}
				else
				{
					$Model->ReplaceableBehavior_replacementSuccess = true;
				}
				// Restore the model data saved above.
				$Model->data = $this->originalModelData;
			}
		}
	}
}
?>

Instead of setting it up in the model's $actsAs variable, it needs to be loaded in Controller::edit() (or your desired method) so it can have access to the current model's ID. Here's the "save" section of one of my controllers:

if (!empty($this->data))
{
	// attach our ReplaceableBehavior
	// _plugin_exists is just an app-wide wrapper for App::objects() and in_array()
	// http://api13.cakephp.org/class/app#method-Appobjects
	if($this->_plugin_exists('Replaceable'))
	{
		$this->FeeDefinition->Behaviors->attach('Replaceable.Replaceable', array('history_row_field_name' => 'history_row', 'created' => '_created', 'modified' => '_modified'));
		if (!$this->FeeDefinition->ReplaceableBehaviorIsSetUp)
		{
			// Trigger to kill the save (technically, kill the transaction).
			$this->FeeDefinition->errorFlash = $this->FeeDefinition->ReplaceableBehaviorMessages['Error'];
		}
	}

	if ($this->FeeDefinition->save($this->data))
	{
		if (!$this->FeeDefinition->errorFlash)
		{
			$this->Session->setFlash(sprintf(__('The %s has been saved', true), 'fee definition'));
			$this->redirect(array('action' => 'index'));
		}
		else
		{
			$this->Session->setFlash($this->FeeDefinition->errorFlash);
			// TODO RTMS: Use this to log unseen errors.
			//debug($this->FeeDefinition->ReplacedFeeDefinition->validationErrors);
		}
	}
	else
	{
		$this->Session->setFlash(sprintf(__('The %s could not be saved. Please, try again.', true), 'fee definition'));
	}
}

The behavior requires one parameter and takes up to three. 'history_row_field_name' is required and passes the name of the model's field in which is stored the ID (row number) of the data in replaced_* (in other words, the ID of the history data being replaced). So, a history chain can be built from the 'history_row_field_name' field starting in the model and connecting the dots through the replaced_* table. That history row ID is maintained when the data is edited again (which triggers another entry into the replaced_* table), so the history chain maintains its integrity. (On a related note, I discovered after I had already designed my system this way that I could have went with a parent_id field name instead and made use of CakePHP's TreeBehavior for chaining the history. However, I didn't see enough of an advantage to warrant a re-design after-the-fact.)

The other two setup parameters, created and modified, are optional, but if you're really going to maintain audit data, the modified field should probably be used at least. These are provided because of the "automagic" Cake-handling mechanism for those field names mentioned above.

After dynamic attachment, I've built in the capability to check whether the setup completed normally. The state is stored in $this->Model->ReplaceableBehaviorIsSetUp. Handle errors as desired.

Due to being an afterSave operation, the logic seems a little screwy at first (to me, anyway). If the model data itself fails to save, there's no need for logic working with the replacement process. Just dump an error. Normal stuff. If Model->save works, though, then Behavior::afterSave and Model::afterSave will go to work. I set a dual-purpose variable on my models (above, $this->Model->errorFlash) to act as a flag for determining an error state and for displaying the error in the session flash message. That's what you see in the inner if/else above, which should be straightforward.

So, what's happening on the model? I'm using transactions on the whole save/history process so as to make it an atomic operation. If you don't care about data consistency, you could have simpler logic. But for real-world use, here's my Model::beforeSave and Model::afterSave:

function beforeSave()
{
	if (!isset($this->dbTransaction))
	{
		// Get my data source
		$this->dbTransaction = $this->getDataSource();
		// Begin db transaction
		return $this->dbTransaction->begin($this);
	}
	else // Don't attempt to start a new transaction if we're just updating the history_row field.
	{
		return true;
	}
}
function afterSave($created)
{
	if ($created) // new (added)
	{
		// nothing here
		$this->dbTransaction->commit($this);
	}
	else // edited
	{
		/**
		 * This is a model with a replaced_* table, so ReplaceableBehavior is in use.
		 */

		if ($this->Behaviors->attached('Replaceable'))
		{
			// The history table replacement has already happened (success or failure)
			// in behavior::afterSave.  We can check the result here and handle it.

			if ($this->ReplaceableBehavior_updatingHistoryRow)
			{
				// Updating history row in the behavior::afterSave.  Switch
				// the value so we don't catch it upon re-entry for this afterSave.
				$this->ReplaceableBehavior_updatingHistoryRow = false;
			}
			else
			{
				/**
				 * Associated data
				 */

				//if (0)
				//{
				//}

				/**
				 * History table (ReplaceableBehavior)
				 */
				//else
				//{
					if (!$this->ReplaceableBehavior_replacementSuccess)
					{
						$this->errorFlash = $this->ReplaceableBehaviorMessages['Error'];
						$this->dbTransaction->rollback($this);
						// TODO RTMS: Log it
					}
					else
					{
						$this->dbTransaction->commit($this);
					}
				//}
			}
		}
		// ReplaceableBehavior not attached.  Just update associated table.
		else
		{
			// No associated data with this model
			$this->dbTransaction->commit($this);
		}
	}
}

What's that condition doing in beforeSave? If you glance at the behavior code above, you'll see there's a call to saveField. Apparently, saveField triggers Model::save again, so you get a nested beforeSave which will break your transaction and cause beforeSave to return false. The condition, then, is necessary to protect our transaction and to set up a global variable for accessing the transaction's rollback/commit methods later in the save process.

Now, keep in mind that before we hit Model::afterSave, Behavior::afterSave runs, and that's where the logic sits for copying the original data to the replaced_* table. (The progression: Behavior::beforeSave, Model::beforeSave, Model::save, Behavior::afterSave, Model::afterSave.) So, when we hit Model::afterSave the replacement has already succeeded or failed.

Now, in Model::afterSave, the outer if/else combo is common for afterSave. We're either saving a new item or saving an edit. The edit is, of course, where our behavior does its work. The first if/else pair there determines whether the behavior is even attached. It should be since we did it in the controller, but if your logic requires a chance to ditch the replacement process between the controller and final db commit, you can detach the behavior and my afterSave structure will catch it. Note, though, that my else condition simply commits, so you would need to edit as needed.

Following the process along, if the behavior is attached, there's that condition test based upon that weird flag ReplaceableBehavior_updatingHistoryRow. This is another model variable set by the behavior and it's used for the same reason as the condition in beforeSave: the re-entry into the save process by saveField in the behavior. While not quite elegant, it is simple and functional.

Finally, passing that condition, Model::afterSave is ready to check the status (with another behavior-set model variable) and to dispose of the db transaction as necessary. Before that, though, you can see the commented sections. In other models, I need to also modify tangentially-related data in other tables during afterSave (and during my db transaction). You can see where that would fit in. Success checks and transaction handling should be included, too.

In light in-development testing, this seems to do the job I need. I had hoped to scale back to something still less-intrusive than this, but I think my transaction and related data requirements force me to leave it in this state. Still, it was a fun exercise in learning CakePHP's save-callback process and trying to solve a problem in a Cakey way.

freetags:

Add new comment