Quantcast
Viewing latest article 2
Browse Latest Browse All 12

Using a custom collection class with Zend_Paginator

Image may be NSFW.
Clik here to view.
Zend_Paginator is a Zend Framework component for paginating collections of data–for example, breaking a list of blog posts or search results into multiple pages. The easiest way to use Zend_Paginator is by passing it a Zend_Db_Select object, which lets it automagically modify the select query to fetch only the results for the desired page.

But designing your models to work with Zend_Paginator in this way can be messy and inelegant.  It often involves either creating a Zend_Paginator instance inside your model or data mapper, or else passing a select object back out of your data mapper. Either approach seems clunky, and violates the encapsulation of your models.

A cleaner approach is to fetch a collection of your models in a custom collection class, and then pass that collection directly to Zend_Paginator. You can write your collection class in such a way that it only fetches data as needed, handling large result sets just as efficiently as passing a select object directly to Zend_Paginator.

Read on to see how.

Say you have an action that shows a paged list of users. The goal is to be able to do something like this in your action controller:

$user_mapper = new Model_User_UserMapper();
$users = $user_mapper->findAll();

$paginator = new Zend_Paginator($users);
$paginator->setItemCountPerPage(20);
$paginator->setCurrentPageNumber($this->_getParam('page'));

$this->view->users = $paginator;

…and this in your view script:

<?php
if (count($this->users) > 0) {

  foreach ($this->users as $user) {
    echo $user->name . '<br>';
  }

  echo $this->paginationControl($this->users, 'Sliding',
    'my_pagination_control.phtml');
}

For this to work, $users needs to be an instance of a collection class that implements the following interfaces: Zend_Paginator_Adapter_Interface (so it can be passed to Zend_Paginator), Iterator (for foreach()), and Countable (for count()).

See below for an example of such a collection class (or fork it at Github).

<?php
/**
 * A simple collection class that also works with Zend_Paginator.
 *
 * @author     Jason Grimes <jason@grimesit.com>
 * @link       https://github.com/jasongrimes/zf-simple-datamapper
 * @category   Jg
 * @package    Mapper
 * @copyright  Copyright (c) 2011 Jason Grimes <jason@grimesit.com>
 */

class Jg_Mapper_Collection implements Iterator, Countable, Zend_Paginator_Adapter_Interface {
  protected $_mapper;
  protected $_total;
  protected $_raw = array();

  protected $_result;
  protected $_pointer = 0;
  protected $_objects = array();

  public function __construct(array $raw, Jg_Mapper $mapper) {
    $this->_raw = $raw;
    $this->_total = count($raw);
    $this->_mapper = $mapper;
  }

  public function add($object) {
    $class = $this->_mapper->getDomainObjectClass();
    if (!($object instanceof $class)) {
      throw new Jg_Mapper_Exception('This is a "' . $class . '" collection');
    }
    $this->_objects[$this->count()] = $object;
    $this->_total++;
  }

  protected function _getRow($num) {
    $this->_notifyAccess($num);

    if ($num >= $this->_total || $num < 0) {
      return null;
    }
    if (!isset($this->_objects[$num]) && isset($this->_raw[$num])) {
      $this->_objects[$num] = $this->_mapper->createObject($this->_raw[$num]);
    }
    return $this->_objects[$num];
  }

  /**
	 * Rewind the Iterator to the first element. Required by Iterator interface.
   * 
	 * @link http://php.net/manual/en/iterator.rewind.php
	 * @return void Any returned value is ignored.
	 */
  public function rewind() {
    $this->_pointer = 0;
  }

  /**
	 * Return the current element. Required by Iterator interface.
   * 
	 * @link http://php.net/manual/en/iterator.current.php
	 * @return mixed Can return any type.
	 */
  public function current() {
    return $this->_getRow($this->_pointer);
  }

  /**
	 * Return the key of the current element. Required by Iterator interface.
   *
	 * @link http://php.net/manual/en/iterator.key.php
	 * @return scalar scalar on success, integer 0 on failure.
	 */
  public function key() {
    return $this->_pointer;
  }

  /**
	 * Move forward to next element. Required by Iterator interface.
   * 
	 * @link http://php.net/manual/en/iterator.next.php
	 * @return void Any returned value is ignored.
	 */
  public function next() {
    $row = $this->_getRow($this->_pointer);
    if ($row) {
      $this->_pointer++;
    }
    return $row;
  }

  /**
	 * Checks if current position is valid. Required by Iterator interface.
   * 
	 * @link http://php.net/manual/en/iterator.valid.php
	 * @return boolean The return value will be casted to boolean and then evaluated.
	 * Returns true on success or false on failure.
	 */
  public function valid() {
    return !is_null($this->current());
  }

  /**
	 * Count elements of an object. Required by Countable interface.
   *
	 * @link http://php.net/manual/en/countable.count.php
	 * @return int The custom count as an integer.
	 */
  public function count() {
    return $this->_total;
  }

