Saturday, July 12, 2008

Merging ContainableBehavior and PaginatorHelper

This is an update to another article, Taming Containables in CakePHP 1.2. You might want to read it first.


Paginator and Containable are both great, but the documentation is wholly inadequate in discussing how to combine their functionality. After scouring the Cake libraries and existing documentation, however, I have found The Way:

var $paginate = array(
    'limit' => 10,
    'order' => array(
      'Segment.name' => 'asc',
      'Unit.label' => 'asc'
    ),
    'recursive' => 2,
    'conditions' => null,
    'contain' => array(
          'Rental' => array(
            'conditions' => array('Rental.active' => 1),
            'fields' => array('Rental.active', 'Rental.expires'),
            'Contact' => array (
              'fields' => array('Contact.id', 'Contact.forename', 'Contact.surname')
            )
          ),
          'Control',
          'Segment'
        )
  );


This code now appears in my UnitsController. You'll note a few differences from the previous version, however.

I have consolidated Rental using standard Model::find('all') syntax and I can now select my fields and conditions without worrying about order precedence.

More interesting still, ContainableBehavior seems to implicitly extend to all sub-arrays, allowing me to provide still more conditions and fields to those aspects.

The inherent possibilities for creating complex multi-model AJAX filters are very promising.

You may also be wondering why I set $paginate['conditions'] to null. I want to be able to programmatically add dynamic filters to the Units model, so this provides an easy way in. It also seems to make PHP happier at runtime if I don't attempt to add that array element. Since the conditions aren't applied until the Model::paginate function is actually called, there is no significant performance or capability hit.

Hint: This also means that we can very heavily alter our paginate variable within functions, for those not uncommon cases where you'll be paginating very different lists within the same controller.

Wednesday, July 9, 2008

Blueprint: Professional Page Layout Made Easy

Nothing beats having a professional designer and Photoshop/Illustrator wizard on your development team, but that is a luxury that is disturbingly rare. For some reason, there just aren’t many people with a deep and broad understanding of HTML, XHTML, CSS, Javascript and server side coding.

Even so, that’s no excuse for a cluttered, hard to follow page layout when there are layout automation tools like Blueprint. While there are many such tools today, I like Blueprint for its compactness, flexibility and its reliability across browsers and platforms. To sweeten the deal, it’s also stupidly simple to implement in CakePHP.

Even if you end up not using it in the final release, it makes the development cycle much easier, simply because it aligns everything along highly customizable grids.

The first step to implementing Blueprint is importing the files. Download the Blueprint Zip file and copy the blueprint folder to APP/webroot/css. There are also useful plugins for buttons, icons and typography. If you want them, copy their respective folders to APP/webroot/css/blueprint/plugins. Each of these folders will contain, at minimum, a readme file and a CSS file, although some may contain additional image files.

Now we’ll need to load the CSS files by editing APP/view/layouts/default.ctp (or default.thtml if you’re using CakePHP 1.1). I use the code:

$screen_css = array(
    'blueprint/screen',
    'blueprint/plugins/fancy-type/screen',
    'blueprint/plugins/buttons/screen',
    'site'
);
echo $html->css($screen_css, null, array('media'=>'screen,projection'));

You can now embed Blueprint layout markup anywhere. For example, you could add “span-22 push-1 last” to the class attribute of your container element. This would give the element a left margin equal to 1/24 of the page width, a width equal to 22/24 of the page width, and leave you another 1/24 of the page width in which to place any other elements of interest.

This still doesn’t help with forms though. You could manually add the markup to your views to divide lines, but if you’re discerning, you want all of your fields and labels neatly aligned and it’s unclear how to accomplish that without mucking with the internal Cake FormHelper.

Obviously, that’s undesirable, so I implemented BlueprintHelper:

<?php
App::import('Helper', 'Form');
App::import('Helper', 'Html');
/**
* Modifies the CakePHP FormHelper to support blueprintCSS
*
* @package dadem
*/
class BlueprintHelper extends FormHelper {
    /**
  * The value of the class attribute assigned to the wrapper div for every label
  * in the format elementType => class 
  *
  * @var array
  */
    var $_labelClass = array();
    /**
  * The value of the class attribute assigned to the wrapper div for most elements
  *
  * @var string
  */
    var $_inputClass = array();
    /**
    * Caches the blueprintCSS markup needed for labels and form elements
    *
    * @param string $label The value assigned to the class attribute of the div wrapper
    * @param string $input
    */                   
    function configure($element, $label = '', $input = '') {
        $this->_labelClass[$element] = $label;
        $this->_inputClass[$element] = $input;
    }
    /**
* Wrapper for FormHelper->input that adds blueprintCSS markup
*
* @param string $fieldName This should be "Modelname.fieldname", "Modelname/fieldname" is deprecated
* @param array $options
* @return string
*/
    function input($fieldName, $options = array()) {

        if (!isset($options['type'])) $options['type'] = 'text';
        if (isset($options['div'])) {
            $options['div'] = $this->addClass($options['div'], 'clear');
            $options['div'] = $this->addClass($options['div'], 'newline');
        }
        else {
            $options['div'] = 'clear newline';
        }
        $options = array_merge(array(
            'before' => '<div class="' . $this->_labelClass[$options['type']] . '">',
            'between' => '</div><div class="' . $this->_inputClass[$options['type']] . '">',
            'after' => '</div>'
        ), $options);
        return parent::input($fieldName, $options);
    }
    /**
* Wrapper for FormHelper->end that adds blueprintCSS markup
  *
* If $options is set a form submit button will be created.
*
* @param mixed $options as a string will use $options as the value of button,
*     array usage:
*         array('label' => 'save'); value="save"
*         array('label' => 'save', 'name' => 'Whatever'); value="save" name="Whatever"
*         array('name' => 'Whatever'); value="Submit" name="Whatever"
*         array('label' => 'save', 'name' => 'Whatever', 'div' => 'good') <div class="good"> value="save" name="Whatever"
*         array('label' => 'save', 'name' => 'Whatever', 'div' => array('class' => 'good')); <div class="good"> value="save" name="Whatever"
*
* @return string a closing FORM tag optional submit button.
* @access public
*/
    function end($options = null) {
        if ($options !== null) {
            if (is_string($options)) $options = array('label'=>$options);
            if (isset($options['div'])) {
                $options['div'] = $this->addClass($options['div'], 'clear');
            }
            else {
                $options['div'] = array('class'=>'clear');
            }
        }
        return parent::end($options);
    }
    /**
* Wrapper for HtmlHelper->div that adds blueprintCSS markup for class clear
*
* @param string $fieldName This should be "Modelname.fieldname", "Modelname/fieldname" is deprecated
* @param string $class CSS class name of the div element.
* @param string $text String content that will appear inside the div element.
*            If null, only a start tag will be printed
* @param array $attributes Additional HTML attributes of the DIV tag
* @param boolean $escape If true, $text will be HTML-escaped
* @return string The formatted DIV element
*/
    function clear($class = null, $text = null, $attributes = array(), $escape = false) {
        if (strlen($class) > 0) {
            $class .= ' clear';
        }
        else {
            $class = 'clear';
        }
        return $this->Html->div($class, $text, $attributes, $escape);
    }
}
?>

The code itself is comment according to CakePHP conventions, so it should be fairly self-explanatory. The only new function is configure().

I use it at the start of my view:

$blueprint->configure('text', 'span-4', 'span-8 last');

