Overriding Model Getters in Doctrine

When I was working on my current project using Symfony 1.4 with Doctine 1.2, I needed to override Doctrine’s get method to return a date format of “Y-m-d” rather than default ‘Y-m-d H:i:s’ format for timestamp value. It was simply the matter of doing:

public function getStart()
{
	return date('Y-m-d', strtotime(parent::getStart()));
}

in Propel, however, Doctrine will complain:

Maximum function nesting level of ’100′ reached, aborting!

Somehow the call to its parent gets resolved somewhere back to the subclass, very weird.

After some googling, I found out that it is because Doctrine doesn’t really have a method called “getStart()”, it was done through a magic __call method that looks for getters and setters and forwards them to a geneic get or set method in Doctrine_Record class. So the call to parent::getStart() will essentially calls to itself, and causes the error mentioned above.

The solution is to do the following:

public function getStart()
{
	return date('Y-m-d', strtotime(parent::_get('start')));
}

I am still new to Doctrine as I have been using Propel for years, but I really want to taste Doctrine as there are so many positives about them on the net.

I think once I am used to Doctrine, it will certainly be more powerful than Propel.

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

<?php

class elChangeLogableBehaviour {

  // The list of change logs that will record edit operations
  protected $_saveEntries = array();

  // The list of change logs that will record delete operations
  protected $_deleteEntries = array();

  public function preSave($object, $con) {

    // clear the instance pool so that we can get a fresh copy from database
    // in order to compare the before and after values
    call_user_func(array($object->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.

Get Working Environment in Symfony

While developing a Symfony application, in lots of cases, you might want to know the current working environment and do some action accordingly. I used to use the following code to retrieve the environment:

	$env = sfContext::getInstance()->getEnvironment();

However, this is a tedious and time consuming way to retrieve the data. The environment information is stored in the sfConfig object after project and application are initialised. So the better and faster way to do it is:

	$env = sfConfig::get('sf_environment');

This will set $env to be ‘prod’, ‘dev’, ‘beta’ based on the environment you set in the frontend controller.

Happy Symfonying… 🙂

Add Title for Symfony Backend Object Actions

To be able to add title for Symfony’s backend object actions ( the tooltip for the action name when user mouse over the object actions ) in Propel’s admin generator, use the following syntax in your generator.yml file:

...
object_actions:
  upload: { label: "Upload" ,action: upload , params:{ title: "Upload Document For This ....." }  }
...

It should work for Symfony 1.2, 1.3 and 1.4 releases.

YAML Support in Eclipse

YAML is a very nice format for configurations and is very popular in the Symfony world. However, it is not supported by Eclipse IDE natively (Eclipse simply sees it as a pure text file with no highlighting at all).

Luckily, there is a plugin you can install in Eclipse to take the advantage of this powerful file format.

Go to http://code.google.com/p/yedit/ and follow the installation instructions or simply add http://dadacoalition.org/yedit into your Eclipse install sites.

Happy YAMLing..