Pattern for using a transaction in a model method, gracefully

I want to find or figure out a good pattern for writing a model method that uses a transaction but only if the caller is not already using one. For example, model Bit has a method saveThings() that does a sequence of delete()s and then a sequence of save()s. These belong inside a transaction. But I want to write the model such that the caller, either a controller method or another model method, may take charge of the transaction.

My first attempt was this, which leaves out some customary Yii model stuff (also at https://gist.github.com/1555642):


<?php

/**

 * @property int $bit_id

 */

class Thing extends CActiveRecord {

}


/**

 * @property int $id

 * @property Thing[] $things

 */

class Bit extends CActiveRecord {


    public function relations() {

        return array(

            'things' => array(self::HAS_MANY, 'Thing', 'bit_id'),

        );

    }


    /**

     * This is an example function that another model method or a controller might use.

     *

     * @param Thing[] $things

     * @return bool true on success

     */

    public function saveThings($things) {

        $curTr = Yii::app()->db->getCurrentTransaction();

        $transaction = $curTr === null || !$curTr->getActive()

            ? app()->db->beginTransaction()

            : false;

        try {

            // Begin of example task...

            if ($this->things)

                foreach ($this->things as $thing)

                    $thing->delete();

            if ($things)

                foreach ($things as $thing) {

                    $thing->bit_id = $this->id;

                    if (!$thing->save())

                        throw new CDbException('Cannot save a thing');

                }

            // ...end of example task.

            if ($transaction)

                $transaction->commit();

        } catch(Exception $e) {

            if ($transaction) {

                $transaction->rollback();

                Yii::log(__CLASS__ . '::' . __FUNCTION__ . '() failed');

                return false;

            } else

                throw $e;

        }

        return true;

    }

}



Then someone very experienced told me I could put the beginTransaction in a beforeSave and the commit/rollback in an afterSave. I got very confused trying to figure out how to distribute the throw/catch logic around. I came up with this:


class Bit extends CActiveRecord {


    /**

     * @var CDbTransaction

     */

    protected $_transaction;


    public function relations() {

        return array(

            'things' => array(self::HAS_MANY, 'Thing', 'bit_id'),

        );

    }


    protected function beforeDelete() {

        $this->beforeChange();

        return parent::beforeDelete();

    }


    protected function beforeSave() {

        $this->beforeChange();

        return parent::beforeSave();

    }


    protected function afterDelete() {

        $this->afterChange();

        return parent::afterDelete();

    }


    protected function afterSave() {

        $this->afterChange();

        return parent::afterDelete();

    }


    /**

     * Begins a transaction if the caller of the model's method didn't already do so.

     */

    protected function beforeChange() {

        $transaction = Yii::app()->db->getCurrentTransaction();

        if (($transaction === null || !$transaction->getActive())

            && ($this->_transaction === null || !$this->_transaction->getActive())

        )

            $this->_transaction = Yii::app()->db->beginTransaction();

    }


    /**

     * If this model has a $this->_transaction then try to committ it and roll it

     * back if commit throws a CDbException.

     */

    protected function afterChange() {

        if ($this->_transaction !== null && $this->_transaction->active) {

            // Does Yii know to *not* call either afterDelete() or afterSave()

            // until the method has finished all its deletes, updates and saves?

            try {

                $this->_transaction->commit();

            } catch (Exception $e) {

                $this->_transaction->rollback();

                // Handle the error with some application logic, e.g. log it and

                // display an error message.

            }

        }

    }




    /**

     * This is an example function that another model method or a controller might use.

     *

     * @param Thing[] $things

     * @return bool true on success

     */

    public function saveThings($things) {

        // Handle the exceptions thrown by model manipulation here but

        // leave the commit to afterChnage().

        try {

            // Begin of example task...

            if ($this->things)

                foreach ($this->things as $thing)

                    $thing->delete();

            if ($things)

                foreach ($things as $thing) {

                    $thing->bit_id = $this->id;

                    if (!$thing->save())

                        throw new CDbException('Cannot save a thing');

                }

            // ...end of example task.

        } catch (Exception $e) {

            if ($this->_transaction !== null && $this->_transaction->getActive())

                $this->_transaction->rollback();

            else

                throw $e;

        }

    }

}

If that works then it’s a nicer solution when the model has more than one method that needs this kind of treatment.

But I’m not confident that it does work. Does Yii know to not call either afterSave or afterDelete until the whole sequence of both deleting and saving in saveThings() is complete? If so how does it trigger the event?

And does the code work if another method of the same model with the same pattern calls saveThings()? In my first attempt, $transaction is a local variable of the method so I feel fairly confident. I’m not so sure of the second version when it is an instance variable.

The code is available for your editing here: https://gist.github.com/1555642