September 23rd, 2009

Zend_Db_Table and Zend_Db_Table_Row provide a great base for the “model” portion of the MVC paradigm implemented by the Zend Framework. Zend_Db_Table abstracts an underlying database table, and Zend_Db_Table_Row abstracts a row. They provide normalized functionality, are database agnostic, and offer other types of slickness that won’t be covered here.

If you haven’t experimented with Zend_Db yet, here’s a simple example of the basic abstractions:

Insert a record:

if ($this->_request->isPost()) {
    $usersModel = new Users();
    $user = $usersModel->createRow();
    $user->name = $this->_request->getParam('name');
    $user->age = $this->_request->getParam('age');
    $user->save();
}

Update a record:

$id = (int)$this->_request->getParam('id');
$usersModel = new Users();
$user = $usersModel->getRow('id = ?', $id);
if (!empty($user)) {
    // Just set the name to uppercase..
    $user->name = strtoupper($user->name);
    $user->save();
}

Notice that when $row->save() is called, the appropriate insert/update is made depending upon whether the row is new or not.

The update method is even smart enough to only update fields that have been marked as modified.

But there is a small problem. Let’s have a look at Zend_Db_Table_Row_Abstract::_set() to see what happens when a value is set into a row:

public function __set($columnName, $value)
{
    $columnName = $this->_transformColumn($columnName);
    if (!array_key_exists($columnName, $this->_data)) {
        require_once 'Zend/Db/Table/Row/Exception.php';
        throw new Zend_Db_Table_Row_Exception(
            "Specified column \"$columnName\" is not in the row"
        );
    }
    $this->_data[$columnName] = $value;
    $this->_modifiedFields[$columnName] = true;
}

Observe at the marked lines that the column is marked modified regardless of the value being set. If the value passed in is already set for that column, it’s still marked modified even though technically it hasn’t been.

This may or may not be desirable given the context, but consider this: Data is always posted in a form, whether or not it changes on the client side. It doesn’t make sense to always compare each field with the column in the row to determine if the data has changed. Surely this can be done better.

What makes sense is for the row to do this check automatically. What makes even more sense is to make the modified columns and their values available, so that controller specific logic can be written to handle special cases when certain data has changed.

It all starts with that Zend_Db_Table_Row_Abstract::_set() function.

What we want is to make the _set function smarter, so that setting a value into a column that already has that value doesn’t also mark it modified.

Zend_Db_Table_Row_Abstract can be extended to add the functionality, then Zend_Db_Table_Abstract can be extended to use the new extended row class. When the model is defined, it will extend My_Db_Table_Abstract and inherit the new functionality.

An overloaded Zend_Db_Table_Row_Abstract:

class My_Db_Table_Row extends Zend_Db_Table_Row_Abstract
{
    // Return modifiedFields
    public function getModifiedFields()
    {
    	return $this->_modifiedFields;
    }

    // Return a hash of only modified field => value pairs
    public function getModifiedValues()
    {
    	$modifiedValues = array();
    	foreach ($this->_modifiedFields as $field => $value)
    	{
    	    $modifiedValues[$field] = $this->_data[$field];
    	}
    	return $modifiedValues;
    }

    //Override, don't allow unmodified fields to be marked...
    public function __set($columnName, $value)
    {
        $columnName = $this->_transformColumn($columnName);
        if (!array_key_exists($columnName, $this->_data)) {
            require_once 'Zend/Db/Table/Row/Exception.php';
            throw new Zend_Db_Table_Row_Exception(
                "Specified column \"$columnName\" is not in the row"
            );
        }

        $origData = $this->_data[$columnName];
        if ($origData != $value) {
            $this->_data[$columnName] = $value;
            $this->_modifiedFields[$columnName] = true;
        }

    }
}

An overloaded Zend_Db_Table_Abstract:

class My_Db_Table_Abstract extends Zend_Db_Table_Abstract {

    protected $_rowClass = 'My_Db_Table_Row';

    public function columnExists($column)
    {
        return in_array($column, $this->_cols);
    }
}

Portion of a controller script utilizing the new row:

$form = new MyForm();
$form->populate($this->_request()->getPost();
$id = (int)$this->_request->getParam('id');
$usersModel = new Users();
$user = $usersModel->getRow('id = ?', $id);
if (!empty($user)) {
    $user->name = $form->getElement['name']->getValue();
    $user->age = $form->getElement['age']->getValue();
    $mods = user->getModifiedValues();
    $user->save();

    foreach($mods as $mod) {
        // do something with the modified values;
    }
}