ActiveRecord and transaction rollback in Yii

I would like to talk about one problem that we encountered when developing our startup project for management accounting.

For our project, as an accounting system, it is typical to make changes in other objects after saving the current one, for example, holding a document on registers after saving. The bottom line is that after saving the object in the transaction, ActiveRecord will assume that all changes were successful, although this is not guaranteed, because subsequent changes can cause Exception, and it, in turn, will roll back the transaction. In our case, this threatens that if the record was created incorrectly, the ActiveRecord instance will already have the status of the existing record (flag isNewRecord == false) or primaryKey will already be assigned to the new record. If you relied on these attributes when rendering (as we did in our project), you will end up with an erroneous view.

    /**
     * Creates a new model.
     */
    public function actionCreate()
    {
        /** @var BaseActiveRecord $model */
        $model = new $this->modelClass('create');
        $this->performAjaxValidation($model);
        $model->attributes = Yii::app()->request->getParam($this->modelClass, array());
        if (Yii::app()->request->isPostRequest && !Yii::app()->request->isAjaxRequest) {
            $transaction = $model->getDbConnection()->beginTransaction();
            try {
                $model->save();
                $transaction->commit();
                $url = array('update', 'id' => $model->primaryKey);
                $this->redirect($url);
            } catch (Exception $e) {
                $transaction->rollback();
            }
        }
        $this->render('create', array('model' => $model));
    }


This is practically the Yii tutorial code. With one exception - saving the object in the database is wrapped in a transaction.

What to do? After rollback (), you need to restore the original state of ActiveRecord. In our case, it was still necessary to restore all ActiveRecords that were changed inside the original model.

To begin with, we turn to the global mind, suddenly, we invent a bicycle. At Github this problem has already been discussed. The developers said that they do not have plans for this at the framework level, since it is resource-intensive. They can be understood for most projects, prior model validation is sufficient. We are missing - we are writing our solution to the problem.

Extending the CDbTransaction class.

/**
 * Class DbTransaction
 * Stores models states for restoring after rollback.
 */
class DbTransaction extends CDbTransaction
{
    /** @var BaseActiveRecord[] models with stored states */
    private $_models = array();
    /**
     * Checks if model state is already stored.
     * @param BaseActiveRecord $model
     * @return boolean
     */
    public function isModelStateStoredForRollback($model)
    {
        return in_array($model, $this->_models, true);
    }
    /**
     * Stores model state for restoring after rollback.
     * @param BaseActiveRecord $model
     */
    public function storeModelStateForRollback($model)
    {
        if (!$this->isModelStateStoredForRollback($model)) {
            $model->storeState(false);
            $this->_models[] = $model;
        }
    }
    /**
     * Rolls back a transaction.
     * @throws CException if the transaction or the DB connection is not active.
     */
    public function rollback()
    {
        parent::rollback();
        foreach ($this->_models as $model) {
            $model->restoreState();
        }
        $this->_models = array();
    }
}


We add the restoreState (), hasStoredState () and storeState () methods to the BaseActiveRecord class (CActiveRecord extension, it already existed in our project).

abstract class BaseActiveRecord extends CActiveRecord
{
    /** @var array сохраненное состояние модели */
    protected $_storedState = array();
    /**
     * Проверка наличия сохраненного состояния модели
     * @return boolean
     */
    public function hasStoredState()
    {
        return $this->_storedState !== array();
    }
    /**
     * Сохранение состояния модели
     * @param boolean $force флаг принудительного сохранения
     * @return void
     */
    public function storeState($force = false)
    {
        if (!$this->hasStoredState() || $force) {
            $this->_storedState = array(
                'isNewRecord' => $this->isNewRecord,
                'attributes' => $this->getAttributes(),
            );
        }
    }
    /**
     * Восстановаление состояния модели
     * @return void
     */
    public function restoreState()
    {
        if ($this->hasStoredState()) {
            $this->isNewRecord = $this->_storedState['isNewRecord'];
            $this->setAttributes($this->_storedState['attributes'], false);
            $this->_storedState = array();
        }
    }
}


As you can see from the code, we only backup the isNewRecord flag and current attributes (including primaryKey). Now it remains only to fix our first code fragment in order to remember the state of the model before saving.

    /**
     * Creates a new model.
     */
    public function actionCreate()
    {
        /** @var BaseActiveRecord $model */
        $model = new $this->modelClass('create');
        $this->performAjaxValidation($model);
        $model->attributes = Yii::app()->request->getParam($this->modelClass, array());
        if (Yii::app()->request->isPostRequest && !Yii::app()->request->isAjaxRequest) {
            $transaction = $model->getDbConnection()->beginTransaction();
            // Сохраняем состояние объекта
            $transaction->storeModelStateForRollback($model);
            try {
                $model->save();
                $transaction->commit();
                $url = array('update', 'id' => $model->primaryKey);
                $this->redirect($url);
            } catch (Exception $e) {
                $transaction->rollback();
            }
        }
        $this->render('create', array('model' => $model));
    }


In our project, we went a little further - moved $ transaction-> storeModelStateForRollback ($ model) to the save () method of BaseActiveRecord itself.

abstract class BaseActiveRecord extends CActiveRecord
{
    // ...
    /**
     * Сохранение экземпляра модели (с поддержкой транзакционности)
     * @param boolean $runValidation необходимость выполнения валидации перед сохранением
     * @param array   $attributes    массив атрибутов для сохранения
     * @throws Exception|UserException
     * @return boolean результат операции
     */
    public function save($runValidation = true, $attributes = null)
    {
        /** @var DbTransaction $transaction */
        $transaction = $this->getDbConnection()->getCurrentTransaction();
        $isExternalTransaction = ($transaction !== null);
        if ($transaction === null) {
            $transaction = $this->getDbConnection()->beginTransaction();
        }
        $transaction->storeModelStateForRollback($this);
        $exception = null;
        try {
            $result = parent::save($runValidation, $attributes);
        } catch (Exception $e) {
            $result = false;
            $exception = $e;
        }
        if ($result) {
            if (!$isExternalTransaction) {
                $transaction->commit();
            }
        } else {
            if (!$isExternalTransaction) {
                $transaction->rollback();
            }
            throw $exception;
        }
        return $result;
    }
    // ...
}

This allowed the rest of the code not to think that the model needs to be restored after the rollback of transactions, and also forces to recursively backup all participating models in saving the current model.

It may seem that the problem and its solution are not worth attention, but as practice has shown, if you do not take this into account immediately during development, you can search for the cause of incomprehensible bugs for a long time.

Also popular now: