Saturday 18 September 2010

Fat Models and Thin Controllers with the Zend Framework

In this article I'm going to discuss what I call Fat Models and how to create them using the Zend Framework. I'll also introduce an abstract base class that wraps up the idea in a reusable way. I'll finish up with a simple example showing the full MVC setup.

The idea of Fat Models is where, in the Model View and Controller (MVC) pattern, to put the code that validates whether the model object can be saved to the database. When a model object has been created or edited, usually via a form, the input is passed through one or more validation objects and only if these validations pass can the object be saved to the database.

Usually this validation code sits in the controller, either in the action method or in a private helper method. The drawback (for me anyway) is that this ties the model directly to the controller and makes testing the behaviour of the model more challenging. It also means that if a programming error is made in the controller, an invalid object might get saved to the database. We call this a fat controller, because it contains a lot of code.

In the Fat Model paradigm, the object maintains it's own validation code. The thin controller simply pumps whatever data it has into the model object and then asks the model object if it can be saved. If the validation fails, the model object populates a list of error messages, that the controller can use to show to the end user. This logic is held in an abstract class, shown below. I've also put this code into my github repository (http://github.com/michaelhodgins/Mooduino); it is under the GPLv2 licence.

abstract class Mooduino_Model_Abstract extends Zend_Db_Table_Row_Abstract { 
  private $_validation_errors = null; 
  /** 
   * Returns true if the model is valid (it passes the validation rules). 
   * @return boolean 
   */ 
  public function isValid() { 
    if (is_null($this->_validation_errors)) { 
      $errors = $this->validate(); 
      if (is_array($errors)) { 
        $this->_validation_errors = $errors; 
      } else { 
        throw new Exception('Validate method didn\'t return an array.'); 
      } 
    } 
    return count($this->_validation_errors) == 0; 
  } 
  /** 
   * Returns all validation errors if there are any or null if there are none. 
   * @return array[string]string|array[string]array[int]string 
   */ 
  public function getErrors() { 
    if (is_array($this->_validation_errors)) { 
      return $this->_validation_errors; 
    } else { 
      throw new Exception('Model has not been validated'); 
    } 
  } 
  /** 
   * Returns the validation error or array of errors for the given field name. 
   * Be sure that there is an error before calling this method by calling 
   * hasError() first. 
   * @param string $field 
   * @return string|array[int]string 
   */ 
  public function getError($field) { 
    $errors = $this->getErrors(); 
    if (array_key_exists($field, $errors)) { 
      return $this->_validation_errors[$field]; 
    } else { 
      throw new Exception('Field not found'); 
    } 
  } 
  /** 
   * Returns true if the given field name has a validation error or false if 
   * it hasn't. 
   * @param string $field 
   * @return boolean 
   */ 
  public function hasError($field) { 
    return is_array($this->_validation_errors) && array_key_exists($field, $this->_validation_errors); 
  } 
  /** 
   * An implementation of the method should validate the instance of the 
   * implementing class and return an array of error messages. If there are 
   * no errors, an empty array should be returned. 
   * @return array[string]string|array[string]array[int]string 
   */ 
  public abstract function validate(); 
  /** 
   * Overrides the save() method in Zend_Db_Table_Row_Abstract so that it is 
   * only called if $this->isValid() returns true. 
   * @return mixed 
   */ 
  public final function save() { 
    if ($this->isValid()) { 
      return parent::save(); 
    } else { 
      throw new Exception('Model can\'t be saved as it isn\'t valid'); 
    } 
  } 
  /** 
   * Given an Iterator such as a Zend_Form, this method will set any error 
   * messages to the form elements. 
   * @param Iterator $iterator 
   */ 
  public final function discoverErrors(Iterator $iterator) { 
    foreach ($iterator as $element) { 
      if ($element instanceof Iterator) { 
        $this->discoverErrors($element); 
      } elseif ($element instanceof Zend_Form_Element) { 
        $this->discoverError($element); 
      } 
    } 
  } 
  /** 
   * If there is a validation error for the given field, it will be set. 
   * @param Zend_Form_Element $element 
   */ 
  private final function discoverError(Zend_Form_Element $element) { 
    if ($this->hasError($element->getName())) { 
      $element->addErrors($this->getError($element->getName())); 
    } 
  } 
}

The last two methods (one public and one private) are used later by the controller to populate a Zend_Form object with the errors that occurred during validation. The public method is recursive so that it correctly handles fieldsets.

An example model


As you can see, the abstract class extends Zend_Db_Table_Row_Abstract and in order to make use of this class, we'll need two model classes; a Table class and a Row class (you can also set up a Rowset class but I don't tend to do that).

