(本記事は書きかけで随時更新しております。)
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 エラーも同様となる。