Yii 1.1: How to avoid a database update if the ActiveRecord was not modified

3 followers

Classic Scenario

The classic Yii way for doing an update action is as follows:

  • load the model using the id (or other data) received with the request
  • massively assign its attributes
  • if validation is ok, save the model

The update of your model will happen, regardless wether or not the user actually modified any attributes. Any audit attrtibutes like for example last_change_date will be updated too.

Detect Attribute Changes

It is possible to detect any changes to the attributes and avoid the costly and unnecessary update operation. There are a couple of complications though. The values in a model's attributes may differ from the database contents because afterFind events changed them. Likewise, the user input can be modified in validation rules and beforeSave events. It is therefore crucial to compare the attribute values at equal moments in their lifetime. The implementation presented here, compares the values as they are read from the database (i.e. before any afterFind events) with the values right before they are written to the database (i.e. after validation rules and any beforeSave events).

Complication 1: data that the system adds but is not coming from the user such as audit trails, changes for every update. These attributes must be ignored when deciding whether or not a record should be considered modified.

Complication 2: null values. A null value will typically become an empty string in the user interface and will also be assigned as an empty string during the massive attributes assignment. It is only very deep in the save operation that the framework will reconvert this into null.

IsDirtyActiveRecord

Here is an implementation for the mechanism described above to detect attribute changes and avoid the extra unnecessary update.

IsDirtyActiveRecord extends CActiveRecord (or another class if you already extended CActiveRecord). If you want to use it, all you have to do is extend your models from IsDirtyActiveRecord instead.

/**
 * IsDirtyActiveRecord adds the isWritten property to {@link CActiveRecord}.
 * When the model's attributes have not changed, then no actual save to the database happens: isWritten will be false, even though save() returned true. 
 */
class IsDirtyActiveRecord extends CActiveRecord {
 
    private $_isWritten = false;
    private $_originalHash;
 
    /**
     * detectDirty enables or disables the calculation of a hash of the attributes after every find operation.
     * @var boolean
     */
    public $detectDirty = true;
 
    /**
     * Some attributes may have changed but that change does not indicate that the record is dirty.  E.g. audit attributes may have
     * been set to a new value: the lastupdatetime for example.  These attributes should be ignored when determining whether or not
     * the model is dirty.
     * @var array
     */
    public $neverMakeDirtyAttributes = array();
 
    /**
     * Should afterSave() events be called if a model was saved but not actually written to the database because it was not dirty ?
     * @var boolean
     */
    public $doAfterSaveIfNotDirty = false;
 
    /**
     * isWritten returns true if the save() did really write something to the database.
     * If the model was not dirty, then save() returns true, but isWritten is false.
     * Note that you should evaluate the result of save() to know whether the validation was
     * succesful, only then should you evaluate isWritten.
     * I.e.
     * if ($model->save())
     *      if ($model->isWritten) {
     *          //really updated the record in the database
     *          ...
     *      }
     *      else {
     *          //validation was ok, but the record was not written because not dirty
     *          ...
     *      }
     * }
     *      
     * @return boolean
     */
    public function getIsWritten() {
        return $this->_isWritten;
    }
 
    /**
     * Calculate the hash and only then launch afterFind() events, because these could modify the attributes.
     * Note that any overrides of this function should first call parent::afterFind() and then implement their own code.
     */
    protected function afterFind() {
        if ($this->detectDirty) {
            $this->_originalHash = $this->calculateHash();
        }
        parent::afterFind();
    }
 
    /**
     * Initialize isWritten to true.  It will be set to false when the save did not happen because the model was not dirty.
     * @return boolean
     */
    protected function beforeSave() {
        $this->_isWritten = true;
        return parent::beforeSave();
    }
 
