Cookbook Recipe: Relation DQL Behavior

Posted on January 28, 2009 by jwage


Today I will teach you how to write a simple Doctrine behavior. You will learn some of the basics of creating a behavior and you will gain some pretty cool functionality for your relationships.

We will write a behavior called RelationDql which allows you to add default query parts that are automatically added to your queries when you reference the specified relationships. So first we will get started by looking at an example schema we can apply this to.

Here is an example schema where we have a Site, BlogPost and Tag model:

[yml]
Site:
  columns:
    name: string(255)

BlogPost:
  actAs: [Timestampable]
  columns:
    title: "string(255)"
    body: clob
    site_id: integer
  relations:
    Site:
      foreignAlias: BlogPosts
    Tags:
      class: Tag
      refClass: BlogPostTag
      foreignAlias: BlogPosts

Tag:
  columns:
    name: string(255)

BlogPostTag:
  columns:
    blog_post_id:
      type: integer
      primary: true
    tag_id:
      type: integer
      primary: true
  relations:
    BlogPost:
      foreignAlias: BlogPostTags
    Tag:
      foreignAlias: BlogPostTags

This is a fairly simple schema as you can see, but what if we want to have a relationship on the Site model to retrieve the latest five BlogPost records or order the Tags relationship alphabetically by default?

Lets modify our schema to take into account a new behavior that we will write in the next step. First modify the Site model and a relationship named LatestBlogPosts:

[yml]
Site:
  actAs:
    RelationDql:
      relations:
        LatestBlogPosts:
          orderBy: %s.created_at DESC
          limit: 5
  columns:
    name: string(255)
  relations:
    LatestBlogPosts:
      autoComplete: false
      class: BlogPost
      local: id
      foreign: site_id

**TIP** The ``autoComplete`` option is set to ``false`` so that the
relationship is not reflected and added to the opposite end,
``BlogPost`` automatically.

Now lets modify the BlogPost model to change the Tags relationship so that it is ordered alphabetically by name by default:

[yml]
BlogPost:
  actAs:
    Timestampable:
    RelationDql:
      relations:
        Tags:
          orderBy: %s.name ASC
  columns:
    title: "string(255)"
    body: clob
    site_id: integer
  relations:
    Site:
      foreignAlias: BlogPosts
    Tags:
      class: Tag
      refClass: BlogPostTag
      foreignAlias: BlogPosts

**TIP** The ``relations`` array is an array of changes to make to
the DQL query. The key can be any valid function on the
``Doctrine_Query`` API and the value is of course the parameter to
pass to the function.

Now that we have our schemas modified to take into account the new RelationDql behavior we need to actually write the code:

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

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

  public function setTableDefinition()
  {
    $this->_table->addRecordListener(new RelationDqlListener($this->_options));
  }
}

The template is very simple. It only attaches a record listener to the invoking table. Their is where most of the magic happens. So now lets define the RelationDqlListener class:

<?php
class RelationDqlListener extends Doctrine_Record_Listener
{
  protected $_options = array('relations' => array());

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

  public function preDqlSelect(Doctrine_Event $event)
  {
    $query = $event->getQuery();

    if (empty($this->_options['relations']))
    {
      throw new Doctrine_Exception(
        'You must specify at least one relationship to add DQL to'
      );
    }

    $relations = $this->_options['relations'];

    $components = $this->_getDqlCallbackComponents($query);
    foreach ($components as $alias => $component)
    {
      if (isset($component['relation']) && isset($relations[$component['relation']->getAlias()]))
      {
        $dqls = $relations[$component['relation']->getAlias()];
        foreach ($dqls as $func => $dql)
        {
          $dql = str_replace('%s', $alias, $dql);
          $query->$func($dql);
        }
        unset($relations[$component['relation']->getAlias()]);
      }
    }
  }

  protected function _getDqlCallbackComponents($query)
  {
      $params = $query->getParams();
      $componentsBefore = array();
      if ($query->isSubquery()) {
          $componentsBefore = $query->getQueryComponents();
      }

      $copy = $query->copy();
      $copy->getSqlQuery($params);
      $componentsAfter = $copy->getQueryComponents();

      if ($componentsBefore !== $componentsAfter) {
          return array_diff($componentsAfter, $componentsBefore);
      } else {
          return $componentsAfter;
      }
  }
}

So now we have the behavior defined so lets look at some example DQL queries and the SQL that is outputted:

TIP Remember, in order for the dql callbacks to be executed we must enable an attribute first.

<?php
    $manager->setAttribute('use_dql_callbacks', true);
<?php
$q = Doctrine_Query::create()
  ->select('s.name, p.title, p.created_at')
  ->from('Site s')
  ->leftJoin('s.LatestBlogPosts p');

echo $q->getSql();

The above would output the following SQL:

[sql]
SELECT s.id AS s__id, s.name AS s__name, b.id AS b__id, b.title AS b__title, b.created_at AS b__created_at FROM site s LEFT JOIN blog_post b ON s.id = b.site_id ORDER BY b.created_at DESC LIMIT 5

**NOTE** Notice how the ``ORDER BY`` and ``LIMIT`` were added to
the query.

Now lets look at an example that involves the BlogPost tags:

<?php
$q = Doctrine_Query::create()
  ->from('BlogPost p')
  ->leftJoin('p.Tags t');

echo $q->getSql();

The above would output the following SQL query:

[sql]
SELECT b.id AS b__id, b.title AS b__title, b.body AS b__body, b.site_id AS b__site_id, b.created_at AS b__created_at, b.updated_at AS b__updated_at, t.id AS t__id, t.name AS t__name FROM blog_post b LEFT JOIN blog_post_tag b2 ON b.id = b2.blog_post_id LEFT JOIN tag t ON t.id = b2.tag_id ORDER BY t.name ASC

As you can see the ORDER BY clause to order the related tags by name was added for us.

Pretty cool huh? You can use this in your projects to make your relationships a little nicer.