Wednesday, July 2, 2008

Taming Containables in CakePHP 1.2

Update: Revised code snippets that address the concerns outlined at the end of this article are now available at Merging ContainableBehavior and PaginatorHelper.


CakePHP 1.2 introduced a handy tool, the ContainableBehavior.
Before it was introduced, we had to use $this->Modelname->unbindModel('AssociatedModel') for each extraneous associated model for any given search.
ContainableBehavior automated this for us, so that every model except the ones specified are automatically unbound on a per-operation basis.
The manual explains simple tasks, like limiting searches to associated models and fields:
$this->Post->contain('Tag');
$this->Post->contain('Tag.author');
It also explains how you can filter with:
$this->Post->find('all', array(
    'contain' => array(
        'Comment' => array(
            'conditions' => array('Comment.author =' => "Daniel"),
            'order' => 'Comment.created DESC'
        )
    )
));
Unfortunately, it doesn't explain how to nest these functions, in effect giving us variably deep arrays of data.
For example, I was developing an application with Control hasOne Unit, Segment hasMany Unit, Unit hasMany Rental and Customer hasMany Rental. I needed a report that showed properties from all four models, but the size of the recursive data set was quickly exceeding the 128MB memory space allotted to PHP. To make matters worse, I needed to filter both Unit and Rental fields.
This function:
$this->Unit->find('all', array(
    'conditions' => array( Unit.segment_id=> $value),
    'recursive'  => 2
    )
);
was returning this data (I’ve removed the additional properties contained within each of these array elements to make this clearer):
[0]
    [Segment]
        [0]
            [Unit]
        [1]
            [Unit]
    [Unit]
    [Rental]
        [0]
            [Customer]
            [Unit]
            [Segment]
        [1]
            [Customer]
            [Unit]
            [Segment]
    [Control]
    [Segment]
[1]
    [Segment]
        [0]
            [Unit]
        [1]
            [Unit]
    [Unit]
    [Rental]
        [0]
            [Customer]
            [Unit]
            [Segment]
        [1]
            [Customer]
            [Unit]
            [Segment]
    [Control]
    [Segment]
When I attempted ‘contain’ => array('Control', 'Rental'), I couldn’t access Customer. When I tried to include Customer in the list, CakePHP threw an error, as it was unable to find a direct association between Unit and Customer. To remove the (known) Segment element from all levels and return only the relevant property in Rental, but still include the Customer information, I ended up using:
$this->Unit->find('all', array(
    'conditions' => array( Unit.segment_id' => $value),
    'recursive'  => 2,
    'contain'    => array(          'Control',
        'Rental' => array('Customer'),
        'Rental.expires' => array('conditions'=>array('Rental.active'=>1))
        )
    )
);
Most of this is pretty straightforward.
We’re returning all records (level 1) that match our condition set and we’re pulling associated records (level 2) and the records associated with those records (level 3). Since the Segment is known, we’ll unbind it completely. Setting Rental to array('Customer') forces Containable to return only the Customer sub-record for Rental.
The last line is magic though. It reduces the elements of the Rental to control_id, unit_id, expires and the Contact array. It also filters out inactive rentals, apparently without overwriting the preceding instruction.
The resulting array is a much more manageable:
[0]
    [Unit]
    [Rental]
        [0]
            [Customer]
        [1]
            [Customer]
    [Control]
[1]
    [Unit]
    [Rental]
        [0]
            [Customer]
        [1]
            [Customer]
    [Control]
Of course, there are a couple gotchas with this technique:
  1. I haven’t found a way to merge the two Rental lines, which would allow me to filter Rental AND return Customer AND return all Rental properties without all of the extraneous records.
  2. CakePHP 1.2.7692 (RC2) thinks that 'conditions' is a reference to a model and returns the error, "Warning (512): Model "Rental" is not associated with model "conditions" [CORE\cake\libs\model\behaviors\containable.php, line 317]." Fortunately, it's just a warning and won’t disrupt execution or even be displayed in a well configured production environment.
  3. While I haven't tested it, the source code seems to indicate that this won't mesh well with the PaginatorHelper.

2 comments:

Unknown said...

Few answers:

1) use DISTINCT in your field list.
$this->Unit->find('all', array('fields' => 'DISTINCT id') ...

You migh have also to overdide the paginateCount method in the model as follow :

function paginateCount($conditions, $recursive)
{
$z = $this->find('count', array('fields' => array('COUNT(DISTINCT Unit.id) AS count'), 'conditions' => $conditions, 'recursive' => $recursive));
return $z;
}

2) Unbind the hasMany relation between Unit and Rental, and bind it as hasOne (do not forget the reset = false) ..

3) .. ??

hth

Dave Mahon said...

This seems to have fixed points 1 and 2:
$this->Unit->find('all', array(
  'conditions' => array(
    'Unit.segment_id' => $segment
   ),
  'recursive' => 2,
  'contain' => array(
    'Rental' => array(
      'conditions' => array('Rental.active' => 1),
      'fields' => array('Rental.active', 'Rental.expires'),
      'Customer' => array (
        'fields' => array('Customer.id', 'Customer.name')
      )
    ),
    'Control'
  )
));