what's on our mind.

Doctrine with Nested I18N & Versionable

Posted @ 2:19 pm by Lee Springer | Category: Technology | 2 Comments

The open source Symfony PHP framework project recently made it clear that the future of Symfony is Doctrine ORM. Doctrine is an incredibly powerful and useful tool due to its built-in behaviors, which allow users to quickly enable complex relations and functionality with a few configuration settings.

One of the more advanced features of Doctrine is the ability to nest behaviors, or stack them on top of each other for combined functionality. However, one of the most useful potential combinations of behaviors in the default Doctrine 1.0 bundled with Symfony 1.2 — nesting I18N with Versionable — is currently broken in Doctrine. This is noted in the Doctrine documentation under the nesting behaviors section. This nest would give you the ability to have a model that auto versioned while supporting content in multiple languages, a very handy tool for any CMS type application.

After fighting against the SQL insert issues to try to get the below example schema to work,

Page:
  actAs:
    Timestampable: ~
    I18n:
      fields: [ title, nav_text, meta_keywords, meta_description, content ]
    Versionable: ~
  tableName: page
  columns:
    id:
      type: integer(4)
      primary: true
      autoincrement: true
    version:
      type: integer(4)
      notnull: true
      default: 0
    active:
      type: boolean
      notnull: true
      default: 1
    title:
      type: string(255)
      notnull: true
    nav_text: string(255)
    meta_keywords: string(2147483647)
    meta_description: string(2147483647)
    content: string(2147483647)

I realized that it is fairly straightforward to apply a quick fix to the problem. We first remove the Versionable from our original schema above, as it doesn’t work the way we want it to. By default, the Versionable behavior tries to create a duplicate table of the model appended with “_version”.  This is easy to recreate in our schema.

PageVersion:
  actAs:
    I18n:
      fields: [ title, nav_text, meta_keywords, meta_description, content ]
  tableName: page_version
  columns:
    id:
      type: integer(4)
      primary: true
      autoincrement: true
    orig_id:
      type: integer(4)
      notnull: true
    version:
      type: integer(4)
      notnull: true
    active:
      type: boolean
      notnull: true
    title:
      type: string(255)
      notnull: true
    nav_text: string(255)
    meta_keywords: string(2147483647)
    meta_description: string(2147483647)
    content: string(2147483647)
    created_at: timestamp
    updated_at: timestamp

We now have two near identical tables, the exception being the addition of 3 new columns in the page_version table. We will use these new columns to capture the timestampable behavior from the page table and capture the original page id.

Now that we have the two tables, we need to alter our models to reproduce the Versionable behavior. Time to fire off a doctrine:build-model. Let’s start by setting up some queries in our PageVersion Table class.

class PageVersionTable extends Doctrine_Table
{
  public function retrieveVersion($object_id, $version)
  {
    $version = (int) $version;
    $object_id = (int) $object_id;
    if($version < 1 || $object_id < 1)
    {
      return null;
    }
    $q = $this->createQuery('v')
    ->where('v.orig_id = ?', $object_id)
    ->andWhere('v.version = ?', $version)
    ->leftJoin('v.Translation t');
    return $q->fetchOne();
  }

  public function retrieveMaxRevision($object_id)
  {
    $object_id = (int) $object_id;
    if($object_id < 1 )
    {
      return 1;
    }
    $q = $this->createQuery('v')
    ->select('MAX(v.version) as max')
    ->where('v.orig_id = ?', $object_id);
    $return = $q->setHydrationMode(Doctrine::HYDRATE_NONE)->fetchOne();
    return array_shift($return);
  }

  public function getAllVersionsQuery($orig_id)
  {
    $orig_id = (int) $orig_id;
    if($orig_id < 1)
    {
      return null;
    }
    $q = $this->createQuery('v')
    ->where('v.version >= ?', 1)
    ->andWhere('v.orig_id = ?', $orig_id)
    ->orderBy('v.version DESC');
    return $q;
  }
}

This gives us 3 useful methods.

  • retrieveVersion to retrieve a specific version out of the page_version table
  • retrieveMaxRevision to get the latest version number
  • getAllVersionsQuery to get all versions based on the original object id

Next we want to recreate the ->revert() method that Versionable provides. First, let’s create a mapping function in our PageVersion Class, which will make translation from a PageVersion object to a Page object much easier.

class PageVersion extends BasePageVersion
{
  public function toRevertArray()
  {
    $thisArray = $this->toArray();
    $thisArray['id'] = $thisArray['orig_id'];
    unset($thisArray['orig_id'], $thisArray['id']);
    foreach($thisArray['Translation'] as $k => $v)
    {
      $thisArray['Translation'][$k]['id'] = $this->orig_id;
    }
    return $thisArray;
  }
}

Lastly, we need to update the Page class to override the save function so that it automatically creates PageVersion objects on each change and gives us the needed methods to revert to an older revision.

class Page extends BasePage
{
  public function getVersionClass()
  {
    return __CLASS__.'Version';
  }

  public function getVersionObject()
  {
    $class = $this->getVersionClass();
    $object = new $class;
    return $object;
  }

  public function save(Doctrine_Connection $conn = null)
  {
    if(is_null($conn))
    {
      $conn = $this->_table->getConnection();
    }
    $this->calculateVersion();
    parent::save($conn);
    $this->mapToVersion();
  }

  public function calculateVersion()
  {
    if(!$this->isNew())
    {
      $this->version = Doctrine::getTable($this->getVersionClass())->retrieveMaxRevision($this->id) + 1;
    }
  }

  public function mapToVersion()
  {
    $version = $this->getVersionObject();
    $version->fromArray($this->toVersionArray());
    $version->save();
  }

  public function toVersionArray()
  {
    $thisArray = $this->toArray();
    $thisArray['orig_id'] = $thisArray['id'];
    unset($thisArray['id']);
    foreach($thisArray['Translation'] as $k => $v)
    {
      unset($thisArray['Translation'][$k]['id']);
    }
    return $thisArray;
  }

  public function revert($version)
  {
    $version = (int) $version;
    $objectVersion = Doctrine::getTable($this->getVersionClass())
    ->retrieveVersion($this->id, $version);
    $this->fromArray($objectVersion->toRevertArray());
  }
}

Now on each change the Page object is updated, but also creates a PageVersion object. The PageVersion objects record the full history of our model including I18N fields. Reverting back to a revision is a simple as calling $page->revert($version);

All of the above methods are abstracted from using model specific namespaces, so they are reusable and can be dropped in to any class. You now have a working application that reproduces nesting Versionable and I18N.

2 thoughts on “Doctrine with Nested I18N & Versionable

  1. Damien Filiatrault says:

    Thanks for documenting this, Lee. This will come in very handy on a project I am currently working on.

  2. fabian says:

    Well explained, thanks Lee.

Leave a reply