Wednesday, February 13, 2013

How to use Behaviors in Yii

Recently someone posted on my blog about keeping your yii models lean, asking for an exmaple of how to implement behaviors.

At their heart, behaviors are really just a collection of event listeners that you can "plug and play" on different models as needed.

The CTimestampBehavior is probably the easiest to understand out of the box, but it also does things completely behind the scenes.

There are a few ways that you can attach a behavior to a model. If the behavior is one that you want the model to have all the time, then the best practice would be to add it to the behaviors() array of the model the same way the CTimestamp behavior is implemented, like so:
public function behaviors(){
  return array(
      'CTimestampBehavior' => array(
      'class' => 'zii.behaviors.CTimestampBehavior',
      'createAttribute' => 'create_time_attribute',
      'updateAttribute' => 'update_time_attribute',
    )
  );
}

If there are multiple behaviors, you simply add them all — indexing them by the class name or some other unique key that you may wish to access them by in the future:
public function behaviors(){
  return array(
    'CTimestampBehavior' => array(
      'class' => 'zii.behaviors.CTimestampBehavior',
      'createAttribute' => 'create_time_attribute',
      'updateAttribute' => 'update_time_attribute',
    ),
    'amazingBehavior' => array(
      'class' => 'application.components.behaviors.amazingModelBehavior',
    ),
  );
}

Depending on what class you base your custom behavior on, you will have different levels of implementation necessary. If you extend CActiveRecordBehavior, then you will inherit event listeners for all the standard CActiveRecord events such as beforeSave, afterSave, beforeValidate, afterValidate, beforeFind, afterFind, etc. The full list of events that the CActiveRecordBehavior listens for can be found in the event details section of the documentation.

This is the kind of behavior that the CTimestampBehavior is. It knows when the model has been updated, and automatically updates the timestamps for the model without you having to do so directly. There is nothing you need to do to implement it beyond including it in the behaviors array and ensuring that the attribute parameters match the names of the timestamp attributes on the model you're attaching it to.

Lets flesh out the amazingModelBehavior a bit as an example. This custom behavior will activate when the active record is successfully saved, and ONLY when the record is successfully saved.
/**
 * @property-read myModel $owner (Code hinting mojo)
 */
class amazingModelBehavior extends CActiveRecordBehavior
{
  public $didNotifyUser = false;

  /**
   * Override the parent method so we will auto-fire notifications
   * @param CModelEvent $event
   */
  public function afterSave( $event )
  {
     // Do something amazing here, like notify the owner of the record that 
     // it has been updated, since this event will not fire if the save fails.
     $this->notifyOwnerOfPasswordChange(); 
  }

  /**
   * Note, because this is a public function of the behavior, it can ALSO
   * be accessed directly by the owner
   * This is part of what makes behaviors so powerful
   */
  public function notifyOwnerOfPasswordChange()
  {
     // Message here to $this->owner->userRelation->emailAddress; 
     // Assuming this behavior's owner doesn't have the email address on 
     // it directly. You can access any of the model's properties that are
     // available to the behavior by referencing $this->owner->xxx
     $this->didNotifyUser = true;
  }
}

Ok, but what if I don't always want those events to fire off messages to the user when I update their data? Not a problem -- just attach the behavior dynamically in the few situations where you DO want to use it, like so:

// i.e., lets say we have an action where we're updating a password via form
public function actionUpdatePassword()
{ 
   // Load the model
   $model = $this->loadModel();

   // Different validation rules so that they can't change the email 
   // address etc.
   $model->scenario = 'updatePasswordRestricted';

   // Do all the normal post stuff ...
   if ( isset( $_POST['myModel'] ))
   {
       // Attach our amazing behavior so that when/if the model successfully
       // saves, the owner will be sent their notification via email, just 
       // in case it was some nefarious third party changing their password 
       // without their knowledge.
       $model->attachBehavior( 
          'amazingBehavior', 
          'application.components.behaviors.amazingModelBehavior'
       );
       
       $model->attributes = $_POST['myModel'];

       // If the save is successful, the attached event listener will
       // trigger the amazingBehavior, without us having to do anything 
       // else
       if ( $model->save() )
       {
          Yii::app()->user->setFlash(
             'success', 
             'Yay! You changed the information.'
          );

          // We can confirm that the user was notified by checking ...
          if ( $model->didNotifyUser )
          {
             Yii::app()->user->setFlash(
               'warning',
               'A message has been sent to the address of record.' 
             );
          } else {
             Yii::app()->user->setFlash(
               'error',
               'Oh snap -- no messages for you.'
             );
          }

          // Send them on their way ...
          $this->redirect( array('index'));
       }
       Yii::app()->user->setFlash(
           'error', 
           'There was a problem updating your information...'
       );
     
   }
   $this->render('form', array('model'=>$model ) );
}