First the Table class; this is the class that describes the database table and it's relationships with other tables in the database. Before we can create a Table class, we need an actual database table. For this example, I'm going to use the following simple table.

CREATE TABLE `mooduino_notes`.`notes` (
 `id` bigint(20) NOT NULL AUTO_INCREMENT,
 `text` longtext NOT NULL,
 `title` varchar(255) NOT NULL,
 `priority` int(11) NOT NULL,
 PRIMARY KEY (`id`),
 UNIQUE KEY `Search` (`title`,`text`(767)),
 KEY `Priority` (`priority`)
);

The Table class is very simple in this example because we only have one table; it simply sets which table in the database to point to and which class to use as the Row class.

class Application_Model_DbTable_Notes extends Zend_Db_Table_Abstract {
  protected $_name = 'notes';
  protected $_rowClass = 'Application_Model_Note';
}

The Row class isn't much more complicated; it implements the abstract validate() method from the base class, and it encapsulates the database table columns names with getters and setters. I do this so that the code in the view doesn't need any knowledge of the database columns. Notice how the validate() method employs the Zend_Validate package to check the three fields in the model.

class Application_Model_Note extends Mooduino_Model_Abstract {
  /**
   * Returns an list of error messages, if there are any generated
   * while validating the note.
   * @return array
   */
  public function validate() {
    $errors = array();
    
    $titleValidator = new Zend_Validate_StringLength(array(
      'min'=>3,
      'max'=>255
    ));
    if (!$titleValidator->isValid($this->getTitle())) {
      $errors['title'] = $titleValidator->getMessages();
    }
    $textValidator = new Zend_Validate_StringLength(array('min'=>1));
    if (!$textValidator->isValid($this->getText())) {
      $errors['text'] = $textValidator->getMessages();
    }
    $priorityValidator = new Zend_Validate_Int();
    if (!$priorityValidator->isValid($this->getPriority())) {
      $errors['priority'] = $priorityValidator->getMessages();
    }
    return $errors;
  }
  /**
   * Returns the id.
   * @return int
   */
  public function getId() {
   return $this->id;
  }
  /**
   * Returns the title.
   * @return string
   */
  public function getTitle() {
    return $this->title;
  }
  /**
   * Sets the title.
   * @param string $title
   */
  public function setTitle($title) {
    $this->title = $title;
  }
  /**
   * Returns the text.
   * @return string
   */
  public function getText() {
    return $this->text;
  }
  /**
   * Sets the text.
   * @param string $text
   */
  public function setText($text) {
    $this->text = $text;
  }
  /**
   * Returns the priority.
   * @return int
   */
  public function getPriority() {
    return $this->priority;
  }
  /**
   * Sets the priority.
   * @param int $priority
   */
  public function setPriority($priority) {
    $this->priority = $priority;
  }
}

The views


We have our model, next our view. I'm going to have three; one that lists existing notes (the index action), one for adding a new note and one for editing an existing note. The index action's view, index.phtml, is as follows. Notice that I'm using Smarty syntax but you don't have to. The script checks that there are notes to display, and if there are, it lists them in a table.

<a href="/notes/new">New Note</a>
{if $notes->count() < 0}
<table>
 <thead>  
  <tr>
    <th>ID</th>
    <th>Title</th>
    <th>Priority</th>
   </tr>
 </thead>
 <tbody>
   {foreach from=$notes item=note}
   <tr>
    <td>{$note->getId()}</td>
    <td>{$note->getTitle()}</td>
    <td>{$note->getPriority()}</td>
    <td><a href="/notes/edit/id/{$note->getId()}">Edit</a></td>
   </tr>
   {/foreach}
 </tbody>
</table>
{else}
 <p>There are no notes at this time.</p>
{/if}

The new and edit action scripts both do the same thing; they render the form that they are passed by the controller. Save the following as new.phtml and edit.phtml in the scripts/notes directory.

{if isset($form)}
  {$form}
{/if}


The thin(ner) controller.


The last part of the example is of course the controller, now without any validation code. You'll notice though that it still sanitizes the user's input in the updateNoteFromRequest() method. I've used the Zend_Filter_StripTags class but you'd do whatever your application requires. In the controller you should notice that the action methods are rather short. Most of the functionality you might otherwise put in the action methods is handled elsewhere, either in private methods (like creating the Zend_Form object) or in the model classes.

