Blog: Refactoring to Propel 1.5: From Peer classes to Query classes

The Propel Team – 03 August 2010

One of the most powerful features of Propel 1.5 lies in the generated Query classes. But to get the most out of them, developers must change their habits and learn to use these new classes instead of the Peer classes.

This tutorial shows how to refactor an existing model code to take advantage of Propel 1.5 features. The code comes from a Forum plugin for symfony, called sfSimpleForumPlugin, initially written for Propel 1.2. You can find the plugin source code in the symfony Subversion repository.

General Philosophy

Static methods are bad. They are hard to reuse, hard to test, and they cannot be chained.

On the other hand, Query methods are good. They are testable, chainable, embeddable, offer IDE completion, and they are as fast as static methods. Besides, they allow for a much more expressive syntax. Here is an example:

// Find the cheapest book by Tolsoi

// with Peer constants and static methods
$c = new Criteria();
$c->addJoin(BookPeer::AUTHOR_ID, AuthorPeer::ID);
$c->add(AuthorPeer::LAST_NAME, 'Tolstoi')
$c->addAscendingOrderByColumn(BookPeer::PRICE)
$book = BookPeer::doSelectOne($c);

// with Query classes
$book = BookQuery::create()
  ->useAuthorQuery()
    ->filterByLastName('Tolstoi')
  ->endUse()
  ->orderByPrice()
  ->findOne();

So the general guideline for converting a Propel < 1.5 application should be to avoid static methods at all costs. That means that every time you fell like writing a static method in a Peer class, you should write a non-static method in the corresponding query class instead.

Initial Static Model Code

Let’s first look at how the controllers interact with the model in the sfSimpleForumPlugin. Two controllers allow to display the list of latest posts written by a given user: one for the web page, the second for the RSS feed:

// in modules/sfSimpleForum/lib/BasesfSimpleForumActions.class.php
class BasesfSimpleForumActions extends sfActions
{
  // ...

  public function executeUserLatestTopics()
  {
    $this->topics_pager = sfSimpleForumTopicPeer::getForUserPager(
      $this->user->getId(),
      $this->getRequestParameter('page', 1),
      sfConfig::get('app_sfSimpleForumPlugin_max_per_page', 10)
    );
    // ...
  }

  public function executeUserLatestTopicsFeed()
  {
    $this->topics = sfSimpleForumTopicPeer::getForUser(
      $this->user->getId(),
      sfConfig::get('app_sfSimpleForumPlugin_feed_max', 10)
    );
    // ...
  }
}

In this example, two Peer methods are used: sfSimpleForumTopicPeer::getForUserPager(), and sfSimpleForumTopicPeer::getForUser(). Let’s check the source code of these methods:

// in lib/model/plugin/PluginsfSimpleForumTopicPeer.php
class PluginsfSimpleForumTopicPeer extends BasesfSimpleForumTopicPeer
{
  // ...

  public static function getForUserPager($user_id, $page = 1, $max_per_page = 10)
  {
    $c = self::getForUserCriteria($user_id);
    $pager = new sfPropelPager('sfSimpleForumTopic', $max_per_page);
    $pager->setPage($page);
    $pager->setCriteria($c);
    $pager->setPeerMethod('doSelectJoinAll');
    $pager->init();

    return $pager;
  }

  public static function getForUser($user_id, $max = 10)
  {
    $c = self::getForUserCriteria($user_id);
    $c->setLimit($max);

    return self::doSelectJoinAll($c);
  }

  protected static function getForUserCriteria($user_id)
  {
    $c = new Criteria();
    $c->add(self::USER_ID, $user_id);
    $c->addDescendingOrderByColumn(self::UPDATED_AT);

    return $c;
  } 

}

In order to avoid repetition of code, the piece of logic that restricts the query to a single user was refactored into a getForUserCriteria() method. Also, both getForUserPager() and getForUser() eventually use sfSimpleForumTopicPeer::doSelectJoinAll() to hydrate topics together with forums.

Remove Termination Methods

It appears that getForUserPager() and getForUser(), apart from reusing the common method getForUserCriteria(), only terminate the query with no special added value. They can be seen as termination methods in the Propel Query terminology, since they don’t return a Criteria object.

But ModelCriteria already offers most of the termination methods that you need (find(), count(), paginate(), etc.). So the right thing to do here is to keep only the code that adds logic to your model (the getForUserCriteria() method). This code is easy to move to a Propel Query class. And since a Propel Query is a Criteria, no need to create one in the method - just use $this instead.

