📜 ⬆️ ⬇️

ActiveRecord and transaction rollback in Yii

I would like to talk about one problem that we encountered in 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 an object in a transaction, ActiveRecord will assume that all changes were successful, although this is not guaranteed, because subsequent changes can cause an Exception, and it in turn will roll back the transaction. In our case, it threatens with the fact that if an entry is erroneously created, the ActiveRecord instance will already have the status of an existing record (the isNewRecord flag == false) or the new record will already be assigned primaryKey. If you relied on these attributes when rendering (as we did in our project), then you will get an erroneous idea as a result.

/** * 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 code of lessons Yii. With one exception, saving the object to the database is wrapped in a transaction.
')
What to do? It is necessary after rollback () to restore the original state of ActiveRecord. In our case, it was necessary to restore all ActiveRecord `s, changed inside the original model.

To begin with, we appeal to the universal mind, all of a sudden, we are reinventing the bicycle. On Gitkhab, this problem has already been discussed. The developers said that they have no plans to solve it at the framework level, since it is resource intensive. They can be understood for the majority of projects with sufficient preliminary validation of the model. We do not have enough - we write our own solution to the problem.

Extend 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 methods restoreState (), hasStoredState (), and storeState () to the BaseActiveRecord class (the 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 back up only the isNewRecord flag and the current attributes (including primaryKey). Now it only remains to correct 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 - we moved $ transaction-> storeModelStateForRollback ($ model) to the BaseActiveRecord's save () method.

 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 rollback of transactions, and also makes it necessary to back up all participating models recursively in saving the current model.

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

Source: https://habr.com/ru/post/198100/


All Articles