class NotesController extends Zend_Controller_Action {

 public function init() {
  /* Initialize action controller here */
 }

 public function indexAction() {
  $table = new Application_Model_DbTable_Notes();
  $this->view->notes = $table->fetchAll();
 }

 public function newAction() {
  $table = new Application_Model_DbTable_Notes();
  $note = $table->fetchNew();

  if ($this->getRequest()->isPost()) {

   $this->updateNoteFromRequest($note, $this->getRequest());

   if ($note->isValid()) {
    $note->save();
    $this->_redirect('/notes/index');
   }
  }
  $this->view->note = $note;
  $this->view->form = $this->notesForm($note);
 }

 public function editAction() {
  $id = $this->getRequest()->getParam('id', 0);
  if ($id == 0) {
   $this->_redirect('/notes/index');
   exit;
  }
  $table = new Application_Model_DbTable_Notes();
  $select = $table->select()
    ->where('id = ?', $id);
  $note = $table->fetchRow($select);
  
  if ($this->getRequest()->isPost()) {

   $this->updateNoteFromRequest($note, $this->getRequest());

   if ($note->isValid()) {
    $note->save();
    $this->_redirect('/notes/index');
   }
  }
  $this->view->note = $note;
  $this->view->form = $this->notesForm($note);
 }

 /**
  * Takes the data from the request object and puts it into the note object.
  * @param Application_Model_Note $note
  * @param Zend_Controller_Request_Abstract $request
  */
 private function updateNoteFromRequest(Application_Model_Note $note, Zend_Controller_Request_Abstract $request) {
  $filter = new Zend_Filter_StripTags();

  $note->setTitle($filter->filter($request->getParam('title', '')));
  $note->setText($filter->filter($request->getParam('text', '')));
  $note->setPriority($filter->filter($request->getParam('priority', '')));
 }

 /**
  * Creates a form for the given note object.
  * @param Application_Model_Note $note
  * @return Zend_Form
  */
 private function notesForm(Application_Model_Note $note = null) {
  $form = new Zend_Form();

  $title = new Zend_Form_Element_Text('title', array('label' => 'Title'));
  $form->addElement($title);

  $text = new Zend_Form_Element_Textarea('text', array('label' => 'Text'));
  $form->addElement($text);

  $priority = new Zend_Form_Element_Text('priority', array('label' => 'Priority'));
  $form->addElement($priority);

  $submit = new Zend_Form_Element_Submit('submit', array('value' => 'Save'));
  $form->addElement($submit);

  if (!is_null($note)) {
   $title->setValue($note->getTitle());
   $text->setValue($note->getText());
   $priority->setValue($note->getPriority());

   $note->discoverErrors($form);
  }

  return $form;
 }

}

5 comments:

  1. I like the way you describe the fat models, but I'm kinda confused here.

    Since you both have a model and a form, it would be more logic to create a separate form class extending Zend_Form that you can set in your model for filtering and validation purposes. This way you have best of both worlds (model containing and modifying data and your form object for filtering and validation) without mixing each purpose. And it can be reused in more than one way.

    Just my $ 0.02,

    Michelangelo

    ReplyDelete
  2. Hi Michelangelo

    Thanks for the comment. You know, if that works for you, then great, I can totally see that working. For me though, I'm not sure I like the idea of tightly coupling my models to Zend_Form as it presupposes that Zend_Form is the only user interface into the model. When I Unit test my models, I want to be testing the model, not the form.

    ReplyDelete
  3. Hey Moo,

    I agree with Michelangelo. Zend_Form can be used as an input filter and validator without ever using it for user interface. The only time decorators are applied and html is output is when you call render on the form.

    As to your concern about coupling, I agree, you want to be able to test your model and only the model. However, in the implementation you have above, you have hard dependencies on Zend_Validate classes.

    If you pass your Zend_Form object into your model's constructor, you can mock the form out when you're unit testing, you no longer have tight coupling between the form and your model, and you can clean out the Zend_Validate and Zend_Filter classes that you have hanging out in your models and controllers.

    You'll also be able to cut your models code by at least 2/3, providing higher cohesion in your models.

    My $0.03.

    ReplyDelete
  4. You need a Service Layer !

    ReplyDelete
  5. Once and for all, a model SHOULD NOT be derived from Zend_Db_Table_Row_Abstract.

    The relationship between an application model and Zend_Db_Table_Row_Abstract should not be a IS-A, it should be a HAS-A!

    ReplyDelete