Using Behaviors to Share Relationship Properties

Posted over 3 years ago 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.

---
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');
            }
        }
    }
}

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:

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.

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');

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:

---
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.


Comments (10) [ add comment ]

Wow Posted by jukea about over 3 years ago.

did you just implement class table inheritance with behaviors ? I read it quickly but at first glance, it looks like it ..

To me, CTI is the holy grail of DB inheritance, so I guess you would've called it by it's name if that's what it was.. but then, what's lacking in this implementation ?

@jukea Posted by jwage about over 3 years ago.

I contemplated calling this FakeInheritance but it doesn't do a few key things. Obviously it isn't real inheritance, the classes don't extend the other classes. If you query for the data, you have to manually specify the joins. I am sure their are other edge cases where it doesn't behave how a real inheritance implementation might.

@jukea Posted by jwage about over 3 years ago.

With a little work this could probably be enhanced with preDqlSelect() hooks to automatically join in the models for you and things like that. But it might be difficult to accomplish.

@jwage Posted by jukea about over 3 years ago.

In theory, wouldn't lazy loading apply, so it would work without the joins ?

Also, I'm very interested on extending this to come as close to CTI as possible. Do you think something relatively clean could be done to let the behavior change the base class of a model ?

@jukea Posted by jwage about over 3 years ago.

You could maybe combine it with concrete inheritance. I gave it a try but the problem is all child classes inherit the columns of the parent classes. So, you'd have to write some code to unset the columns of each child class that is not owned by that class.

double? Posted by Harro about over 3 years ago.

SharedProperties::setUp -> Why loop through the relations twice? That will only slow it down.

@Harro Posted by jukea about over 2 years ago.

I think this is to setup the relations in both classes implied. The relation has two directions, from A to B and from B to A.

@Harro Posted by jukea about over 2 years ago.

I think this is to setup the relations in both classes implied. The relation has two directions, from A to B and from B to A.

Simple enhance? Posted by leonardo diaz about over 2 years ago.

a small improvement could be to use a non static type and lenght definition for the foreign key in then setTableDefinition method, 'cause right now is using a integer, with no defined lenght so by default is using bigint 20 in mysql, and if you have a "parent" class with a pk integer(4) it gives an error when adding the behavior (at least with symfony and mysql)

SQLSTATE[HY000]: General error: 1005 Can't create table './ivr/#sql-158d_1e.frm' (errno: 150). Failing Query: ALTER TABLE doctor ADD FOREIGN KEY (sf_guard_user_id) REFERENCES sf_guard_user(id) ON DELETE CASCADE

so could you please point us what method to use to get the foreign type and length as the API does not clearly explains all the methods? or if there is a better way to do this??

combine with concrete inheritance Posted by moepMan about over 2 years ago.

$decision = Is it possible to combine this behavior with the concrete inheritance?

if ($decision) { tell me how to do it; } explain the usage of concrete inheritance, please...

i dont know how to do anything with concrete inheritance...

Create Comment