Get objects and related count in one shot

July 29, 2011Benjamin Grandfond4 min read

Sometimes you just need to output the number of objects related to another, but this simple operation can be a major blow performance-wise. I hope this trick I use a lot in my symfony + doctrine developments will save you some time.

Let's consider a blog that allows you to tag your posts:

BlogPost:
  columns:
    title: string(255)
    body: clob
  relations:
    Tags:
      class: Tag
      foreignAlias: BlogPosts
      refClass: BlogPostTag
      local: blog_post_id
      foreign: tag_id

Tag:
  columns:
    name: string(255)

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

You can retrieve [this schema](http://www.symfony-project.org/cookbook/12/fr/doctrine) in the symfony 1.x documentation_

Now, we build an admin generator which shows the number of tags per blog post on the list, with 20 results per page. This means we will have 1 SQL request to retrieve the 20 posts and 1 SQL request per post to retrieve the tag count. Taking into account the count request of the pager, we will have a total of 22 requests. This will get worse if we choose to display more blog posts at a time.

There is a way to optimize this with Doctrine!

Add count into the query

Let's add the calculation of the tag count to the request that retrieves the blog posts.

It could look like that:

  // lib/model/doctrine/BlogPostTable.class.php

  /**
   * Find a blog post by its id.
   * @param integer $id
   * @return BlogPost|false
   */
  public function findById($id)
  {
    // Subquery that counts the number of tags per post.
    $sub_query = '(SELECT COUNT(t.id) FROM BlogPostTag t WHERE blog_post_id = '.$id.')';

    $query = $this->createQuery('bp')
      ->select('bp.*')
      ->addSelect($sub_query.' as nb_tags') // the number of tags will be in the nb_tags variable
      ->where('bp.id = ?', $id);

    return $query->execute();
  }
Explanations
  • The $subquery counts the number of tags for the blog post in SQL
  • Create a query that retrieves blog post by its id
  • Add the $subquery into the select with an alias 'nb_tags'. You have to specify what you want to select first to use the addSelect method, otherwise, it will not work.
  • Return the execution of the query
Result

The result of the query should be an instance of a DoctrineRecord (false if no blog post is found) which contains the result of the subquery into its protected array `$values`. As it is a protected attribute of the Doctrine_Record class it can be accessed in your BlogPost model class.

Create a smart getter

So now that we get the value of 'nb_tags' into the hydrated record we can write a getter that returns this value in a smart way.

First of all, you should add an attribute to your model class to store the number of tags:

  // lib/model/doctrine/BlogPost.class.php

  /**
   * The number of tags of the blog post.
   * @var Integer
   */
  protected $nb_tags = null;

Then, implement the getNbTags() that will return the value of the 'nbtags' key in the $values array of the doctrine record. But what if the record has been found by using another query? The 'nb_tags' will not exist so you have to test it otherwise you might face an exception. This is how you should write your getter:

  // lib/model/doctrine/BlogPost.class.php

  /**
   * Return the number of tags related to the blog post.
   *
   * @return Integer
   */
  public function getNbTags()
  {
    // The number of tags is not yet set
    if (is_null($this->nb_tags)
    {
      // the variable added in the SQL request will be found in the $_values of the doctrine record
      if (isset($this->_values['nb_tags']))
      {
        $this->nb_tags = $this->_values['nb_tags'];
      }
      else
      {
        /**
         * The number of tags has not been set in the SQL request
         * Doctrine will lazy load every tag and count them all.
         * This could be optimized by overwriting the createQuery method,
         * adding a left join to the tag table automatically in BlogPostTable.class.php
         * (beware, it can lead to unwanted side effects)
         */
        $this->nb_tags = $this->getTags()->count();
      }
    }

    return $this->nb_tags;
  }

Conclusion

So what have we achieved? Simple: we reduced the number of SQL requests in our admin gen from 22 to 2! One to retrieve the blog posts with the number of related tags, and the other by the Doctrine pager. Obviously, this trick isn't restricted to admin generators, so think of the many situations where you can use it!

Benjamin Grandfond

Benjamin Grandfond

Web Developer at Theodo