AttributeBehavior - DRY and powerful

This article is also available in the following languages:
By taylor.luk
Another piece taken inspiration from a somewhat similar AliasBehavior, but most of the time u to do more than aliasing. What about a simple callback mechanism to build aliases, virtual or derivative attributes the simple way, Results is pretty neat. :)

Background

There are some cases you want to modified the result returned by model find methods, We have been told that we should overwrite the afterFind() do any post-processing to support aliasing of attributes, derivative values or simply doing some filtering.

I found my afterFilter on some of my bigger application endup to be very messy and possibly break other find results if you are not careful. And sadly after you wrested to get correct behavior to alter $results array for both direct model find and associated find. All the logic stuck there and not reusable.

Here is a familiar pattern of implementing some post-processing to implement virtual attribute in Modle::afterFind correctly.

Model Class:

<?php 
class Person extends AppModel {
  function 
afterFind($results$primary) {
    
# Primary find 
    
if ($primary && $results[0][$this->alias]) {
      foreach (
$results as $i => $result) {
        
# build another property
        
$full_name "{$result[$this->alias]['first_name']} {$result[$this->alias]['last_name']}";
        
$results[$i][$this->alias]['full_name'] = $full_name;
      
        
# query is adult 
        
$results[$i][$this->alias]['is_adult'] = (int)$result[$this->alias]['age'] > 18
      
}
    }
# Associated find
    
elseif (isset($results[$this->alias])) {
        
# build another property
        
$full_name "{$result[$this->alias]['first_name']} {$result[$this->alias]['last_name']}";
        
$results[$this->alias]['full_name'] = $full_name;
      
        
# query is adult 
        
$results[$this->alias]['is_adult'] = (int)$result[$this->alias]['age'] > 18

    
}
  }
}
?>

That is some trivial logic and pretty "WET" code,

Introducing Virtual Attribute


This behavior is largely Inspirated by AliasBehavior and nifty trick Felix Geisendörfer uses static model methods for url generation.. http://debuggable.com/posts/new-router-goodies:480f4dd6-4d40-4405-908d-4cd7cbdd56cb
Features
  1. Idea for any kind of data manipulation that usually ended in Model::afterFind()
  2. There is a lot of potential application for this such as Attribute aliasing, Derivative attribute and value filtering
  3. Following the pattern you will endup with some very useful static methods can be used in controller or view and be Mr. DRY

Example

Same person model is suddenly much sexier now, now returned find result will include all virtual attribute that we desired and all virtual attribute are build from respective model methods and Best yet you can reuse them in a view or controller.

Sexy Person model

Model Class:

<?php 
class Person extends AppModel {
  
$actsAs = array('Attribute' => array('full_name''is_adult'));
 
  function 
full_name ($person) {
    
$person $person['Person'];
    
$middleInitial $person['middle_name'] ? strtoupper($person['middle_name'][0]).'.' :'';
    return 
"{$person['Person']['first_name']} {$middleInitial} {$person['Person']['last_name']}
  
}
  
  function 
is_adult($person) {
    return 
int_val($person['Person']['age']) >= 18
  
}

  function 
url($person) {
    
$slug Inflecter::slugify($this->full_name());
    return 
"/people/{$person['Person']['id'};{$slug}";
  }
}
?>

Result
  $this->Person->find('all');

Sweet, Result will automattically include our custom attributes,

