Using Behaviors to Share Relationship Properties

Posted on February 4, 2009 by jwage


Define The Schema

In this article we will demonstrate some more ways to add functionality to Doctrine by using the behavior system. We will call this behavior SharedProperties and it allows you to share properties between your models and one-to-one relationships. Here is an example schema that will make use of the behavior we will write.

[yml]
Entity:
  actAs:
    Sluggable:
      fields: [name]
    Timestampable:
  columns:
    name: string(255)
    is_active:
      type: boolean
      default: false

BlogPost:
  actAs:
    SharedProperties:
      relations: [Entity]
  columns:
    title: "string(255)"
    body: clob

User:
  actAs:
    SharedProperties:
      relations: [Entity]
  columns:
    username: string(255)
    password: string(255)

Administrator:
  actAs:
    SharedProperties:
      relations: [User]
  columns:
    responsibilities: string(255)

The schema should be somewhat self-explanatory. Each model that acts as SharedProperties you must specify an array of model names or existing relationship aliases. Our behavior will automatically add foreign keys for the list of models and instantiate a one-to-one relationship between the models automatically.

Write the Template

Lets write the first part of our template that simply adds a column for each of the listed relations:

<?php
class SharedProperties extends Doctrine_Template
{
    protected $_options = array();

    public function __construct($options)
    {
        $this->_options = $options;
    }

    public function setTableDefinition()
    {
        foreach ($this->_options['relations'] as $relation) {
            $columnName = Doctrine_Inflector::tableize($relation) . '_id';
            if (!$this->_table->hasColumn($columnName)) {
                $this->hasColumn($columnName, 'integer');
            }
        }
    }
}

**NOTE** You will notice we add columns for each of the
``relations`` specified if the column does not already exist. We
will use these columns to automatically create the
relationships/foreign keys between the models if they don't already
exist in the next step.

Enhance the Template

Now lets enhance our template and add a setUp() method to instantiate our relationships between the list of relations and the columns we added in the previous step:

<?php
class SharedProperties extends Doctrine_Template
{
    // ...

    public function setUp()
    {
        foreach ($this->_options['relations'] as $model) {
            $table = $this->_table;
            $local = Doctrine_Inflector::tableize($model) . '_id';
            $foreign = Doctrine::getTable($model)->getIdentifier();
            $this->_makeRelation($table, $model, $local, $foreign, true);
        }

        foreach ($this->_options['relations'] as $model) {
            $table = Doctrine::getTable($model);
            $local = $table->getIdentifier();
            $foreign = Doctrine_Inflector::tableize($model) . '_id';
            $this->_makeRelation($table, $this->_table->getOption('name'), $table->getIdentifier(), $foreign);
        }
    }

    protected function _makeRelation(Doctrine_Table $table, $model, $local, $foreign, $cascade = false)
    {
        if (!$table->hasRelation($model)) {
            $options = array('local'   => $local, 'foreign' => $foreign);
            if ($cascade) {
                $options['onDelete'] = 'CASCADE';
            }
            $table->bind(array($model, $options), Doctrine_Relation::ONE);
        }
    }
}

Generated SQL

This code we've added now makes a one-to-one relationship between the models that act as SharedProperties and the list of models specified. So for example, Entity has one BlogPost and BlogPost has one Entity. The above models at this point would generate the following SQL:

[sql]
CREATE TABLE administrator (id BIGINT AUTO_INCREMENT, responsibilities VARCHAR(255), user_id BIGINT, INDEX user_id_idx (user_id), PRIMARY KEY(id)) ENGINE = INNODB;

CREATE TABLE blog_post (id BIGINT AUTO_INCREMENT, title VARCHAR(255), body LONGTEXT, entity_id BIGINT, INDEX entity_id_idx (entity_id), PRIMARY KEY(id)) ENGINE = INNODB;

CREATE TABLE entity (id BIGINT AUTO_INCREMENT, name VARCHAR(255), is_active TINYINT(1) DEFAULT '0', slug VARCHAR(255), created_at DATETIME, updated_at DATETIME, UNIQUE INDEX sluggable_idx (slug), PRIMARY KEY(id)) ENGINE = INNODB;

CREATE TABLE user (id BIGINT AUTO_INCREMENT, username VARCHAR(255), password VARCHAR(255), entity_id BIGINT, INDEX entity_id_idx (entity_id), PRIMARY KEY(id)) ENGINE = INNODB;

ALTER TABLE administrator ADD FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE;

ALTER TABLE blog_post ADD FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE CASCADE;

ALTER TABLE user ADD FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE CASCADE;

Sharing Properties/Methods

Now to get to the fun, the main purpose of doing all this is to share the properties of these relationships. We can accomplish this by using the Doctrine_Record_Filter feature and some magic PHP __call() functionality. First lets modify our template to attach a new record filter.