The first parameter specifies the type attribute of the input element (you can also use ‘select’ for select elements. To see the full list of permitted values, read the code for the input function in CAKE\libs\view\helpers\form.php

The second function defines the classes assigned to the label and the third defines the classes assigned to the form element proper.

I use one such command for each possible element. I generally use it in my view file, but you could implement it in your layout file without harm.

Now, when I want to apply Blueprint formatting selectively to elements, I can implement a view like so:

<div class="locks form span-12">
    <?php
        echo $blueprint->create('Lock');
        echo $blueprint->input('serial');
    ?>
    <div class="clear newline">
      <div class="span-4">
            <label for="LockC1">Combination</label>
        </div>
      <div class="span-8 last">
            <?php
              echo $form->input('c1', array('label'=>'', 'size'=>'2', 'div'=>'')) . ' - ';
              echo $form->input('c2', array('label'=>'', 'size'=>'2', 'div'=>'')) . ' - ';
              echo $form->input('c3', array('label'=>'', 'size'=>'2', 'div'=>''));
            ?>
        </div>
    </div>
    <div class="clear">
        <?php
            echo $form->input('damaged', array('type' => 'checkbox'));
        ?>
    </div>
    <div class="clear">
        <?php
            echo $form->input('id', array('type'=>'hidden'));
        ?>
    </div>
    <?php
        echo $blueprint->end('Save Record');
    ?>
</div>

I won’t go into detail, because Blueprint provides a good assortment of resources. Pictures speak louder than words, anyway:

mixed-blueprint-form

Passing Named Variables To PaginatorHelper (aka Filtering Paged Data)

It came to pass that an application had a table with lots of similar results. For me, it was a list of serials on combination locks. Several thousand of them, in fact. Since the users will typically want to look for a lock with a specific serial, sorting was woefully inadequate. I needed to filter.

But how?

The CakePHP 1.2 manual indicates that the Controller->paginator variable will accept the options from the now deprecated function Model->FindAll. However, by default, it doesn’t seem to retain this information across page loads.

In other words, the moment I click on one of the PaginatorHelper auto-generated links, my filter goes away. This means that we need to find a way to pass a custom defined variable in the URL, which I did in my Locks controller, like so:

function index() {
    if (isset($this->data['Lock']['serial']))
        $this->params['named']['filter'] = $this->data['Lock']['serial'];
    if (isset($this->params['named']['filter']) ) {
        $this->paginate['conditions'] = array(
            'Lock.serial LIKE ' => '%' . $this->params['named']['filter'] . '%'
        );
    }

    $locks = $this->paginate('Lock');
    $this->set(compact('locks'));
    if (isset($this->params['named']['filter']) )
        $this->params['paging']['Lock']['options']['filter'] = $this->params['named']['filter'];
}

The first two lines of the function simply check for a search parameter specified from a form in the index view defined by the lines:

$form->create('Lock', array('action'=>'index'));
echo $form->input('serial', array('label'=>'Serial Contains: '));
echo $form->end('Apply Filter');

The next two lines apply the filter, regardless of whether it was passed by the form or the URL.

The two lines after that generate the paginated results, but nothing we have done so far will preserve the filter across page loads.

That’s where my final if statement comes into play, which assigns our designated filter (sans my additional SQL markup, to keep the user interface clean) to a variable that is preserved. Of course, $this->params['paging']['Lock']['options']['filter'] is a bit of a mouthful, so I’ll break that down.

From generous use of the debug() function, it appears that Cake stores the information needed for the PaginatorHelper to generate its help in the view in Controller->params['paging']. Each relevant model is stored as a sub-array (indicating that we can filter on multiple models if necessary). Each model contains several elements: page, current, count, prevPage, nextPage, pageCount, defaults and options.

Array
(
  [Lock] => Array
    (
      [page] => 2
      [current] => 3
      [count] => 13
      [prevPage] => 1
      [nextPage] =>
      [pageCount] => 2
      [defaults] => Array
        (
          [limit] => 10
          [step] => 1
          [order] => Array
            (
              [Lock.serial] => asc
            )
          [conditions] => Array
            (
              [Lock.serial LIKE ] => %100%
            )
        )
      [options] => Array
        (
          [page] => 2
          [limit] => 10
          [order] => Array
            (
              [Lock.serial] => asc
            )
          [conditions] => Array
            (
              [Lock.serial LIKE ] => %100%
            )
          [filter] => 100
        )
    )
)

It’s intuitive how PaginatorHelper uses most of these values, although I haven’t seen (or looked for) a clear raison d’etre for [defaults]. The elements of options, save for [conditions], are where CakePHP stores the values that get defined in the links. Hence, with this code, my Cake-generated links now look like:

appserver/app/locks/index/page:1/filter:100

Had I added the variable bob and assigned it the value 'good', the options array would look (internally) like:

[options] => Array
(
  [page] => 2
  [limit] => 10
  [order] => Array
    (
      [Lock.serial] => asc
    )
  [conditions] => Array
    (
       [Lock.serial LIKE ] => %100%
    )
  [filter] => 100
  [bob] => good
)

and the link like:

appserver/app/locks/index/page:1/filter:100/bob:good

Even better, since this entire array is passed to the view, we can look for these custom array elements in the view to indicate whether or not a filter has been applied and if so, how.

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.