Array (
 0 => Array(
    'Person' => Array(
        'id' => 1,
        'first_name' => 'Peter',
        'last_name' => 'Black'
        'middle_name' => 'Joanna'
        'age' => 21,
        'full_name' => 'Peter J. Black',
        'is_adult' => 1,
        'url' => '/people/1;peter-j-black'
    )  
 1 => Array(
  ......  
   
 )  

Here is a example how you can reuse those static methods in view, let's assume for now those additional attribute doesn't exist yet, since there are cases you may want to reuse those logic in view or controller

View Template:


...
  <ul>
  <? foreach ($people as $p) : ?>
     <li>
        <? if Person::is_adult($p) : ?>
          He seems old enough 
        <? endif ?>
      
         <?= $html->link(Person::fullname($p), Person::url($p)) ?>
      </li>
  <? endforeach ?>
  </ul>
...


More example


Here is a Article model for a blogging application, but you want to provide Aliasing, filtering or derived attribute

Model Class:

<?php 
class Article extends AppModel {
  
$actsAs = array(
    
'Attribute' => array('body''slug''url''is_commentable')
  );
 
  function 
slug($article) {
     return 
$article['Article']['permalink'];
  }

  function 
is_commentable($article) {
     return 
$article['Article']['allow_comment'] === 'yes';
  }
  function 
is_published($article) {
     return 
$article['Article']['status'] === 'published' ;
  }  
  
  function 
url($article) {
    
$article $article['Article'];
    return 
date('/Y/m/d/'strtotime($article['published_at']) . $article['permalink'];
  }
}
?>

Limitation and Work around

Currently CakePHP model doesn't propagate afterFind callback to behavior in associated model, for example: Site hasMany Article.
When u find your site, All article will be find except AttributeBehavior::afterFind won't be triggered. Here is a example work around but use with care.

Model Class:

<?php 
class Site extends AppModel {
  var 
$hasMany = array('Article');
}

//work around
class Article extends AppModel {
     ....
     function 
afterFind($results$primary false) {
          if (!
$primary) {
              return 
$this->Behaviors->Attribute->afterFind($this$resultstrue);
          }
     }
}
?>


Code


Save as app/models/behaviors/attribute.php

Model Class:

<?php 
class AttributeBehavior extends ModelBehavior {
    function 
setup(&$model$config = array()) {
        if (
is_string($config))
            
$config = array($config);

        
$this->settings[$model->alias] = $config;   
    }
    
    function 
afterFind(&$model$results = array(), $primary false) {
        
$attributes $this->settings[$model->alias];
        
        if (
$primary && isset($results[0][$model->alias])) {
            foreach(
$results as $i => $result) {
                foreach (
$attributes as $attr) {
                    if (
method_exists($model$attr) && !is_null($tmp $model->$attr($result))) {
                        
$results[$i][$model->alias][$attr] = $tmp;
                    } 
                }
            }
        } 
        elseif (isset(
$results[$model->alias])) {
            foreach (
$attributes as $attr) {
                if (
method_exists($model$attr) &&  !is_null($tmp $model->$attr($result))) {
                    
$results[$model->alias][$attr] = $tmp
                }
            }
        }
        return 
$results;
    }

?>
?>

Comments

  • Posted 05/09/11 03:40:07 AM
    I might be reading it wrong but is the second call to $model->attr($result) (approx line 21) supposed to be $model->attr($results) ?

    And btw, thanks for your work :)
  • Posted 11/25/09 02:04:54 PM
    Thanks so much for this, you just saved me from having to write some crappy code to do the same thing!

    The ability to use computed model attributes (or variables) is much needed. (Yeah, that was Google bait to match my searches) :)

    Thanks!
  • Posted 11/27/08 08:20:22 PM
    When I delete a record of a model that uses this behavior, I get notice warnings. For example, in a model Person that creates a virtual attribute called "full_name" which simply concatenates first_name and last_name, I get the following notice errors when deleting a Person record:


    Notice (8): Undefined index:  first_name [APP/models/person.php, line 27]
    Notice (8): Undefined index:  last_name [APP/models/person.php, line 27]
    • Posted 12/28/08 11:36:52 PM
      hi Steve Oliveira,

      CakePHP delete operation will first do a find('count') operation to see if there is such record.

      You can use the same work around as comment #4 i have posted.

      Merry xmas
  • Posted 11/13/08 09:14:19 PM
    @franck - That FarmerBehavior http://openpaste.org/en/3929/ you you have posted does different things.



    Okay, here is the thing, at the moment AttributeBehaivor works on result data array returned by CakePHP model operation.


    However, There are cases you create additional alias on the database query. What a coincident! this is exactly the issue i was working on yesterday.


    For example

    Model Class:

    <?php 
    $this
    ->Person->find('all', array(
        
    'fields' => "YEAR(Person.date_of_birth) as birth_year"
         
        
    // This one wouldn't work, its invalid SQL
        //'fields' => "YEAR(Person.date_of_birth) as Person.birth_year"
    ))
    ?>

    Following is what Cake Model returns..

    Result


    Array(
      0 => Array(
        'Person' => array(
           'id' => 1,
           'name' => 'Peter Jackson',
           ....
        ),
        0 => Array(
           'birth_year'
        )
      )

    See, there is no way to merge SQL alias to your Person result array and At the moment you can use a bit Set::merge() to do it manually.

    the behavior http://openpaste.org/en/3929/ you posted seem to solve exactly that.

    Let me know if such feature be added to AttributeBehavior or we should patch CakePHP model..

    @Ricardo - Thanks for your tequila shots. Going to be big night for me this weekend. Cheers man :P
  • Posted 11/13/08 01:31:46 PM
    I've faced myself this kind of problems, glad to know someone out there have the willing to delight us with this clean solution.

    Thumbs up and tequila shots for you!!
  • Posted 11/11/08 06:04:05 PM
    @frank, @dardo I am glad this work out great for you guys.


    Problem There are different kind of find operation such as find('list'), find('count') and find('neighbors') also invokes Model::afterFind() callbacks, and there are cases you only select subset of all fields.

    You may experience some unexpected result showing "undefined index" error.


    Work around You can add a if condition to ensure result data set contains dependent fields

    Model Class:

    <?php 
    class Person extends AppModel {
      function 
    is_male($data) {
         if (!isset(
    $data['Person']['gender'])
             return 
    null;
         
         return 
    $data['Person]['gender'] === 'male';
      }

    }
    ?>

    cheers :)
  • Posted 11/10/08 06:47:47 PM
    Nice, I also found this from grigri:
    http://openpaste.org/en/3929/ This is really good and extends a lot the capabilities of "dynamic fields".
    I did modify to cope with some notations etc, but the this really works.
    hth
  • Posted 11/07/08 10:59:46 AM
    Nor only it's useful but also it's a very clean approach, I'm already using it in a proyect.

    Thanks for sharing it.

    - Dardo Sordi.

Comments are closed for articles over a year old