// in lib/model/plugin/PluginsfSimpleForumTopicQuery.php
class PluginsfSimpleForumTopicQuery extends BasesfSimpleForumTopicQuery
{
  // ...

  public function getForUserCriteria($user_id)
  {
    $this->add(self::USER_ID, $user_id);
    $this->addDescendingOrderByColumn(self::UPDATED_AT);

    return $this;
  } 
}

The termination can be left to the controllers, which must be refactored a little:

// in modules/sfSimpleForum/lib/BasesfSimpleForumActions.class.php
class BasesfSimpleForumActions extends sfActions
{
  // ...

  public function executeUserLatestTopics()
  {
    $this->topics_pager = sfSimpleForumTopicQuery::create()
      ->getForUserCriteria($this->user->getId())
      ->paginate($this->getRequestParameter('page', 1), sfConfig::get('app_sfSimpleForumPlugin_max_per_page', 10)
      );
    // ...
  }

  public function executeUserLatestTopicsFeed()
  {
    $this->topics = sfSimpleForumTopicQuery::create()
      ->getForUserCriteria($this->user->getId())
      ->limit(sfConfig::get('app_sfSimpleForumPlugin_feed_max', 10))
      ->find();
    // ...
  }
}

It’s a good guideline to let the controllers do the termination themselves, and keep in the model classes only filter methods, which return the current query object. It will make your model code much more reusable.

There is one thing missing from this refactoring: the joined hydration that used to be offered by doSelectJoinAll(). Let’s add it back to the model code using the new syntax offered by Propel Queries - the joinWith() method:

// in lib/model/plugin/PluginsfSimpleForumTopicQuery.php
class PluginsfSimpleForumTopicQuery extends BasesfSimpleForumTopicQuery
{
  // ...

  public function getForUserCriteria($user_id)
  {
    $this->add(self::USER_ID, $user_id);
    $this->addDescendingOrderByColumn(self::UPDATED_AT);
    $this->joinWith('sfSimpleForumForum');

    return $this;
  } 
}

Use Generated Filter Methods

From a Propel Query point of view, the getForUserCriteria() method filters and orders the query. The Propel Query API has a faster way of doing so, using the generated filter methods:

// in lib/model/plugin/PluginsfSimpleForumTopicQuery.php
class PluginsfSimpleForumTopicQuery extends BasesfSimpleForumTopicQuery
{
  // ...

  public function getForUserCriteria($user_id)
  {
    return $this
      ->filterByUserId($user_id)
      ->orderByUpdatedAt('desc')
      ->joinWith('sfSimpleForumForum');
  } 
}

filterByUserId() replaces the call to Criteria::add(), and orderByUpdatedAt() replaces the longish addDescendingOrderByColumn(). All the Propel Query methods that are not termination methods return the current Query object, so the fluid interface was used to avoid the repetition of $this on each line.

Use Objects Whenever Possible

The controller calls the filterByUserId() method, but it has access to the whole User object. Why not keep objects for this filter? The generated Query class offer an object filter for each foreign key, including a more convenient filterByUser($user) method:

// in modules/sfSimpleForum/lib/BasesfSimpleForumActions.class.php
class BasesfSimpleForumActions extends sfActions
{
  // ...

  public function executeUserLatestTopics()
  {
    $this->topics_pager = sfSimpleForumTopicQuery::create()
      ->getForUserCriteria($this->user)
      ->paginate($this->getRequestParameter('page', 1), sfConfig::get('app_sfSimpleForumPlugin_max_per_page', 10)
      );
    // ...
  }

  public function executeUserLatestTopicsFeed()
  {
    $this->topics = sfSimpleForumTopicQuery::create()
      ->getForUserCriteria($this->user)
      ->limit(sfConfig::get('app_sfSimpleForumPlugin_feed_max', 10))
      ->find();
    // ...
  }
}

The model code should be modified accordingly:

<?php
// in lib/model/plugin/PluginsfSimpleForumTopicQuery.php
class PluginsfSimpleForumTopicQuery extends BasesfSimpleForumTopicQuery
{
  // ...

  public function getForUserCriteria($user)
  {
    return $this
      ->filterByUser($user)
      ->orderByUpdatedAt('desc')
      ->joinWith('sfSimpleForumForum');
  } 
}

Keep objects as long as you can in your model queries - the code will be clearer, and you will be able to achieve more elaborate queries. Propel encourages the use of objects over columns and foreign keys.

The new API has already allowed to dramatically reduce the Model and the Controller code, but it’s only the beginning.

Use Meaningful Names

The middle piece of the method, which orders results by update date, could be refactored to be more expressive. Actually, tt returns the latest updated books first, so let’s write it this way:

// in lib/model/plugin/PluginsfSimpleForumTopicQuery.php
class PluginsfSimpleForumTopicQuery extends BasesfSimpleForumTopicQuery
{
  // ...

  public function getForUserCriteria($user)
  {
    return $this
      ->filterByUser($user)
      ->lastUpdatedFirst()
      ->joinWith('sfSimpleForumForum');
  }

  public function lastUpdatedFirst()
  {
    return $this->orderByUpdatedAt('desc');
  } 
}

This new method can then be reused in other queries easily.

Now it’s time to wonder about the main method name. getForUserCriteria() was good for a Peer static method, but now that it’s in a query class, it should be named differently. Something that like latestForUser() should fit:

// in lib/model/plugin/PluginsfSimpleForumTopicQuery.php
class PluginsfSimpleForumTopicQuery extends BasesfSimpleForumTopicQuery
{
  // ...

  public function latestForUser($user)
  {
    return $this
      ->filterByUser($user)
      ->lastUpdatedFirst()
      ->joinWith('sfSimpleForumForum');
  }

  public function lastUpdatedFirst()
  {
    return $this->orderByUpdatedAt('desc');
  } 
}

Now the model code is expressive and reusable, and the controller code is very simple and readable:

// in modules/sfSimpleForum/lib/BasesfSimpleForumActions.class.php
class BasesfSimpleForumActions extends sfActions
{
  // ...

  public function executeUserLatestTopics()
  {
    $this->topics_pager = sfSimpleForumTopicQuery::create()
      ->latestForUser($this->user)
      ->paginate($this->getRequestParameter('page', 1), sfConfig::get('app_sfSimpleForumPlugin_max_per_page', 10)
      );
    // ...
  }

  public function executeUserLatestTopicsFeed()
  {
    $this->topics = sfSimpleForumTopicQuery::create()
      ->latestForUser($this->user)
      ->limit(sfConfig::get('app_sfSimpleForumPlugin_feed_max', 10))
      ->find();
    // ...
  }
}

You should keep the amount of model code inside controller to a minimum. A good rule of thumb is to allow one creation method (create()), one termination method (paginate(), find()), and one logic method (like latestForUser()).

Remove Things That Propel Can Do On Its Own

The model code of the sfSimpleForum plugin contains several more examples of getXXXCriteria() methods in Peer classes that can benefit from a similar refactoring. It also contains a lot of custom code that can easily be replaced by native Propel Query features. For instance:

// in lib/model/plugin/PluginsfSimpleForumForumPeer.php
class PluginsfSimpleForumForumPeer extends BasesfSimpleForumForumPeer
{
  public static function retrieveByStrippedName($stripped_name)
  {
    $c = new Criteria();
    $c->add(self::STRIPPED_NAME, $stripped_name);

    return self::doSelectOne($c);
  }

  public static function getAllAsArray()
  {
    $forums = self::doSelect(new Criteria());
    $res = array();

    foreach ($forums as $forum)
    {
      $res[$forum->getStrippedName()] = $forum->getName();
    }

    return $res;
  }
}

The first method, retrieveByStrippedName() has a Model Query counterpart, out of the box:

// retrieve one forum by stripped name
$forum = sfSimpleForumForumQuery::create()
  ->findOneByStrippedName($stripped_name);

The second method, getAllAsArray(), is of no use since Propel naturally returns collections, which are one line away from arrays:

// get all forum names as an array indexed by stripped name
$forums = sfSimpleForumForumQuery::create()
  ->find()
  ->toKeyValue('StrippedName', 'Name');

Additionally, all the methods implementing a custom join hydration (like PluginsfSimpleForumForumPeer ::doSelectJoinCategoryLeftJoinPost(), or PluginsfSimpleForumPostPeer::doSelectJoinTopicAndForum()) become useless since you can choose the joined objects directly in the query using joinWith().

All in all, more than 75% of the Peer code of the current sfSimpleForum plugin doesn’t need to be ported to a Query object - new Propel 1.5 features do the job out of the box.

Conclusion

Moving existing model code written for Propel 1.4 to Propel 1.5 Query classes is fast, easy, and it will make your application better. Reusability comes by moving code from the controller to the model. Expressivity comes by using objects as arguments, and meaningful method names. And ease of maintenance comes by keeping the number of Model methods low.

Applications written for Propel 1.3 or 1.4 work out of the box with Propel 1.5, which is a backwards compatible release. Since it’s so easy to replace old code by new code optimized for Propel 1.5, don’t wait, and upgrade now.