3

OK I'm finding a solution for "NESTED" transactions in MySQL using PHP, and as you know in the MySQL documentation says that it's not possible to have transactions within transactions (Mysql transactions within transactions). I was trying to use the Database class propossed in http://php.net/manual/en/pdo.begintransaction.php but unfortunately that's wrong for me, because its counter scope is object level and not class level, to solve this issue I created this class (TransactionController) that has the counter (named $nest) static and it brings the class level required to make the transactions "linear" (with "linear" I'm saying: it aparently is nested but if you look quite it is not nested, then the transactions will work well, what do you think? (LOOK THE EXAMPLE AT THE END, CarOwner)

        class TransactionController extends \\PDO {
            public static $warn_rollback_was_thrown = false;
            public static $transaction_rollbacked = false;
            public function __construct()
            {
                parent :: __construct( ... connection info ... );
            }
            public static $nest = 0;
            public function reset()
            {
                TransactionController :: $transaction_rollbacked = false;
                TransactionController :: $warn_rollback_was_thrown = false;
                TransactionController :: $nest = 0;
            }
            function beginTransaction()
            {
                $result = null;
                if (TransactionController :: $nest == 0) {
                    $this->reset();
                    $result = parent :: beginTransaction();
                }
                TransactionController :: $nest++;
                return $result;
            }

            public function commit()
            {

                $result = null;

                if (TransactionController :: $nest == 0 &&
                        !TransactionController :: $transaction_rollbacked &&
                        !TransactionController :: $warn_rollback_was_thrown) {
                            $result = parent :: commit();
                        }
                        TransactionController :: $nest--;
                        return $result;
            }

            public function rollback()
            {
                $result = null;
                if (TransactionController :: $nest >= 0) {
                    if (TransactionController :: $nest == 0) {
                        $result = parent :: rollback();
                        TransactionController :: $transaction_rollbacked = true;
                    }
                    else {
                        TransactionController  :: $warn_rollback_was_thrown = true;
                    }
                }
TransactionController :: $nest--;
                return $result;
            }

    public function transactionFailed()
    {
        return TransactionController :: $warn_rollback_was_thrown === true;
    }
    // to force rollback you can only do it from $nest = 0
    public function forceRollback()
    {
        if (TransactionController :: $nest === 0) {
            throw new \PDOException();
}
    }
        }

        class CarData extends TransactionController {
            public function insertCar()
            {

                try {
                    $this->beginTransaction();
                    ... (operations) ...
                    $this->commit();
                }
                catch (\PDOException $e) {
                    $this->rollback();
                }
            }
        }
        class PersonData extends TransactionController {
            public function insertPerson(  $person=null )
            {
                try {
                    $this->beginTransaction();
                    ... (operations) ...
                    $this->commit();
                }
                catch (\PDOException $e) {
                    $this->rollback();
                }
            }
        }

        class CarOwnerData extends TransactionController {
            public function createOwner()
            {
                try {
                    $this->beginTransaction();

                    $car = new CarData();
                    $car->insertCar();

                    $person = new PersonData();
                    $person->insertPerson();

                    ... (operations) ...

                    $this->commit();
                }
                catch (\PDOException $e) {
                    $this->rollback();
                }
            }
        }


        $sellCar = new CarOwnerData();
        $sellCar->createOwner();

UPDATE1: static attribute $warn_rollback_was_thrown was added to TransactionController in order to warn that the transaction was failed in some moment of the execution, but there wasn't rollbacked.

UPDATE2: When a transaction fails in some moment you can let the code still running to the end or STOP it definitively using forceRollback(), as an example of this see the following code:

<?php    // inside the class PersonData

    public function insertMultiplePersons( $arrayPersons )
    {
        try {
        $this->beginTransaction();
        if (is_array( $arrayPersons )) {
            foreach ($arrayPersons as $k => $person) {
                $this->insertPerson( $person ); 
                if ($this->transactionFailed()) {
                    $this->forceRollback();                    
                }
            }
        }
        $this->commit();
        }
        catch (\PDOException $e) {
            $this->rollback();
        }
    } ?>
Community
  • 1
  • 1
Sequoya
  • 433
  • 4
  • 16
  • 1
    Apart from a missing reset of `$nest` after `rollback`: nested transactions should support: `start trans 1, do something, start trans 2, rollback trans 2, optionally retry trans 2 or do something else, commit trans 1`, your code cannot support this (as mysql doesn't support it). Your code will only work as expected if you never rollback. You need to jump to the exception of trans 1 whenever any inner rollback happens (you can do this e.g. by reraising the exception), otherwise after a failed `$person->insertPerson();`, the code `... (operations) ...` will be executed without any transaction. – Solarflare Oct 01 '16 at 17:59
  • The idea is not to rollback or commit nested transactions, simply rollback or commit all at level 0, you need to understand the code better. For that reason I said: the transactions look like it is nested (php), but really in the mysql execution it isn't nested. Look quite. (there's no missing reset because there is a `$transaction_rollbacked = true` – Sequoya Oct 01 '16 at 20:34
  • You mean, after your edits it does :-P (before it really didn't, it would just call $parent->rollback, and actually, it still does not work). But at least now it's clear what you are trying to do. Your code looks better now. I think `$this->beginTransaction();` should be `$parent->beginTransaction();` (otherwise it won't ever call pdo, but do a loop). `TransactionController :: $nest--;` in `rollback` has to be before the test for `== 0` (otherwise it will never rollback), same for `commit`. And `forceRollback` should set a variable which you check in `rollback` and rethrow there ... – Solarflare Oct 02 '16 at 09:00
  • ... again if `$nest > 0` (otherwise it will only force one level down, unless you want that). And you might want to add a check for `$next < 0` somewhere. A simple forgotten `commit` before your first start will otherwise not throw an error, but for the rest of your code behave incorrectly (commit level 1). And you might still need a `reset` in `rollback` (and for safety in `commit` too), otherwise you cannot start a new transaction at level 0 - at least if you want it to be able to do that. And since rethrowing is not your default-behaviour, you need to check in `commit`, if ... – Solarflare Oct 02 '16 at 09:17
  • ... you are at `$nest = 0` and an rollback has happened, and then do a call to `rollback` (but don't do 2 `$nest--`), otherwise it will never rollback (and any code after that will be in an unknown state). And you may should consider using "force" as a default behaviour. Since you rollback at the end anyway, anything in the db you do after the first `rollback` will, best case, just cost time, and anything you do other than using the database (like displaying stuff) is using data in an unknown state (because something went wrong before). But that depends on what you are doing in your class. – Solarflare Oct 02 '16 at 09:19
  • that $this->beginTransaction is wrong... thanks, I fixed – Sequoya Oct 02 '16 at 16:55
  • @CristianCrishk, what's wrong with using [`SAVEPOINT`](http://dev.mysql.com/doc/refman/5.7/en/savepoint.html)? This **is** the way to properly nest transactions. – Vladimir Baranov Oct 04 '16 at 10:47
  • not necessary to use SAVEPOINT... I think this class is the right way to go, I'm waiting for an objection (if exists) – Sequoya Oct 05 '16 at 07:31
  • @VladimirBaranov his wording is very misleading. He don't need a real nested transaction actually. What he wants is to know whether to fire commit or not. I wish I had time to rewrite his question out of that mess that now it is. – Your Common Sense Oct 05 '16 at 08:03
  • @VladimirBaranov I want to reorganize this too, I don't have time now too, but I will – Sequoya Oct 06 '16 at 05:34
  • 1
    By using static class variables, you have just blocked yourself from having more than one db connection (e.g. to different databases) during any given request. – Bill Karwin Oct 06 '16 at 17:21
  • @BillKarwin what you are meaning may be that the `$nest` counter be static per db connection. – Sequoya Oct 17 '16 at 06:08
  • 1
    I'll post an answer below with a code example of what I mean. – Bill Karwin Oct 17 '16 at 15:03

3 Answers3

4

As pointed out by @YourCommonSense in the comments, you aren't actually implementing nested transactions.

I'm not sure I like the idea of calling commit() anywhere in my code and it not actually committing anything.

Your whole solution seems to be an attempt to mitigate a design decision to put transaction code in your insert functions and forget about it.

You could separate your insert operations from the transaction logic and wrap these function calls in a separate function which does the transactions:

public/private function insertPerson(  $person=null )
{
  ... (operations) ...
}

public function createPerson()
{
    $person = new Person();
    ... (setup person) ...

    $this->beginTransaction();
    try {
        $this->insertPerson($person);
        $this->commit();
    }
    catch (\PDOException $e) {
        $this->rollback();
    }
} 

If you are absolutely sure you need to always insert the person within a transaction, you could check you are within a transaction when it is called:

public/private function insertPerson($person=null)
{
  if (!$this->hasActiveTransaction){ // Needs implementing
     throw new Exception('Must be called within a transaction');
  }
  ...(operations)...
}

In our projects, all the saving logic is within the Models and all transaction logic is at the Controller level.

I'm assuming you are aware that for a single statement there is no need for a transaction, as these are atomic operations, and that your code represents more complex cases.

Arth
  • 12,789
  • 5
  • 37
  • 69
  • +1 I agree that managing transactions at the controller level is the only way that makes sense in an MVC architecture. – Bill Karwin Oct 06 '16 at 17:23
2

I made the comment above:

By using static class variables, you have just blocked yourself from having more than one db connection (e.g. to different databases) during any given request.

You seem to have a question about my comment:

@BillKarwin what you are meaning may be that the $nest counter be static per db connection. – Cristian Crishk

That's not how static works in PHP. A static class property is shared by all instances of the class. If one instance updates it, all other instances see the change.

<?php

class Foo {
 static $nest = 0;

 public function getNest() {
  return Foo::$nest;
 }

 public function setNest($newNest) {
  Foo::$nest = $newNest;
 }

}

$foo1 = new Foo();
$foo2 = new Foo();

echo "foo1::nest = " . $foo1->getNest() . "\n";
echo "foo2::nest = " . $foo2->getNest() . "\n";

$foo1->setNest(42);

echo "foo1::nest = " . $foo1->getNest() . "\n";
echo "foo2::nest = " . $foo2->getNest() . "\n";

Output:

foo1::nest = 0
foo2::nest = 0
foo1::nest = 42
foo2::nest = 42

This means your static $nest class property is the same value for all db connections in your application. You therefore can't have more than one db connection with your current design.

I don't even know why you made this property static. It doesn't need to be.

But I agree with the answer from @ICE that it's a folly to try to implement this kind of "nesting transaction" class. It doesn't work. Transactions are scoped to the db connection, not to objects. I've written about this before on Stack Overflow, way back in 2008. Read my answer to: How do detect that transaction has already been started?

Community
  • 1
  • 1
Bill Karwin
  • 538,548
  • 86
  • 673
  • 828
  • Your answer may be a propossal to solve that issue (it is clearly right), I believe if you have a static array with the connection and the counter it will not generates any conflict. Maybe that is the final code that considers this topic and answer this question. I'm working on it... thanks by your answer. – Sequoya Oct 18 '16 at 07:19
0

The logic of the insert in your code must change.

Unnecessary LOOP is the worst thing can affect the PERFORMANCE.

When you know you want to insert multiple persons and they can insert with one query. don't do it inside loop. Just do it with ONE query. This is the main syntax for multiple insert:

INSERT INTO table_name (col1,col2,col3,...)
VALUES (Value1,Value2,...), (Value1,Value2,...)

insertPerson method must deal with multiple persons. like this:

$this->insertPerson($arrayPersons);

and inside insertPerson method you must create VALUES just like i explained here before: How to insert multiple dynamic rows into the database

and after that, insertPerson method can insert one person or multiple persons in ONE QUERY.

Community
  • 1
  • 1
ICE
  • 1,667
  • 2
  • 21
  • 43
  • 2
    This doesn't answer the question. – Your Common Sense Oct 05 '16 at 04:40
  • You can't say the better solution can't answer the question while you want solve the problem with more complicated and low performance solution! Let me tell you a story. In a soap product line, there was a malfunction. some boxes go empty to the final line. the packing machine was so expensive and they couldn't afford to change or fix it, they hire some men to check those boxes before final line. a wise man put a fan right after packing machine rail and empty boxes flying out of the rail because the empty box was very light. Maybe a problem has 100 answer but choose the fast and reliable one. – ICE Oct 05 '16 at 07:58
  • 1
    the problem of a nested transaction is irrelevant to a problem of multiple inserts. **There could be other queries that needs to be included in a transaction.** – Your Common Sense Oct 05 '16 at 08:01
  • @ICE Your solution is the equivalent of the wise man speeding up the packing machine, meanwhile empty boxes are piling up in production. This 'answer' should be a comment. As for 'Unnecessary LOOP is the worst thing can affect the PERFORMANCE', not sure that's true. – Arth Oct 05 '16 at 10:54