CakePHP Traceable Model Behavior
Sometimes it is useful to be able to associate an authenticated user with an action carried out on particular database table entry. For example, if you have a collection of recipes in a database which can be soft-deleted (i.e., marked as deleted but not actually removed from the database in order to allow for undo functionality), you might want to keep track of which user deleted a particular recipe. This is relatively easy to do in CakePHP; you can simply use association aliases to link a particular User to the deletion action by adding a corresponding foreign key to the Recipe table and defining the association accordingly:
1 2 3 4 5 6 | public $belongsTo = array( 'DeletedBy' => array( 'className' => 'User', 'foreignKey' => 'deleted_by' ) ); |
This is all well and good, but you have to manage the foreign keys for each of these associations somehow, which would typically involve either adding a hidden form field to the appropriate view containing the currently authenticated user’s id, or using the beforeSave model callback to add the user’s id into the data set. This can soon get cumbersome if you have many models which you need to trace in this way. To solve this issue I wrote a model behaviour which automates the process of associating the user performing an action with the model.
The behaviour works via a system of trigger and target fields. When the value of a trigger field is set, the currently authenticated user is automatically associated with the model using an alias which corresponds to the action being performed. If the value of a trigger field should be ‘unset’ (more on what constitutes unset in a minute), the association will be empty.
To clarify with an example, if you have a TINYINT(1) field named ‘deleted’ which indicates the soft-deletion state of a recipe (1 = deleted, 0 = not deleted), the behaviour will automatically provide an association with the user who performed the deletion, using an alias ‘DeletedBy’. The result of a find call to the Recipe model where the recipe’s deleted field = 1 would then be as follows:
1 2 3 4 5 6 7 8 9 10 11 | Array ( 'Recipe' => Array ( 'id' => 1 // etc ), 'DeletedBy' => Array ( 'id' => 1, 'username' => 'Karl' // etc ) ) |
For recipes whose deleted field = 0, the result would be:
1 2 3 4 5 6 7 | Array ( 'Recipe' => Array ( 'id' => 1 // etc ), 'DeletedBy' => Array () ) |
Note that the association is still present, but it contains no data, because no user deleted the recipe.
When determining if the value of a trigger field such as ‘deleted’ is set or unset, the behaviour uses two mechanisms. The first is simply to test if the value is empty according to PHP’s empty() function - if it is, the value is considered unset, otherwise it is considered set. The second mechanism is to check the value against a list of values which would not be considered empty according to PHP’s empty() function, but which you want to treat as unset anyway. An example of this would be an empty datetime string (“0000-00-00 00:00:00″); if it represents the date a recipe was deleted for example, it should be considered unset. By default the behaviour is set up to treat empty datetime strings as unset, but other arbitrary values can be added to this.
When a trigger field is determined to be unset, the target field’s value is set to 0 by default. This can be configured on a per-model basis.
The behaviour can handle any number of associations simultaneously - you might want to keep track of who created or modified a particular row for example. There really is no limit, as long as you define a trigger field and a corresponding target field the behaviour will automatically handle the associations.
By default the behaviour will use the trigger fields ‘created’, ‘modified’, ‘deleted’, ‘hidden’ and ‘locked’, using the target fields ‘created_by’, ‘modified_by’, ‘deleted_by’, ‘hidden_by’ and ‘locked_by’ (though these defaults can be easily configured). Extra trigger and target fields can be set on a per-model basis by defining them when adding the behaviour to the model (note that you will also need to re-include any default fields which you may need):
1 2 3 4 5 6 7 8 | public $actsAs = array( 'Traceable' => array( 'fields' => array( 'created' => 'created_by', 'modified' => 'modified_by', 'published' => 'published_by' ) ); |
There are a few other settings which can be defined here too:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | public $actsAs = array( 'Traceable' => array( // The name of the user model used for Authentication. Defaults to 'User' 'user_model' => 'User', // An array of trigger => target fields to trace for this model. 'fields' => array( 'created' => 'created_by', 'modified' => 'modified_by', 'published' => 'published_by' ), // The value to which target fields will be set when the corresponding trigger field is unset. // Defaults to 0. 'restored_value' => 0 ); |
Enough explanation, here is the code.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 | <?php /** * * Automagically adds the logged in user's id to the specified fields when certain * events occur within the model. * * NB: If this behavior is not working, be sure you are not using saveField to save the * trigger field. If you do, no other fields (including the target field) will be saved. * Instead use a standard model->save call. This is a cake issue so nothing can be * done without altering the core. * * This works on a system of trigger fields and target fields. When a 'trigger' field * is saved, and it updates a corresponding 'target' field. For example, if you have * a trigger field called 'deleted' with a corresponding target field of 'deleted_by', * when deleted is saved with an empty value (anything which causes empty() to return true) * then the deleted_by field is set to a specified value (by default, 0). When deleted * is saved with a non-empty value, deleted_by is updated with the id of the currently * Authed user. * * When using find queries on a model which implements this behaviour, a * user model will be generated for each target field and stored under a corresponding * name in the data array (assuming the recursive level permits of course). * For example, a User model for user identified in the created_by field will * be stored under the CreatedBy key in the returned data array: * * Array ( * 'Webpages' => Array ( * 'id' => 1 * // etc * ), * 'CreatedBy' => Array ( * 'id' => 1, * 'username' => 'Karl' * // etc * ) * ) * * In the event that no user model can be associated with the target field (for * example, of the target field contains 0 or NULL), the target field data will * be set to an empty array: * * Array ( * 'Webpages' => Array ( * 'id' => 1 * // etc * ), * 'CreatedBy' => Array () * ) * * @author Karl Rixon <[email protected]> * @version 1.0 * **/ class TraceableBehavior extends ModelBehavior { /** * @var mixed An array of default configuration options * @access private */ private $_defaults = array( // The name of the user model. 'user_model' => 'User', // An array of fields, with a trigger field as key and a target field as value. // When the trigger field is set to a non empty() value, the user's id is // inserted into the corresponding target field. When it is set to an empty() // value, the target field is reset to restored_value. 'fields' => array( 'created' => 'created_by', 'modified' => 'modified_by', 'deleted' => 'deleted_by', 'hidden' => 'hidden_by', 'locked' => 'locked_by' ), // The value to which target fields will be set when toggled back. 'restored_value' => 0, // If true, a user model representing each target field will be automatically bound // to the model which implements this behaviour (using a belongsTo association). 'auto_bind' => true, 'map' => array() ); /** * @var int Stores the id of the currently Authed user. * @access private */ private $_userId = 0; /** * @var mixed An array of values which should indicate that a field has been emptied. There * is no need to specify the values which evaluate true using PHP's empty() function; * this is intended for custom values which would not normally be considered empty(). * @access private */ private $_empty = array( '0000-00-00 00:00:00' // empty datetime ); private $_noTrace = false; /** * Initialises the behaviour. * * @param Model $model A reference to the model object to which this behaviour is attached. * @param mixed $config An array of behaviour configuration options to be merged with the defaults. * @return void * @author Karl Rixon <[email protected]> * @version 1.0 * @since 1.0 */ function setup($model, $config = array()) { if (!$model->useTable) { // Model is not tied to a database table. return; } $this->settings[$model->alias] = array_merge($this->_defaults, (array) $config); if (empty($this->_userId)) { $this->_userId = $this->_getAuthedUserId($model); } $this->settings[$model->alias]['map'] = $this->_buildFieldMap($model); if ($this->settings[$model->alias]['auto_bind']) { $this->_bindModels($model); } } /** * Called before model data is saved. * * This method is just used as a place to attach a call to the _trace method which * does all the hard work of managing the trigger/target fields. * * @param Model $model A reference to the model object to which this behaviour is attached. * @return boolean Always returns true to allow the save to continue as normal. * @author Karl Rixon <[email protected]> * @version 1.0 * @since 1.0 */ function beforeSave($model) { if (!empty($this->settings[$model->alias]['map'])) { $this->_trace($model); } return true; } /** * Cleans up the found data, changing empty association models to empty arrays. * * Without this, any empty association model data will contain all of the keys * from the User model's table. It seems neater to return an empty array for any * items which do not have a matching user model. * * @param Model $model A reference to the model object to which this behaviour is attached. * @param mixed $results An array containing the data returned from the find. * @return void * @author Karl Rixon <[email protected]> * @version 1.0 * @since 1.0 */ function afterFind($model, $results, $primary) { if (empty($this->settings[$model->alias]['map'])) { return $results; } if ($model->recursive == -1) { return $results; } if (!empty($results) && $primary) { foreach ($results as &$result) { if (!isset($result[$model->alias])) { continue; } foreach ($this->settings[$model->alias]['map'] as $associationName) { if (empty($result[$associationName]['id'])) { $result[$associationName] = array(); } } } } return $results; } /** * Checks for any triggered fields, and sets the corresponding target field accordingly. * * If a triggered field is found in the data, and it's value is empty (anything * which evaluates to true using PHP's empty() function), its target field is * set to the restored_value. If it is not empty, its target field is set to the * id of the currently Authed user. * * @param Model $model A reference to the model object to which this behaviour is attached. * @return bool True if succesful, false if an error occured. Note that true will be * returned even if no trigger fields were found. False will only be returned if * an actual error occured - the lack of trigger fields is not considered an error. * @author Karl Rixon <[email protected]> * @version 1.0 * @since 1.0 */ private function _trace(&$model) { if (!isset($this->settings[$model->alias]['fields'])) { return false; } elseif (!$this->_userId) { return false; } foreach ($this->settings[$model->alias]['fields'] as $trigger => $target) { if (isset($model->data[$model->alias][$trigger])) { if (empty($model->data[$model->alias][$trigger]) || in_array($model->data[$model->alias][$trigger], $this->_empty)) { $model->data[$model->alias][$target] = $this->settings[$model->alias]['restored_value']; } else { $model->data[$model->alias][$target] = $this->_userId; } } } return true; } /** * Gets the id of the currently Authed user. */ private function _getAuthedUserId($model) { App::import('Component', 'Session'); $session = new SessionComponent(); return $session->read('Auth.' . $this->settings[$model->alias]['user_model'] . '.id'); } /** * Builds an array with field names as keys, and camelized versions of * the field names as values. */ private function _buildFieldMap($model) { // Get an array of all fields which exist in both the model's table, and in // the behaviour settings for this model. $fields = array_values(array_intersect( $this->settings[$model->alias]['fields'], array_keys($model->_schema) )); $map = array(); foreach ($fields as $field) { $map[$field] = Inflector::camelize($field); } return $map; } /** * Binds any models which should be bound in order to represent the * User who is to be traced. */ private function _bindModels($model) { if (empty($this->settings[$model->alias]['map'])) { return false; } $models = array(); foreach ($this->settings[$model->alias]['map'] as $foreignKey => $associationName) { $models['belongsTo'][$associationName] = array( 'className' => $this->settings[$model->alias]['user_model'], 'foreignKey' => $foreignKey ); } if (sizeof($models) > 0) { $model->bindModel($models, false); } } } ?> |
This is a nice plugin and should be put in GitHub…. It’s similar to https://github.com/josegonzalez/trackable-behavior
One issue is how the user ID is retrieved - instantiating a new SessionComponent will throw an error saying session is already started. Also, it should be a static variable so if it’s re-used several times across several models, it’s not causing further problems. I changed the code that retrieves the user to the User singleton hack out there: User::get(‘id’). Though I had to remove the user_id retrieval from setup() and wait until beforeSave() because some models get instantiated before the user model does.
Tomas Maly
4 May 11 at 4:17 am
Hi Tomas
I can’t reproduce the behaviour you mention with the SessionComponent complaining that the session has started.
Also there isn’t any need for a static variable - only one instance of the behaviour is used for all models, not one per model. This is handled internally by Cake, and is the reason for indexing the settings property of the behaviour using the model’s alias.
Karl
23 Jul 11 at 12:13 pm