Add ChangLogable Behaviour To Your Symfony Models

Add ChangLogable Behaviour To Your Symfony Models

Last week I have been working on a task to log backend edits to certain models in Symfony so that admin can keep track of all history of creates, edits and updates to tracked models. I have done some research and found some plugins that does similar job, but none of them were up-to-date, and most of them just don’t meet what we are really looking for.

For simplicity, we decided to develop one ourselves for Symfony 1.4 and for now only keep track of the columns on the table, i.e. ignore one-to-many or many-to-many relationships. I might develop it to a Symfony plugin in the future, but for the purpose of the post, I will simply outline what I did and how it works. I am sure someone might be benefits from the code.

To start with, I needed to enable the behaviour for Propel in the propel.ini configuration file:

propel.builder.addBehaviors = true

create a table with the following schema:

DROP TABLE IF EXISTS `change_log`;
CREATE TABLE IF NOT EXISTS `change_log` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `admin_id` smallint(3) unsigned NOT NULL,
  `table` varchar(80) COLLATE utf8_unicode_ci NOT NULL,
  `primary_id` int(11) unsigned NOT NULL,
  `column` varchar(80) COLLATE utf8_unicode_ci NOT NULL,
  `action` varchar(10) COLLATE utf8_unicode_ci NOT NULL,
  `before` text COLLATE utf8_unicode_ci NOT NULL,
  `after` text COLLATE utf8_unicode_ci NOT NULL,
  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `admin_search` (`admin_id`,`created_at`),
  KEY `table_search` (`table`,`column`,`created_at`),
  KEY `column_search` (`column`,`primary_id`,`created_at`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci AUTO_INCREMENT=1 ;

and I added the following to propel’s schema definition:

  change_log:
    _attributes: { phpName: ChangeLog }
    id:
    admin_id: ~
    table: { type: VARCHAR, size: '255', required: true }
    primary_id: { type: INTEGER, size: '11', required: true }
    column: { type: VARCHAR, size: '255', required: true }
    action: { type: VARCHAR, size: '10', required: true }
    before: { type: LONGVARCHAR, required: false }
    after: { type: LONGVARCHAR, required: false }
    created_at:

and then re-generate the models by running

./symfony propel:build-model

Secondly, I created a new behaviour class called “elChangeLogableBehaviour”, “el” for “Eric Lin” by the way

getPeer(), 'clearInstancePool'));

    $modifiedColumns = $object->getModifiedColumns();
    $tableName = constant(get_class($object->getPeer())."::TABLE_NAME");
    $id = (int) $object->getPrimaryKey();

    // retrieve the object with old data ( the data in the database )
    $oldObject = call_user_func_array(array($object->getPeer(), 'retrieveByPk'), array($id));

    // default action is update
    $action = 'update';

    // if there is no record in the database, means it is an insert ( new record )
    if(!$oldObject) {

      $className = get_class($object);
      $oldObject = new $className;
      $action = 'insert';
    }

    // go through each modified columns and create one change log each without saving them
    foreach($modifiedColumns as $column) {

      $beforeValue = $oldObject->getByName($column, BasePeer::TYPE_COLNAME);
      $afterValue = $object->getByName($column, BasePeer::TYPE_COLNAME);

      // if values are the same, don't record them
      if($beforeValue == $afterValue) continue;

      $changeLog = new ChangeLog();
      $changeLog->setNew(true);
      $changeLog->setAdminId(sfContext::getInstance()->getUser()->getId());
      $changeLog->setPrimaryId($object->getPrimaryKey());
      $changeLog->setTable($tableName);
      $changeLog->setColumn(call_user_func_array(array($object->getPeer(), 'translateFieldName'), array($column, BasePeer::TYPE_COLNAME, BasePeer::TYPE_FIELDNAME)));
      $changeLog->setAction($action);
      $changeLog->setBefore($beforeValue);
      $changeLog->setAfter($afterValue);
      $changeLog->setCreatedAt(date('Y-m-d H:i:s'));

      $this->_saveEntries[$tableName][$id][$column] = $changeLog;
    }

    return true;
  }

  // Once the save is performed, save all the change logs for the given object
  public function postSave($object, $con) {

    $tableName = constant(get_class($object->getPeer())."::TABLE_NAME");
    if(isset($this->_saveEntries[$tableName])) {

      foreach($this->_saveEntries[$tableName] as $id => $columnChanges) {

        foreach($columnChanges as $column => $changeLog) {

          // need to update the primary for new records
          if($changeLog->getPrimaryId() == 0) {

            $changeLog->setPrimaryId($object->getPrimaryKey());
          }
          $changeLog->save();
        }
      }
    }
  }

  // Create a chagne log entry for delete operation
  public function preDelete($object, $con) {

    $tableName = constant(get_class($object->getPeer())."::TABLE_NAME");

    $changeLog = new ChangeLog();
    $changeLog->setNew(true);
    $changeLog->setAdminId(sfContext::getInstance()->getUser()->getId());
    $changeLog->setPrimaryId($object->getPrimaryKey());
    $changeLog->setTable($tableName);
    $changeLog->setColumn('');
    $changeLog->setAction('delete');
    $changeLog->setBefore('');
    $changeLog->setAfter('');
    $changeLog->setCreatedAt(date('Y-m-d H:i:s'));

    $this->_deleteEntries[$tableName][$object->getId()] = $changeLog;
  }

  // Save the delete change log after delete operation
  public function postDelete($object, $con) {

    foreach($this->_deleteEntries as $changeLogs) {

      foreach($changeLogs as $changeLog) {

        $changeLog->save();
      }
    }
  }
}

I have also updated the myUser class to store the admin_id in the user session so that we can retrieve it easily:

public function logIn($id=0) {

  $this->setAuthenticated(true);
  $this->setAttribute('userId', $id, 'currentUser');
}

public function getId() {

  return $this->getAttribute('userId', 0, 'currentUser');
}

Thirdly, I updated setup function in my project’s ProjectConfiguration class:

  public function setup() {
    // for compatibility / remove and enable only the plugins you want
    $this->enableAllPluginsExcept(array('sfDoctrinePlugin'));

    sfPropelBehavior::registerHooks('changelogable', array(
      ':save:pre'    => array('elChangeLogableBehaviour', 'preSave'),
      ':save:post'   => array('elChangeLogableBehaviour', 'postSave'),
      ':delete:pre'  => array('elChangeLogableBehaviour', 'preDelete'),
      ':delete:post' => array('elChangeLogableBehaviour', 'postDelete')
     ));
  }

This allows me to register the hook to Propel’s models. I also needed to add

require_once dirname(__FILE__) . '/../lib/vendor/symfony/lib/plugins/sfPropelPlugin/lib/addon/sfPropelBehavior.class.php';

at the top of the file, otherwise my symfony will complain that the class is missing.

Then add:

propel.behavior.changelogable.class = lib.behaviour.elChangeLogableBehaviour

to my propel.ini file to enable the new behaviour for my Symfony project.

And finally I needed to register the new behaviour to the models that I need to track by adding the following line to the end of model class(es):

sfPropelBehavior::add('Publication', array('changelogable'));

That’s all, it should be able to record all changes done to the Propel model when someone changes data in the backend.

If you have any comments or suggestions, please post your idea to the comments below.

Leave a Reply

Your email address will not be published.

My new Snowflake Blog is now live. I will not be updating this blog anymore but will continue with new contents in the Snowflake world!