From 146ecc6f8af388881f70b298a9a1f0904a047650 Mon Sep 17 00:00:00 2001 From: Crovitche-1623 Date: Thu, 19 Feb 2026 14:39:06 +0100 Subject: [PATCH] Add PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS attribute When autocommit is off, MySQL's SERVER_STATUS_IN_TRANS flag is cleared after COMMIT/ROLLBACK even though the session is still logically in a transaction. This causes commit() and rollBack() to throw "There is no active transaction" in the gap before the next SQL statement. This new opt-in attribute makes commit() and rollBack() return true (no-op) instead of throwing when autocommit is off and no server-level transaction is active. This eliminates the need for the redundant BEGIN that frameworks send after every COMMIT to work around this gap. --- ext/pdo/pdo_dbh.c | 18 +++++ ext/pdo/pdo_dbh.stub.php | 2 + ext/pdo/pdo_dbh_arginfo.h | 8 +- ext/pdo/php_pdo_driver.h | 5 ++ ...sql_autocommit_aware_begintransaction.phpt | 73 +++++++++++++++++++ .../pdo_mysql_autocommit_aware_commit.phpt | 65 +++++++++++++++++ ...do_mysql_autocommit_aware_default_off.phpt | 62 ++++++++++++++++ ..._mysql_autocommit_aware_intransaction.phpt | 63 ++++++++++++++++ .../pdo_mysql_autocommit_aware_rollback.phpt | 61 ++++++++++++++++ ...l_autocommit_aware_with_autocommit_on.phpt | 50 +++++++++++++ 10 files changed, 406 insertions(+), 1 deletion(-) create mode 100644 ext/pdo_mysql/tests/pdo_mysql_autocommit_aware_begintransaction.phpt create mode 100644 ext/pdo_mysql/tests/pdo_mysql_autocommit_aware_commit.phpt create mode 100644 ext/pdo_mysql/tests/pdo_mysql_autocommit_aware_default_off.phpt create mode 100644 ext/pdo_mysql/tests/pdo_mysql_autocommit_aware_intransaction.phpt create mode 100644 ext/pdo_mysql/tests/pdo_mysql_autocommit_aware_rollback.phpt create mode 100644 ext/pdo_mysql/tests/pdo_mysql_autocommit_aware_with_autocommit_on.phpt diff --git a/ext/pdo/pdo_dbh.c b/ext/pdo/pdo_dbh.c index 34f19e364faa7..156337313d7fb 100644 --- a/ext/pdo/pdo_dbh.c +++ b/ext/pdo/pdo_dbh.c @@ -726,6 +726,10 @@ PHP_METHOD(PDO, commit) PDO_CONSTRUCT_CHECK; if (!pdo_is_in_transaction(dbh)) { + /* autocommit off: always logically in a transaction, no-op is safe */ + if (dbh->autocommit_aware_txn && !dbh->auto_commit) { + RETURN_TRUE; + } zend_throw_exception_ex(php_pdo_get_exception(), 0, "There is no active transaction"); RETURN_THROWS(); } @@ -750,6 +754,10 @@ PHP_METHOD(PDO, rollBack) PDO_CONSTRUCT_CHECK; if (!pdo_is_in_transaction(dbh)) { + /* see commit() */ + if (dbh->autocommit_aware_txn && !dbh->auto_commit) { + RETURN_TRUE; + } zend_throw_exception_ex(php_pdo_get_exception(), 0, "There is no active transaction"); RETURN_THROWS(); } @@ -943,6 +951,13 @@ static bool pdo_dbh_attribute_set(pdo_dbh_t *dbh, zend_long attr, zval *value, u } return true; } + case PDO_ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS: + if (!pdo_get_bool_param(&bval, value)) { + return false; + } + dbh->autocommit_aware_txn = bval; + return true; + /* Don't throw a ValueError as the attribute might be a driver specific one */ default:; } @@ -1030,6 +1045,9 @@ PHP_METHOD(PDO, getAttribute) case PDO_ATTR_STRINGIFY_FETCHES: RETURN_BOOL(dbh->stringify); + case PDO_ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS: + RETURN_BOOL(dbh->autocommit_aware_txn); + default: break; } diff --git a/ext/pdo/pdo_dbh.stub.php b/ext/pdo/pdo_dbh.stub.php index 7fcec0226b0ba..1af84ba867571 100644 --- a/ext/pdo/pdo_dbh.stub.php +++ b/ext/pdo/pdo_dbh.stub.php @@ -121,6 +121,8 @@ class PDO public const int ATTR_DEFAULT_FETCH_MODE = UNKNOWN; /** @cvalue LONG_CONST(PDO_ATTR_DEFAULT_STR_PARAM) */ public const int ATTR_DEFAULT_STR_PARAM = UNKNOWN; + /** @cvalue LONG_CONST(PDO_ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS) */ + public const int ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS = UNKNOWN; /** @cvalue LONG_CONST(PDO_ERRMODE_SILENT) */ public const int ERRMODE_SILENT = UNKNOWN; diff --git a/ext/pdo/pdo_dbh_arginfo.h b/ext/pdo/pdo_dbh_arginfo.h index 90da5123a4874..6fa54f447ba9b 100644 --- a/ext/pdo/pdo_dbh_arginfo.h +++ b/ext/pdo/pdo_dbh_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit pdo_dbh.stub.php instead. - * Stub hash: 006be61b2c519e7d9ca997a7f12135eb3e0f3500 */ + * Stub hash: fad3c79ca52abcd6e6c867d9f0563ed90fdca39f */ ZEND_BEGIN_ARG_INFO_EX(arginfo_class_PDO___construct, 0, 0, 1) ZEND_ARG_TYPE_INFO(0, dsn, IS_STRING, 0) @@ -451,6 +451,12 @@ static zend_class_entry *register_class_PDO(void) zend_declare_typed_class_constant(class_entry, const_ATTR_DEFAULT_STR_PARAM_name, &const_ATTR_DEFAULT_STR_PARAM_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_LONG)); zend_string_release_ex(const_ATTR_DEFAULT_STR_PARAM_name, true); + zval const_ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS_value; + ZVAL_LONG(&const_ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS_value, LONG_CONST(PDO_ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS)); + zend_string *const_ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS_name = zend_string_init_interned("ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS", sizeof("ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS") - 1, true); + zend_declare_typed_class_constant(class_entry, const_ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS_name, &const_ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_LONG)); + zend_string_release_ex(const_ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS_name, true); + zval const_ERRMODE_SILENT_value; ZVAL_LONG(&const_ERRMODE_SILENT_value, LONG_CONST(PDO_ERRMODE_SILENT)); zend_string *const_ERRMODE_SILENT_name = zend_string_init_interned("ERRMODE_SILENT", sizeof("ERRMODE_SILENT") - 1, true); diff --git a/ext/pdo/php_pdo_driver.h b/ext/pdo/php_pdo_driver.h index 9c5986ff8bce8..75abebcdefc12 100644 --- a/ext/pdo/php_pdo_driver.h +++ b/ext/pdo/php_pdo_driver.h @@ -123,6 +123,7 @@ enum pdo_attribute_type { PDO_ATTR_DEFAULT_FETCH_MODE, /* Set the default fetch mode */ PDO_ATTR_EMULATE_PREPARES, /* use query emulation rather than native */ PDO_ATTR_DEFAULT_STR_PARAM, /* set the default string parameter type (see the PDO::PARAM_STR_* magic flags) */ + PDO_ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS, /* suppress exception from commit()/rollBack() when autocommit is off and no transaction is active */ /* this defines the start of the range for driver specific options. * Drivers should define their own attribute constants beginning with this @@ -457,6 +458,10 @@ struct _pdo_dbh_t { /* if true, commit or rollBack is allowed to be called */ bool in_txn:1; + /* if true, commit()/rollBack() return true instead of throwing when + * autocommit is off and no transaction is active */ + bool autocommit_aware_txn:1; + /* when set, convert int/floats to strings */ bool stringify:1; diff --git a/ext/pdo_mysql/tests/pdo_mysql_autocommit_aware_begintransaction.phpt b/ext/pdo_mysql/tests/pdo_mysql_autocommit_aware_begintransaction.phpt new file mode 100644 index 0000000000000..1c7650c1d1c01 --- /dev/null +++ b/ext/pdo_mysql/tests/pdo_mysql_autocommit_aware_begintransaction.phpt @@ -0,0 +1,73 @@ +--TEST-- +PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS - beginTransaction() still works in gap +--DESCRIPTION-- +beginTransaction() must remain fully functional. In the gap after COMMIT +(when SERVER_STATUS_IN_TRANS is cleared), beginTransaction() should succeed +and start an explicit transaction. +--EXTENSIONS-- +pdo_mysql +--SKIPIF-- + +--FILE-- +setAttribute(PDO::ATTR_AUTOCOMMIT, false); +$db->setAttribute(PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS, true); +$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +$db->exec('DROP TABLE IF EXISTS test_autocommit_aware_bt'); +$db->exec('CREATE TABLE test_autocommit_aware_bt (id INT PRIMARY KEY) ENGINE=InnoDB'); + +// 1. Commit to enter the gap +$db->exec('INSERT INTO test_autocommit_aware_bt VALUES (1)'); +$db->commit(); +var_dump($db->inTransaction()); // false — in the gap + +// 2. beginTransaction() works in the gap +$db->beginTransaction(); +var_dump($db->inTransaction()); // true — explicit transaction started + +// 3. Operations within explicit transaction +$db->exec('INSERT INTO test_autocommit_aware_bt VALUES (2)'); +$db->commit(); + +// 4. beginTransaction() still throws when already in a transaction +$db->beginTransaction(); +try { + $db->beginTransaction(); + echo "ERROR: should have thrown\n"; +} catch (PDOException $e) { + echo $e->getMessage() . "\n"; +} +$db->rollBack(); + +// 5. Verify data +$stmt = $db->query('SELECT id FROM test_autocommit_aware_bt ORDER BY id'); +var_dump($stmt->fetchAll(PDO::FETCH_COLUMN)); + +$db->exec('DROP TABLE test_autocommit_aware_bt'); +?> +--CLEAN-- +query('DROP TABLE IF EXISTS test_autocommit_aware_bt'); +?> +--EXPECT-- +bool(false) +bool(true) +There is already an active transaction +array(2) { + [0]=> + int(1) + [1]=> + int(2) +} diff --git a/ext/pdo_mysql/tests/pdo_mysql_autocommit_aware_commit.phpt b/ext/pdo_mysql/tests/pdo_mysql_autocommit_aware_commit.phpt new file mode 100644 index 0000000000000..7ef1ae30ee67b --- /dev/null +++ b/ext/pdo_mysql/tests/pdo_mysql_autocommit_aware_commit.phpt @@ -0,0 +1,65 @@ +--TEST-- +PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS - commit() in gap does not throw +--EXTENSIONS-- +pdo_mysql +--SKIPIF-- + +--FILE-- +setAttribute(PDO::ATTR_AUTOCOMMIT, false); +$db->setAttribute(PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS, true); +$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +$db->exec('DROP TABLE IF EXISTS test_autocommit_aware'); +$db->exec('CREATE TABLE test_autocommit_aware (id INT PRIMARY KEY) ENGINE=InnoDB'); + +// 1. Normal commit works +$db->exec('INSERT INTO test_autocommit_aware VALUES (1)'); +var_dump($db->inTransaction()); // true — SERVER_STATUS_IN_TRANS set +$result = $db->commit(); +var_dump($result); // true +var_dump($db->inTransaction()); // false — flag cleared by COMMIT + +// 2. Second commit in the gap: should return true silently (no-op) +$result = $db->commit(); +var_dump($result); // true — no exception thrown + +// 3. Subsequent operations still work +$db->exec('INSERT INTO test_autocommit_aware VALUES (2)'); +var_dump($db->inTransaction()); // true +$db->commit(); + +// 4. Verify both rows were persisted +$stmt = $db->query('SELECT id FROM test_autocommit_aware ORDER BY id'); +$rows = $stmt->fetchAll(PDO::FETCH_COLUMN); +var_dump($rows); + +$db->exec('DROP TABLE test_autocommit_aware'); +?> +--CLEAN-- +query('DROP TABLE IF EXISTS test_autocommit_aware'); +?> +--EXPECT-- +bool(true) +bool(true) +bool(false) +bool(true) +bool(true) +array(2) { + [0]=> + int(1) + [1]=> + int(2) +} diff --git a/ext/pdo_mysql/tests/pdo_mysql_autocommit_aware_default_off.phpt b/ext/pdo_mysql/tests/pdo_mysql_autocommit_aware_default_off.phpt new file mode 100644 index 0000000000000..f23d7e54da5fc --- /dev/null +++ b/ext/pdo_mysql/tests/pdo_mysql_autocommit_aware_default_off.phpt @@ -0,0 +1,62 @@ +--TEST-- +PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS - defaults to off (BC preserved) +--DESCRIPTION-- +Without explicitly enabling the attribute, the current behavior must be +preserved: commit() and rollBack() throw when there is no active transaction. +--EXTENSIONS-- +pdo_mysql +--SKIPIF-- + +--FILE-- +setAttribute(PDO::ATTR_AUTOCOMMIT, false); +// NOT setting ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS — defaults to false +$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +$db->exec('DROP TABLE IF EXISTS test_autocommit_aware_do'); +$db->exec('CREATE TABLE test_autocommit_aware_do (id INT) ENGINE=InnoDB'); + +// Execute a DML and commit to enter the gap +$db->exec('INSERT INTO test_autocommit_aware_do VALUES (1)'); +$db->commit(); + +// commit() in the gap should throw (current behavior preserved) +try { + $db->commit(); + echo "ERROR: should have thrown\n"; +} catch (PDOException $e) { + echo "commit: " . $e->getMessage() . "\n"; +} + +// rollBack() in the gap should throw (current behavior preserved) +try { + $db->rollBack(); + echo "ERROR: should have thrown\n"; +} catch (PDOException $e) { + echo "rollBack: " . $e->getMessage() . "\n"; +} + +// Verify attribute defaults to false +var_dump($db->getAttribute(PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS)); + +$db->exec('DROP TABLE test_autocommit_aware_do'); +?> +--CLEAN-- +query('DROP TABLE IF EXISTS test_autocommit_aware_do'); +?> +--EXPECT-- +commit: There is no active transaction +rollBack: There is no active transaction +bool(false) diff --git a/ext/pdo_mysql/tests/pdo_mysql_autocommit_aware_intransaction.phpt b/ext/pdo_mysql/tests/pdo_mysql_autocommit_aware_intransaction.phpt new file mode 100644 index 0000000000000..1331fbb89d175 --- /dev/null +++ b/ext/pdo_mysql/tests/pdo_mysql_autocommit_aware_intransaction.phpt @@ -0,0 +1,63 @@ +--TEST-- +PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS - inTransaction() behavior unchanged +--DESCRIPTION-- +The attribute must NOT change inTransaction() semantics. It should still +reflect the actual SERVER_STATUS_IN_TRANS flag from MySQL. +--EXTENSIONS-- +pdo_mysql +--SKIPIF-- + +--FILE-- +setAttribute(PDO::ATTR_AUTOCOMMIT, false); +$db->setAttribute(PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS, true); +$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +$db->exec('DROP TABLE IF EXISTS test_autocommit_aware_it'); +$db->exec('CREATE TABLE test_autocommit_aware_it (id INT) ENGINE=InnoDB'); + +// 1. After DDL (implicit commit clears flag) — flag not set +var_dump($db->inTransaction()); // false + +// 2. After a DML statement — flag set by implicit transaction +$db->exec('INSERT INTO test_autocommit_aware_it VALUES (1)'); +var_dump($db->inTransaction()); // true + +// 3. After COMMIT — flag cleared +$db->commit(); +var_dump($db->inTransaction()); // false — unchanged behavior + +// 4. After explicit BEGIN — flag set +$db->beginTransaction(); +var_dump($db->inTransaction()); // true + +// 5. After explicit COMMIT — flag cleared +$db->commit(); +var_dump($db->inTransaction()); // false + +echo "inTransaction() behavior is unchanged\n"; + +$db->exec('DROP TABLE test_autocommit_aware_it'); +?> +--CLEAN-- +query('DROP TABLE IF EXISTS test_autocommit_aware_it'); +?> +--EXPECT-- +bool(false) +bool(true) +bool(false) +bool(true) +bool(false) +inTransaction() behavior is unchanged diff --git a/ext/pdo_mysql/tests/pdo_mysql_autocommit_aware_rollback.phpt b/ext/pdo_mysql/tests/pdo_mysql_autocommit_aware_rollback.phpt new file mode 100644 index 0000000000000..e26096d2f687b --- /dev/null +++ b/ext/pdo_mysql/tests/pdo_mysql_autocommit_aware_rollback.phpt @@ -0,0 +1,61 @@ +--TEST-- +PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS - rollBack() in gap does not throw +--EXTENSIONS-- +pdo_mysql +--SKIPIF-- + +--FILE-- +setAttribute(PDO::ATTR_AUTOCOMMIT, false); +$db->setAttribute(PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS, true); +$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +$db->exec('DROP TABLE IF EXISTS test_autocommit_aware_rb'); +$db->exec('CREATE TABLE test_autocommit_aware_rb (id INT PRIMARY KEY) ENGINE=InnoDB'); + +// 1. Normal rollback works +$db->exec('INSERT INTO test_autocommit_aware_rb VALUES (1)'); +var_dump($db->inTransaction()); // true +$db->rollBack(); +var_dump($db->inTransaction()); // false + +// 2. Second rollBack in the gap: should return true silently (no-op) +$result = $db->rollBack(); +var_dump($result); // true — no exception + +// 3. Verify the insert was rolled back +$stmt = $db->query('SELECT COUNT(*) FROM test_autocommit_aware_rb'); +var_dump($stmt->fetchColumn()); // 0 + +// 4. Subsequent operations still work after gap rollback +$db->exec('INSERT INTO test_autocommit_aware_rb VALUES (2)'); +$db->commit(); +$stmt = $db->query('SELECT id FROM test_autocommit_aware_rb'); +var_dump($stmt->fetchAll(PDO::FETCH_COLUMN)); + +$db->exec('DROP TABLE test_autocommit_aware_rb'); +?> +--CLEAN-- +query('DROP TABLE IF EXISTS test_autocommit_aware_rb'); +?> +--EXPECT-- +bool(true) +bool(false) +bool(true) +int(0) +array(1) { + [0]=> + int(2) +} diff --git a/ext/pdo_mysql/tests/pdo_mysql_autocommit_aware_with_autocommit_on.phpt b/ext/pdo_mysql/tests/pdo_mysql_autocommit_aware_with_autocommit_on.phpt new file mode 100644 index 0000000000000..da71ded6a9330 --- /dev/null +++ b/ext/pdo_mysql/tests/pdo_mysql_autocommit_aware_with_autocommit_on.phpt @@ -0,0 +1,50 @@ +--TEST-- +PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS - no effect when autocommit is on +--DESCRIPTION-- +When autocommit is ON (default), the attribute must have no effect. +commit() and rollBack() should throw as usual when no transaction is active. +The attribute only changes behavior when BOTH autocommit is off AND the +attribute is enabled. +--EXTENSIONS-- +pdo_mysql +--SKIPIF-- + +--FILE-- +setAttribute(PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS, true); +$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +// With autocommit on, there is no implicit transaction — commit should throw +try { + $db->commit(); + echo "ERROR: should have thrown\n"; +} catch (PDOException $e) { + echo "commit: " . $e->getMessage() . "\n"; +} + +try { + $db->rollBack(); + echo "ERROR: should have thrown\n"; +} catch (PDOException $e) { + echo "rollBack: " . $e->getMessage() . "\n"; +} + +// Normal transaction flow still works +$db->beginTransaction(); +$db->commit(); +echo "Normal transaction flow OK\n"; +?> +--EXPECT-- +commit: There is no active transaction +rollBack: There is no active transaction +Normal transaction flow OK