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