TIP Records filters in Doctrine allow you to handle all unknown properties access on a Doctrine object. This allows us to forward the calls on to the relationships so you can access properties from them.

<?php
class SharedProperties extends Doctrine_Template
{
    // ...

    public function setTableDefinition()
    {
        // ...

        $this->_table->unshiftFilter(new SharedPropertiesFilter($this->_options));
    }

    // ...
}

Now that we have attached our filter we need to write that class:

<?php
class SharedPropertiesFilter extends Doctrine_Record_Filter
{
    protected $_options = array();

    public function __construct($options)
    {
        $this->_options = $options;
    }

    public function init()
    {
        foreach ($this->_options['relations'] as $model) {
            $this->_table->getRelation($model);
        }
    }

    public function filterSet(Doctrine_Record $record, $name, $value)
    {
        foreach ($this->_options['relations'] as $model) {
            try {
                $record->$model->$name = $value;
                return $record;
            } catch (Exception $e) {}
        }
        throw new Doctrine_Record_UnknownPropertyException(sprintf('Unknown record property / related component "%s" on "%s"', $name, get_class($record)));
    }

    public function filterGet(Doctrine_Record $record, $name)
    {
        foreach ($this->_options['relations'] as $model) {
            try {
                return $record->$model->$name;
            } catch (Exception $e) {}
        }
        throw new Doctrine_Record_UnknownPropertyException(sprintf('Unknown record property / related component "%s" on "%s"', $name, get_class($record)));
    }
}

Now you can see this filter checks to see if the property exists on any of the relations specified otherwise throws the normal Doctrine_Record_UnknownPropertyException.

The last thing we need to do is add a magic __call() function to our template to handle the forwarding of any unknown methods to the relations:

<?php
class SharedProperties extends Doctrine_Template
{
    // ...

    public function __call($method, $arguments)
    {
        $invoker = $this->getInvoker();
        foreach ($this->_options['relations'] as $model) {
            try {
                return call_user_func_array(array($invoker->$model, $method), $arguments);
            } catch (Exception $e) {
                continue;
            }
        }
    }
}

This is required if we have functions defined on the models and want to be able to access these methods. So for example if we were to add a setPassword() method to the generated User class like the following:

<?php
class User extends BaseUser
{
    public function setPassword($password)
    {
        $this->_set('password', md5($password));
    }
}

Without the above __call() function we would not be able to do the following:

<?php
$administrator = new Administrator();
$administrator->setPassword('new_password');

**TIP** **Auto Accessor and Mutator Overriding**

If you want Doctrine to automatically override accessors with
matching ``set*()`` and ``get*()`` functions then you need to
enable the ``auto_accessor_override`` attribute in your
configuration where you create your connections and set Doctrine
attributes:
<?php
    $manager = Doctrine_Manager::getInstance();
    $manager->setAttribute('auto_accessor_override', true);

Now with that attribute the following is possible. Instead of
having to call the method ``setPassword()``, Doctrine sees you are
setting the ``password`` and a method named ``setPassword()``
exists so it uses it to do the mutating.
<?php
    $administrator->password = 'new_password';

Example Usage

That is it! Our behavior is implemented and we are ready to write some code that use our new models.

Creating New Records

<?php
$admin = new Administrator();
$admin->name = 'Jonathan H. Wage';
$admin->username = 'jwage';
$admin->password = 'changeme';
$admin->is_active = 1;
$admin->responsibilities = 'Train all the PHP developers!';
$admin->save();

Now that code results in the following structure being persisted to the database:

<?php
print_r($admin->toArray(true));
/*
Array
(
    [id] => 2
    [responsibilities] => Train all the PHP developers!
    [user_id] => 2
    [User] => Array
        (
            [id] => 2
            [username] => jwage
            [password] => 4cb9c8a8048fd02294477fcb1a41191a
            [entity_id] => 3
            [Entity] => Array
                (
                    [id] => 3
                    [name] => Jonathan H. Wage
                    [is_active] => 1
                    [slug] => jonathan-h-wage
                    [created_at] => 2009-02-04 16:01:12
                    [updated_at] => 2009-02-04 16:01:12
                )

        )

)
*/

Data Fixtures

Similarly, the following data fixtures would be possible:

[yml]
BlogPost:
  BlogPost_1:
    name: Test Blog Post
    title: "This is a test blog post"
    body: This is a test blog post

Administrator:
  Administrator_1:
    name: Test Manager
    username: jwage
    password: changeme
    responsibilities: Overseeing development department

Querying For and Accessing Data

You can query for these relationships as well:

<?php
$q = Doctrine_Query::create()
    ->from('Administrator a')
    ->leftJoin('a.User u')
    ->leftJoin('u.Entity e')
    ->where('u.username = ?', 'jwage');

$user = $q->fetchOne();
echo $user['created_at'];

The above code would output the value of the created_at column that actually exists in the Entity model that is available through the Administrator->User->Entity relations.