  /**
   * Returns an array of items for a page. Required by Zend_Paginator_Adapter_Interface.
   *
   * @param  integer $offset Page offset
   * @param  integer $itemCountPerPage Number of items per page
   * @return array
   */
  public function getItems($offset, $itemCountPerPage) {
    $this->_notifyAccess($offset, $itemCountPerPage);

    $items = array();
    for ($i = $offset; $i < ($offset + $itemCountPerPage); $i++) {
      $items[] = $this->_getRow($i);
    }
    return $items;
  }

  protected function _notifyAccess($offset, $length = 1) {
    // Empty on purpose. Child classes can extend to support lazy loading
  }
}

Jg_Mapper_Collection stores an array of “raw” user data, as retrieved from the database. When a row is accessed (ex. by iterating over the collection with foreach()), _getRow() asks the mapper to convert the raw data for that row into a user object, which it returns.

The user mapper is used to fetch the user objects to and from the database. In this example, the Model_User_UserMapper class needs to have at least the following methods:

createObject() takes an array of data and returns a user object populated with that data:

public function createObject(array $data) {
  $class = $this->getDomainObjectClass();
  return new $class($data);
}

getDomainObjectClass() returns the name of the user class. It’s used by the collection class for type checking.

public function getDomainObjectClass() {
  return 'Model_User';
}

findAll() fetches all the users from the database, and returns them in a collection.

public function findAll() {
  $select = $this->_getDbAdapter()->select()->from('mydb.users');
  $data = $select->query()->fetchAll();
  return new Jg_Mapper_Collection($data, $this);
}

More detailed sample code is available in my zf-simple-datamapper project at Github.

This works fine for small collections, but what if you have a million users? It’s not efficient to fetch a huge result set and store it all in the collection object when you may end up needing only a few pages of it.

To handle this properly, let’s extend the collection class to support the lazy load pattern, so it only fetches the raw data for the necessary pages.

<?php
/**
 * A "lazy" collection, which queries for row data as it is needed.
 *
 * @author     Jason Grimes <jason@grimesit.com>
 * @link       https://github.com/jasongrimes/zf-simple-datamapper
 * @category   Jg
 * @package    Mapper
 * @copyright  Copyright (c) 2011 Jason Grimes <jason@grimesit.com>
 */

class Jg_Mapper_LazyCollection extends Jg_Mapper_Collection {

  /**
   * @var Zend_Db_Select
   */
  protected $_select;

  /**
   * A Zend_Paginator_Adapter_DbSelect instance is used to automagically convert
   * the select object into a count query. @see count()
   *
   * @var Zend_Paginator_Adapter_DbSelect
   */
  protected $_paginator_helper;

  public function __construct(Zend_Db_Select $select, Jg_Mapper $mapper) {
    $this->_select = $select;
    $this->_mapper = $mapper;
    $this->_paginator_helper = new Zend_Paginator_Adapter_DbSelect($select);
  }

  public function count() {
    if (is_null($this->_total)) {
      $this->_total = $this->_paginator_helper->count();
    }
    return $this->_total;
  }

  public function getSelect() {
    return $this->_select;
  }

  protected function _notifyAccess($offset, $length = 1) {
    if (!$this->_issetRawSlice($offset, $length)) {
      $this->_loadRawSlice($offset, $length);
    }
  }

  protected function _issetRawSlice($offset, $length = 1) {
    $last_offset = $offset + $length - 1;
    if ($last_offset > $this->count()) {
      $last_offset = $this->count() - 1;
    }

    for ($i = $offset; $i <= $last_offset; $i++) {
      if (!isset($this->_raw[$i])) {
        return false;
      }
    }
    return true;
  }

  protected function _loadRawSlice($offset, $length = 1) {
    $select = $this->getSelect();
    $select->limit($length, $offset);
    $data = $select->query()->fetchAll();

    $i = $offset;
    foreach ($data as $row_data) {
      $this->_raw[$i] = $row_data;
      $i++;
    }
  }
}

When an instance of Jg_Mapper_LazyCollection is created, instead of passing it an array of “raw” data from the database, the mapper passes it a Zend_Db_Select object instead. This select object is used to retrieve data as needed, modified by an appropriate LIMIT clause. The select object is also modified to automatically generate a count of the total number of results. This logic can be complicated, so we use the count() method of Zend_Paginator_Adapter_DbSelect as a helper to handle it.

In order to support lazy collections, the finder method in the mapper needs to be updated. Here’s the new findAll() method:

public function findAll($options = array()) {
  $select = $this->_getDbAdapter()->select()->from('mydb.users');

  if ($options['get_lazy_collection']) {
    return new Jg_Mapper_LazyCollection($select, $this);
  } else {
    $data = $select->query()->fetchAll();
    return new Jg_Mapper_Collection($data, $this);
  }
}

When fetching a result set that you intend to pass to Zend_Paginator, tell the finder method to return a lazy collection, like this:

$users = $user_mapper->findAll(array('get_lazy_collection' => true));

For more detailed sample code, see this zf-simple-datamapper project at Github.


Viewing latest article 2
Browse Latest Browse All 12

Trending Articles