MariaDB とCakePHP5でのトランザクションの実験(5)

CakePHP
この記事は約27分で読めます。

(本記事は書きかけで随時更新しております。)

ORMで保存した場合のトランザクションの挙動(続き)

save()時にSQLエラーが起きた時。

アソシエーションされた親子テーブルに save()で一度に保存した際、わざとSQLを起こしてみる。

OrderDetails のsub_order_nameフィールドをUNIQUEとしてわざとdupicate を起こしてみる。

※ PHPコード省略

[app_dev]$ bin/cake transaction multiTableSaveSqlError
トランザクション中? いいえ
Exception in orders 発生
SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry 'sub_name1' for key 'sub_order_name_uniq'
トランザクション中? いいえ
-------------------------------------------------------------------------------
Ordersのレコード数:0
OrderDetailsのレコード数:0
debug: connection= role= duration=0 rows=0 BEGIN
debug: connection= role=write duration=0.2 rows=1 INSERT INTO orders (order_name, created, modified) VALUES ('test1', '2024-12-01 00:00:00', '2024-12-01 00:00:00')
debug: connection= role=write duration=0.1 rows=1 INSERT INTO order_details (order_id, sub_order_name, created, modified) VALUES (1, 'sub_name1', '2024-12-01 00:00:00', '2024-12-01 00:00:00')
debug: connection= role=write duration=0 rows=0 INSERT INTO order_details (order_id, sub_order_name, created, modified) VALUES (1, 'sub_name1', '2024-12-01 00:00:00', '2024-12-01 00:00:00')
debug: connection= role= duration=0 rows=0 ROLLBACK

結果:SQL Exception が発生し、rollback された。

明示的なトランザクション内でsave()を繰り返す。

トランザクション内で、親テーブル1レコードをsave()し、別で子テーブル2レコードをsave()する。

    protected function multiTableSaveManualTransaction(): void
    {
        $this->io->info("明示的なトランザクション内で、別々のテーブルにsave()する。\n親テーブル1レコードをsave()し、別で子テーブル2レコードをsave()する。");
        $this->io->hr();
        /** @var Cake\Database\Connection */
        $connection = ConnectionManager::get('default');

        $ret = $connection->execute('truncate table orders;');
        $ret = $connection->execute('truncate table order_details;');

        $ordersTable = $this->fetchTable('Orders');
        $orderDetailsTable = $this->fetchTable('OrderDetails');

        $connection->begin();
        $this->dispInTransaction();

        try {

            $newOrder = $ordersTable->newEmptyEntity();
            $newOrder->order_name = 'test1';
            if ($ordersTable->save($newOrder)) {
                $this->io->success('Order save() 成功');
            } else {
                // validation , buildRule が失敗すると save()==false となる。
                debug($newOrder);
                $this->io->error('Order save() 失敗');
            }
            $newOrderDetail = $orderDetailsTable->newEmptyEntity();
            $newOrderDetail->order_id = $newOrder->id;
            $newOrderDetail->sub_order_name = 'sub_name1';
            if ($orderDetailsTable->save($newOrderDetail)) {
                $this->io->success('OrderDetail save() 成功');
            } else {
                // validation , buildRule が失敗すると save()==false となる。
                debug($newOrderDetail);
                $this->io->error('OrderDetail save() 失敗');
            }
            $newOrderDetail = $orderDetailsTable->newEmptyEntity();
            $newOrderDetail->order_id = $newOrder->id;
            $newOrderDetail->sub_order_name = 'sub_name2';
            if ($orderDetailsTable->save($newOrderDetail)) {
                $this->io->success('OrderDetail save() 成功');
            } else {
                // validation , buildRule が失敗すると save()==false となる。
                debug($newOrderDetail);
                $this->io->error('OrderDetail save() 失敗');
            }

            $this->dispInTransaction();

            $connection->commit();
            $this->io->success('commit 成功');

            $this->dispInTransaction();

        } catch (Exception $ex) {
            $this->io->error('Exception in orders 発生');
            $this->io->error($ex->getMessage());

            $this->dispInTransaction();
            $connection->rollback();
            $this->io->warning('rollback');
            $this->dispInTransaction();

        } finally {
        }
        $this->dispInTransaction();

        $this->io->hr();
        $orderRowCount = $ordersTable->find()->count();
        $this->io->warning('Ordersのレコード数:' . $orderRowCount);
        $orderDetailRowCount = $orderDetailsTable->find()->count();
        $this->io->warning('OrderDetailsのレコード数:' . $orderDetailRowCount);
    }
    protected function dispInTransaction() : void {
        /** @var Cake\Database\Connection */
        $connection = ConnectionManager::get('default');
        $status = $connection->inTransaction(); // トランザクションの状態をCakeは保存している。
        $this->io->info("トランザクション中? " . ($status ? 'はい' : 'いいえ'));
    }
debug: connection= role= duration=0 rows=0 BEGIN
debug: connection= role=write duration=0.3 rows=1 INSERT INTO orders (order_name, created, modified) VALUES ('test1', '2024-12-01 00:00:00', '2024-12-01 00:00:00')
debug: connection= role=write duration=0.2 rows=1 SELECT 1 AS existing FROM orders Orders WHERE Orders.id = 1 LIMIT 1
debug: connection= role=write duration=0.1 rows=1 INSERT INTO order_details (order_id, sub_order_name, created, modified) VALUES (1, 'sub_name1', '2024-12-01 00:00:00', '2024-12-01 00:00:00')
debug: connection= role=write duration=0 rows=1 SELECT 1 AS existing FROM orders Orders WHERE Orders.id = 1 LIMIT 1
debug: connection= role=write duration=0 rows=1 INSERT INTO order_details (order_id, sub_order_name, created, modified) VALUES (1, 'sub_name2', '2024-12-01 00:00:00', '2024-12-01 00:00:00')
debug: connection= role= duration=0 rows=0 COMMIT

結果:明示的なトランザクション内でsave()すると、多重のトランザクションにならない。(save()メソッド単体の動きと異なる。)
なお、上記の例ではOrderDetailsTable.php 内のbuildRules()の定義があるので、いちいちselectで確認していることがわかる。

明示的なトランザクション内でsave()を繰り返す。savepointを明示的に有効にする。

トランザクション内で、親テーブル1レコードをsave()し、別で子テーブル2レコードをsave()する。
明示的にenableSavePoints(true)を実行する。

    protected function multiTableSaveManualTransactionEnableSavepoint(): void
    {
        $this->io->info("savepoint利用を明示的に有効。明示的なトランザクション内で、別々のテーブルにsave()する。\n親テーブル1レコードをsave()し、別で子テーブル2レコードをsave()する。");
        $this->io->hr();
        /** @var Cake\Database\Connection */
        $connection = ConnectionManager::get('default');
        $this->dispSavepointStatus();
        $connection->enableSavePoints(true); // savepoint 有効
        $this->dispSavepointStatus();

        $ret = $connection->execute('truncate table orders;');
        $ret = $connection->execute('truncate table order_details;');

        $ordersTable = $this->fetchTable('Orders');
        $orderDetailsTable = $this->fetchTable('OrderDetails');

        $connection->begin();
        $this->dispInTransaction();

        try {

            $newOrder = $ordersTable->newEmptyEntity();
            $newOrder->order_name = 'test1';
            if ($ordersTable->save($newOrder)) {
                $this->io->success('Order save() 成功');
            } else {
                // validation , buildRule が失敗すると save()==false となる。
                debug($newOrder);
                $this->io->error('Order save() 失敗');
            }
            $newOrderDetail = $orderDetailsTable->newEmptyEntity();
            $newOrderDetail->order_id = $newOrder->id;
            $newOrderDetail->sub_order_name = 'sub_name1';
            if ($orderDetailsTable->save($newOrderDetail)) {
                $this->io->success('OrderDetail save() 成功');
            } else {
                // validation , buildRule が失敗すると save()==false となる。
                debug($newOrderDetail);
                $this->io->error('OrderDetail save() 失敗');
            }

            $newOrderDetail = $orderDetailsTable->newEntity(['order_id'=>$newOrder->id , 'sub_order_name'=>'test']);
            // $newOrderDetail->order_id = $newOrder->id;
            // $newOrderDetail->sub_order_name = '123456';
//            debug($newOrderDetail);exit();
            if ($orderDetailsTable->save($newOrderDetail)) {
                $this->io->success('OrderDetail save() 成功');
            } else {
                // validation , buildRule が失敗すると save()==false となる。
                debug($newOrderDetail);
                $this->io->error('OrderDetail save() 失敗');
            }

            $this->dispInTransaction();

            $connection->commit();
            $this->io->success('commit 成功');

            $this->dispInTransaction();

        } catch (Exception $ex) {
            $this->io->error('Exception in orders 発生');
            $this->io->error($ex->getMessage());

            $this->dispInTransaction();
            $connection->rollback();
            $this->io->warning('rollback');
            $this->dispInTransaction();

        } finally {
        }
        $this->dispInTransaction();

        $this->io->hr();
        $orderRowCount = $ordersTable->find()->count();
        $this->io->warning('Ordersのレコード数:' . $orderRowCount);
        $orderDetailRowCount = $orderDetailsTable->find()->count();
        $this->io->warning('OrderDetailsのレコード数:' . $orderDetailRowCount);
    }
debug: connection= role=write duration=6.3 rows=0 truncate table orders;
debug: connection= role=write duration=3.4 rows=0 truncate table order_details;
debug: connection= role= duration=0 rows=0 BEGIN
debug: connection= role=write duration=0 rows=0 SAVEPOINT LEVEL1
debug: connection= role=write duration=0.2 rows=1 INSERT INTO orders (order_name, created, modified) VALUES ('test1', '2024-12-01 00:00:00', '2024-12-01 00:00:00')
debug: connection= role=write duration=0 rows=0 RELEASE SAVEPOINT LEVEL1
debug: connection= role=write duration=0 rows=0 SAVEPOINT LEVEL1
debug: connection= role=write duration=0.2 rows=1 SELECT 1 AS existing FROM orders Orders WHERE Orders.id = 1 LIMIT 1
debug: connection= role=write duration=0.1 rows=1 INSERT INTO order_details (order_id, sub_order_name, created, modified) VALUES (1, 'sub_name1', '2024-12-01 00:00:00', '2024-12-01 00:00:00')
debug: connection= role=write duration=0 rows=0 RELEASE SAVEPOINT LEVEL1
debug: connection= role=write duration=0 rows=0 SAVEPOINT LEVEL1
debug: connection= role=write duration=0 rows=1 SELECT 1 AS existing FROM orders Orders WHERE Orders.id = 1 LIMIT 1
debug: connection= role=write duration=0 rows=1 INSERT INTO order_details (order_id, sub_order_name, created, modified) VALUES (1, 'test', '2024-12-01 00:00:00', '2024-12-01 00:00:00')
debug: connection= role=write duration=0 rows=0 RELEASE SAVEPOINT LEVEL1
debug: connection= role= duration=0 rows=0 COMMIT
debug: connection= role=write duration=0.1 rows=1 SELECT (COUNT(*)) AS count FROM orders Orders
debug: connection= role=write duration=2.2 rows=1 SELECT (COUNT(*)) AS count FROM order_details OrderDetails

結果:明示的にenableSavePoints(true) を実行すると、save()メソッド内もトランザクションとなる。
予期しない動きを防ぐため、セーブポイントは使うのをデフォルトにする方が良いと思われる。

明示的なトランザクション内でsave()を繰り返す。子テーブルの2回目のデータをわざとnullにし、validation errorにしてみる。

CakePHPの仕様で、newEmptyEntity()と、newEntity()ではvalidationの動きが異なる。

    protected function multiTableSaveManualTransaction(): void
    {
        $this->io->info("明示的なトランザクション内で、別々のテーブルにsave()する。\n親テーブル1レコードをsave()し、別で子テーブル2レコードをsave()する。");
        $this->io->hr();
        /** @var Cake\Database\Connection */
        $connection = ConnectionManager::get('default');

        $ret = $connection->execute('truncate table orders;');
        $ret = $connection->execute('truncate table order_details;');

        $ordersTable = $this->fetchTable('Orders');
        $orderDetailsTable = $this->fetchTable('OrderDetails');

        $connection->begin();
        $this->dispInTransaction();

        try {

            $newOrder = $ordersTable->newEmptyEntity();
            $newOrder->order_name = 'test1';
            if ($ordersTable->save($newOrder)) {
                $this->io->success('Order save() 成功');
            } else {
                // validation , buildRule が失敗すると save()==false となる。
                debug($newOrder);
                $this->io->error('Order save() 失敗');
            }
            $newOrderDetail = $orderDetailsTable->newEmptyEntity();
            $newOrderDetail->order_id = $newOrder->id;
            $newOrderDetail->sub_order_name = 'sub_name1';
            if ($orderDetailsTable->save($newOrderDetail)) {
                $this->io->success('OrderDetail save() 成功');
            } else {
                // validation , buildRule が失敗すると save()==false となる。
                debug($newOrderDetail);
                $this->io->error('OrderDetail save() 失敗');
            }
            $newOrderDetail = $orderDetailsTable->newEmptyEntity();
            $newOrderDetail->order_id = $newOrder->id;
            $newOrderDetail->sub_order_name = 'sub_name2';
            if ($orderDetailsTable->save($newOrderDetail)) {
                $this->io->success('OrderDetail save() 成功');
            } else {
                // validation , buildRule が失敗すると save()==false となる。
                debug($newOrderDetail);
                $this->io->error('OrderDetail save() 失敗');
            }

            $this->dispInTransaction();

            $connection->commit();
            $this->io->success('commit 成功');

            $this->dispInTransaction();

        } catch (Exception $ex) {
            $this->io->error('Exception in orders 発生');
            $this->io->error($ex->getMessage());

            $this->dispInTransaction();
            $connection->rollback();
            $this->io->warning('rollback');
            $this->dispInTransaction();

        } finally {
        }
        $this->dispInTransaction();

        $this->io->hr();
        $orderRowCount = $ordersTable->find()->count();
        $this->io->warning('Ordersのレコード数:' . $orderRowCount);
        $orderDetailRowCount = $orderDetailsTable->find()->count();
        $this->io->warning('OrderDetailsのレコード数:' . $orderDetailRowCount);
    }
    protected function dispInTransaction() : void {
        /** @var Cake\Database\Connection */
        $connection = ConnectionManager::get('default');
        $status = $connection->inTransaction(); // トランザクションの状態をCakeは保存している。
        $this->io->info("トランザクション中? " . ($status ? 'はい' : 'いいえ'));
    }
debug: connection= role= duration=0 rows=0 BEGIN
debug: connection= role=write duration=0.3 rows=1 INSERT INTO orders (order_name, created, modified) VALUES ('test1', '2024-12-01 00:00:00', '2024-12-01 00:00:00')
debug: connection= role=write duration=0.2 rows=1 SELECT 1 AS existing FROM orders Orders WHERE Orders.id = 1 LIMIT 1
debug: connection= role=write duration=0.1 rows=1 INSERT INTO order_details (order_id, sub_order_name, created, modified) VALUES (1, 'sub_name1', '2024-12-01 00:00:00', '2024-12-01 00:00:00')
debug: connection= role= duration=0 rows=0 COMMIT

実行結果

トランザクション中? はい
Order save() 成功
OrderDetail save() 成功
APP/Command/TransactionCommand.php
########## DEBUG ##########
object(App\Model\Entity\OrderDetail) id:0 {
  'order_id' => (int) 1
  '[new]' => true
  '[accessible]' => [
    'order_id' => true,
    'sub_order_name' => true,
    'created' => true,
    'modified' => true,
    'order' => true,
    'order2_details' => true
  ]
  '[dirty]' => [
    'order_id' => true
  ]
  '[original]' => []
  '[originalFields]' => [
    (int) 0 => 'order_id'
  ]
  '[virtual]' => []
  '[hasErrors]' => true
  '[errors]' => [
    'sub_order_name' => [
      '_empty' => 'This field cannot be left empty'
    ]
  ]
  '[invalid]' => [
    'sub_order_name' => null
  ]
  '[repository]' => 'OrderDetails'
}
###########################
OrderDetail save() 失敗
トランザクション中? はい
commit 成功
トランザクション中? いいえ
トランザクション中? いいえ
-------------------------------------------------------------------------------
Ordersのレコード数:1
OrderDetailsのレコード数:1

結果:最後のsaveが、validationエラーによってCakePHPによってスキップされてしまったが他は保存された。必要なら、明示的にrollback()しなければならない。SQLExceptionをThrowするのが良いかもしれない。もちろん、validationエラーが起きていない行を保存したいなら、このままでも良い。buildRules エラーも同様となる。

まとめ に続く。

タイトルとURLをコピーしました