    /**
     * This is a verbatim copy of the Yii 1.1.14 version of update() with modifications to stop the save in
     * case the model is not dirty. (and it uses the parent's class methods to track primary key modifications
     * instead of directly writing to private variables which are inaccessible here)
     * The update() needs to be overridden here because it raises the beforeSave() events and then saves.  But
     * we need to calculate the hash after beforeSave(), but before anything is written to the database.
     * @param array $attributes list of attributes that need to be saved. Defaults to null,
     * meaning all attributes that are loaded from DB will be saved.
     * @return boolean whether the update is successful or the model was not dirty.
     * @throws CDbException if the record is new
     */
    public function update($attributes=null)
    {
        if($this->getIsNewRecord())
            throw new CDbException(Yii::t('yii','The active record cannot be updated because it is new.'));
        if($this->beforeSave())
        {
            if ($this->detectDirty && $this->_originalHash == $this->calculateHash(true)) {
                $this->_isWritten = false;
                if ($this->doAfterSaveIfNotDirty)
                    $this->afterSave();
                return true;
            }
 
            Yii::trace(get_class($this).'.update()','system.db.ar.CActiveRecord');
 
            if($this->getOldPrimaryKey()===null)
                $this->setOldPrimaryKey($this->getPrimaryKey());
            $this->updateByPk($this->getOldPrimaryKey(),$this->getAttributes($attributes));
            $this->setOldPrimaryKey($this->getPrimaryKey());
            $this->afterSave();
            return true;
        }
        else
            return false;
    }
 
    /**
     * Calculate a hash from the model's attributes.
     * It excludes attributes in neverMakeDirtyAttributes.
     * If $castEmpty=true it will typecast empty string values to null if the matching database field is not a string.
     * That is what the Yii framework does when saving, so we need to mimic this here to be able to compare attributes after load and before save.
     * See {@see CActiveRecord::createUpdateCommand}.
     * @param boolean $castEmpty
     * @return string
     */
    private function calculateHash($castEmpty = false) {
        $a = array_diff_key($this->getAttributes(false), array_flip($this->neverMakeDirtyAttributes));
        if ($castEmpty) {
            $a = $this->typeCastEmpty($a);
        }
        return md5(serialize($a));
    }
 
    /**
     * Any empty attributes (value==='') for non-string database fields are translated into null.
     * @param array $a
     * @return array
     */
    private function typeCastEmpty($a) {
        $table=null;
        foreach($a as $k=>$v) {
            if ($v==='') {
                if ($table===null)
                    $table=$this->getTableSchema();
                $column = $table->getColumn($k);
                if($column->allowNull && $column->type!=='string')
                    $a[$k]=null;
            }
        }
        return $a;
    }
}

Usage

Model

If your model has any audit attributes, you should list them as exceptions in the Init() function. Otherwise no changes necessary, apart from the extending of course.

class MyThing extends IsDirtyActiveRecord
{
    /* ... */
 
    public function init() {
        parent::init();
        //isDirty exceptions: a change in these attributes does not mean the model is modified
        $this->neverMakeDirtyAttributes = array('updatetime', 'updateuserid');
 
        //do not bother calculating isDirty if not $_POST
        $this->detectDirty = isset($_POST[get_class($this)]);
        /* ... rest of init ... */
    }
 
    /* ... rest of model MyThing ... */
}

Controller

You probably could leave your conrtroller exactly like it is. If you want to make a distinction between a save with actual data written to the database and a save that did not write data, do this:

class MyThing Controller extends Controller
{
    public function actionUpdateMyThing($thingid) {
        $mything = $this->LoadMyThing($thingid);  //findByPk, Exception if not found
 
        if (isset($_POST['MyThing'])) {
            $mything->attributes = $_POST['MyThing'];
 
            if ($mything->save()) {
                if ($mything->isWritten) {
                    //do something because new data was written
                    //could be: set flash message, clear cache, ...
                    /* ... */
                }
                $this->redirect(array('mything/menu'));  //back to menu
            }
        }
        $this->render('update', array('mything' => $mything));
    }

Rationale

For a regular website where an update operation is rather exceptional, this is not worth the trouble. But for a data entry application this may be more appropriate. Think web applications where every user is logged in and where it does not make sense to have seperate view and update pages: everything is mostly presented in update pages. Chances are high that users will click the submit button instead of the cancel button to close the page and go back to the menu.

This implementation avoids the costly update operation.

Another reason might be that you do not want to update audit attributes if there were no actual changes to the model.

Or maybe you just do not want to waste cpu cycles and disk io ;-)

Be the first person to leave a comment

Please to leave your comment.

Write new article