If you're not using a CActiveRecord as your base model, or simply want to avoid having all those additional event listeners on the model, you can always extend the CBehavior or CModelBehavior.

To do so, if the event you want to use is not already defined in both the events() method of the behavior and on the model your attaching it to as an event trigger, then you will need to create them.

See the onAfterSave() documentation for how to create the event listeners, and the CActiveRecord update() on how to modify your methods to trigger those events and react to them. (That's a whole OTHER blog post...)

I hope that helps to clarify things for those who were asking.

Monday, February 11, 2013

Yii Translations - With Great Power ...

The ability of Yii to translate with ease is one of its primary benefits for many people. You have the ability to translate individual strings or entire views based on the current language selected for the application, and the definitions that you have provided. It's a tremendously powerful tool, when used responsibly. I discovered today that when used with less than extreme care, the results can be ... unexpected.

The core documentation for Yii::t() can be found here: http://www.yiiframework.com/doc/api/1.1/YiiBase#t-detail

Recently, I encountered a bug in some production code that was causing 0s to be stripped from within strings before relaying the ActiveRecord's attribute values. After some convoluted research, I discovered that the problematic code was in an overloaded getAttribute() method, which was calling Yii::t() to translate the attribute value before returning it. Normally, that would not be a problem, however, the model had actually added a method for t, so that it could be referenced as $this->t( $message ); and avoid having to type in the category for the translation, since the translations were being based on the __CLASS__ of the model. Again, this would be fine ... except that the default parameters established for the method were incorrect.

Passing NULL to the $params value of the Yii::t() method in no way equates to the default empty array that the method actually uses. If you pass NULL through as the param value, it will in fact continue through all of the remainder of the Yii::t() method, and execute the final line, which is:
return $params!==array() ? strtr($message,$params) : $message;
Side note: It should be noted that if $params == array() the translated message is returned after the first if block ( https://github.com/yiisoft/yii/blob/1.1.13/framework/YiiBase.php#L580 ). If that same conditional also checked for === null, well, this would be a different post ;)

Now, imagine that the original string was something like:
myemail2001@example.com

Should email addresses be going through Yii::t()? No, not really. The code in question was more on the order of translating the favorite color of the user to the proper language, but when applied universally, without proper thorough research (no, it's NOT safe to assume that null will work as a default value ...), bad things can happen.

The following statements are all true, though the last is a bit shocking:
$attributeValue = 'myemail2001@example.com';

// Translate the value with no parameters - assuming translations are stored 
// in the messages/(lang)/user_preferences.php file
$valueTranslated = Yii::t( 'user_preferences', $attributeValue );

// At this point, $valueTranslated and $attributeValue are the same, since 
// it's an email address, and there is no direct translation.
echo $valueTranslated ,' equals ', $attributeValue;

// Now, lets assume that you presumed the params parameter was null, 
// rather than array() ...
$valueTranslatedWrong = Yii::t( 'user_preferences', $attributeValue, null ); 

// At this point, $valueTranslatedWrong is: myemail21@example.com
// Note that the 0s have all been stripped out of the string
echo $valueTranslatedWrong , ' does not equal ' , $attributeValue;

What?! Where are my 0s? Well, as we all know, in PHP, 0 is considered equal to null, unless you're using ===. So, when you strtr out NULL, you're pulling out the 0s.

Moral of the story? Always double check the method declaration.