Recently the open source Symfony PHP framework project 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, these behaviors 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 in essence 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 is currently broken in Doctrine, nesting I18N with Versionable. 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. First we 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 and lets’ 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, this 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 to automatically create PageVersion objects on each change and to also give 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. Now you have a working application that reproduces nesting Versionable and I18N.