Skip to content

Commit 146ecc6

Browse files
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.
1 parent fa2caa0 commit 146ecc6

10 files changed

+406
-1
lines changed

ext/pdo/pdo_dbh.c

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,6 +726,10 @@ PHP_METHOD(PDO, commit)
726726
PDO_CONSTRUCT_CHECK;
727727

728728
if (!pdo_is_in_transaction(dbh)) {
729+
/* autocommit off: always logically in a transaction, no-op is safe */
730+
if (dbh->autocommit_aware_txn && !dbh->auto_commit) {
731+
RETURN_TRUE;
732+
}
729733
zend_throw_exception_ex(php_pdo_get_exception(), 0, "There is no active transaction");
730734
RETURN_THROWS();
731735
}
@@ -750,6 +754,10 @@ PHP_METHOD(PDO, rollBack)
750754
PDO_CONSTRUCT_CHECK;
751755

752756
if (!pdo_is_in_transaction(dbh)) {
757+
/* see commit() */
758+
if (dbh->autocommit_aware_txn && !dbh->auto_commit) {
759+
RETURN_TRUE;
760+
}
753761
zend_throw_exception_ex(php_pdo_get_exception(), 0, "There is no active transaction");
754762
RETURN_THROWS();
755763
}
@@ -943,6 +951,13 @@ static bool pdo_dbh_attribute_set(pdo_dbh_t *dbh, zend_long attr, zval *value, u
943951
}
944952
return true;
945953
}
954+
case PDO_ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS:
955+
if (!pdo_get_bool_param(&bval, value)) {
956+
return false;
957+
}
958+
dbh->autocommit_aware_txn = bval;
959+
return true;
960+
946961
/* Don't throw a ValueError as the attribute might be a driver specific one */
947962
default:;
948963
}
@@ -1030,6 +1045,9 @@ PHP_METHOD(PDO, getAttribute)
10301045
case PDO_ATTR_STRINGIFY_FETCHES:
10311046
RETURN_BOOL(dbh->stringify);
10321047

1048+
case PDO_ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS:
1049+
RETURN_BOOL(dbh->autocommit_aware_txn);
1050+
10331051
default:
10341052
break;
10351053
}

ext/pdo/pdo_dbh.stub.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ class PDO
121121
public const int ATTR_DEFAULT_FETCH_MODE = UNKNOWN;
122122
/** @cvalue LONG_CONST(PDO_ATTR_DEFAULT_STR_PARAM) */
123123
public const int ATTR_DEFAULT_STR_PARAM = UNKNOWN;
124+
/** @cvalue LONG_CONST(PDO_ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS) */
125+
public const int ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS = UNKNOWN;
124126

125127
/** @cvalue LONG_CONST(PDO_ERRMODE_SILENT) */
126128
public const int ERRMODE_SILENT = UNKNOWN;

ext/pdo/pdo_dbh_arginfo.h

Lines changed: 7 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ext/pdo/php_pdo_driver.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ enum pdo_attribute_type {
123123
PDO_ATTR_DEFAULT_FETCH_MODE, /* Set the default fetch mode */
124124
PDO_ATTR_EMULATE_PREPARES, /* use query emulation rather than native */
125125
PDO_ATTR_DEFAULT_STR_PARAM, /* set the default string parameter type (see the PDO::PARAM_STR_* magic flags) */
126+
PDO_ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS, /* suppress exception from commit()/rollBack() when autocommit is off and no transaction is active */
126127

127128
/* this defines the start of the range for driver specific options.
128129
* Drivers should define their own attribute constants beginning with this
@@ -457,6 +458,10 @@ struct _pdo_dbh_t {
457458
/* if true, commit or rollBack is allowed to be called */
458459
bool in_txn:1;
459460

461+
/* if true, commit()/rollBack() return true instead of throwing when
462+
* autocommit is off and no transaction is active */
463+
bool autocommit_aware_txn:1;
464+
460465
/* when set, convert int/floats to strings */
461466
bool stringify:1;
462467

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
--TEST--
2+
PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS - beginTransaction() still works in gap
3+
--DESCRIPTION--
4+
beginTransaction() must remain fully functional. In the gap after COMMIT
5+
(when SERVER_STATUS_IN_TRANS is cleared), beginTransaction() should succeed
6+
and start an explicit transaction.
7+
--EXTENSIONS--
8+
pdo_mysql
9+
--SKIPIF--
10+
<?php
11+
require_once __DIR__ . '/inc/mysql_pdo_test.inc';
12+
MySQLPDOTest::skip();
13+
if (!defined('PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS')) {
14+
die('skip PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS not available');
15+
}
16+
?>
17+
--FILE--
18+
<?php
19+
require_once __DIR__ . '/inc/mysql_pdo_test.inc';
20+
21+
$db = MySQLPDOTest::factory();
22+
$db->setAttribute(PDO::ATTR_AUTOCOMMIT, false);
23+
$db->setAttribute(PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS, true);
24+
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
25+
26+
$db->exec('DROP TABLE IF EXISTS test_autocommit_aware_bt');
27+
$db->exec('CREATE TABLE test_autocommit_aware_bt (id INT PRIMARY KEY) ENGINE=InnoDB');
28+
29+
// 1. Commit to enter the gap
30+
$db->exec('INSERT INTO test_autocommit_aware_bt VALUES (1)');
31+
$db->commit();
32+
var_dump($db->inTransaction()); // false — in the gap
33+
34+
// 2. beginTransaction() works in the gap
35+
$db->beginTransaction();
36+
var_dump($db->inTransaction()); // true — explicit transaction started
37+
38+
// 3. Operations within explicit transaction
39+
$db->exec('INSERT INTO test_autocommit_aware_bt VALUES (2)');
40+
$db->commit();
41+
42+
// 4. beginTransaction() still throws when already in a transaction
43+
$db->beginTransaction();
44+
try {
45+
$db->beginTransaction();
46+
echo "ERROR: should have thrown\n";
47+
} catch (PDOException $e) {
48+
echo $e->getMessage() . "\n";
49+
}
50+
$db->rollBack();
51+
52+
// 5. Verify data
53+
$stmt = $db->query('SELECT id FROM test_autocommit_aware_bt ORDER BY id');
54+
var_dump($stmt->fetchAll(PDO::FETCH_COLUMN));
55+
56+
$db->exec('DROP TABLE test_autocommit_aware_bt');
57+
?>
58+
--CLEAN--
59+
<?php
60+
require __DIR__ . '/inc/mysql_pdo_test.inc';
61+
$db = MySQLPDOTest::factory();
62+
$db->query('DROP TABLE IF EXISTS test_autocommit_aware_bt');
63+
?>
64+
--EXPECT--
65+
bool(false)
66+
bool(true)
67+
There is already an active transaction
68+
array(2) {
69+
[0]=>
70+
int(1)
71+
[1]=>
72+
int(2)
73+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
--TEST--
2+
PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS - commit() in gap does not throw
3+
--EXTENSIONS--
4+
pdo_mysql
5+
--SKIPIF--
6+
<?php
7+
require_once __DIR__ . '/inc/mysql_pdo_test.inc';
8+
MySQLPDOTest::skip();
9+
if (!defined('PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS')) {
10+
die('skip PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS not available');
11+
}
12+
?>
13+
--FILE--
14+
<?php
15+
require_once __DIR__ . '/inc/mysql_pdo_test.inc';
16+
17+
$db = MySQLPDOTest::factory();
18+
$db->setAttribute(PDO::ATTR_AUTOCOMMIT, false);
19+
$db->setAttribute(PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS, true);
20+
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
21+
22+
$db->exec('DROP TABLE IF EXISTS test_autocommit_aware');
23+
$db->exec('CREATE TABLE test_autocommit_aware (id INT PRIMARY KEY) ENGINE=InnoDB');
24+
25+
// 1. Normal commit works
26+
$db->exec('INSERT INTO test_autocommit_aware VALUES (1)');
27+
var_dump($db->inTransaction()); // true — SERVER_STATUS_IN_TRANS set
28+
$result = $db->commit();
29+
var_dump($result); // true
30+
var_dump($db->inTransaction()); // false — flag cleared by COMMIT
31+
32+
// 2. Second commit in the gap: should return true silently (no-op)
33+
$result = $db->commit();
34+
var_dump($result); // true — no exception thrown
35+
36+
// 3. Subsequent operations still work
37+
$db->exec('INSERT INTO test_autocommit_aware VALUES (2)');
38+
var_dump($db->inTransaction()); // true
39+
$db->commit();
40+
41+
// 4. Verify both rows were persisted
42+
$stmt = $db->query('SELECT id FROM test_autocommit_aware ORDER BY id');
43+
$rows = $stmt->fetchAll(PDO::FETCH_COLUMN);
44+
var_dump($rows);
45+
46+
$db->exec('DROP TABLE test_autocommit_aware');
47+
?>
48+
--CLEAN--
49+
<?php
50+
require __DIR__ . '/inc/mysql_pdo_test.inc';
51+
$db = MySQLPDOTest::factory();
52+
$db->query('DROP TABLE IF EXISTS test_autocommit_aware');
53+
?>
54+
--EXPECT--
55+
bool(true)
56+
bool(true)
57+
bool(false)
58+
bool(true)
59+
bool(true)
60+
array(2) {
61+
[0]=>
62+
int(1)
63+
[1]=>
64+
int(2)
65+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
--TEST--
2+
PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS - defaults to off (BC preserved)
3+
--DESCRIPTION--
4+
Without explicitly enabling the attribute, the current behavior must be
5+
preserved: commit() and rollBack() throw when there is no active transaction.
6+
--EXTENSIONS--
7+
pdo_mysql
8+
--SKIPIF--
9+
<?php
10+
require_once __DIR__ . '/inc/mysql_pdo_test.inc';
11+
MySQLPDOTest::skip();
12+
if (!defined('PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS')) {
13+
die('skip PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS not available');
14+
}
15+
?>
16+
--FILE--
17+
<?php
18+
require_once __DIR__ . '/inc/mysql_pdo_test.inc';
19+
20+
$db = MySQLPDOTest::factory();
21+
$db->setAttribute(PDO::ATTR_AUTOCOMMIT, false);
22+
// NOT setting ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS — defaults to false
23+
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
24+
25+
$db->exec('DROP TABLE IF EXISTS test_autocommit_aware_do');
26+
$db->exec('CREATE TABLE test_autocommit_aware_do (id INT) ENGINE=InnoDB');
27+
28+
// Execute a DML and commit to enter the gap
29+
$db->exec('INSERT INTO test_autocommit_aware_do VALUES (1)');
30+
$db->commit();
31+
32+
// commit() in the gap should throw (current behavior preserved)
33+
try {
34+
$db->commit();
35+
echo "ERROR: should have thrown\n";
36+
} catch (PDOException $e) {
37+
echo "commit: " . $e->getMessage() . "\n";
38+
}
39+
40+
// rollBack() in the gap should throw (current behavior preserved)
41+
try {
42+
$db->rollBack();
43+
echo "ERROR: should have thrown\n";
44+
} catch (PDOException $e) {
45+
echo "rollBack: " . $e->getMessage() . "\n";
46+
}
47+
48+
// Verify attribute defaults to false
49+
var_dump($db->getAttribute(PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS));
50+
51+
$db->exec('DROP TABLE test_autocommit_aware_do');
52+
?>
53+
--CLEAN--
54+
<?php
55+
require __DIR__ . '/inc/mysql_pdo_test.inc';
56+
$db = MySQLPDOTest::factory();
57+
$db->query('DROP TABLE IF EXISTS test_autocommit_aware_do');
58+
?>
59+
--EXPECT--
60+
commit: There is no active transaction
61+
rollBack: There is no active transaction
62+
bool(false)
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
--TEST--
2+
PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS - inTransaction() behavior unchanged
3+
--DESCRIPTION--
4+
The attribute must NOT change inTransaction() semantics. It should still
5+
reflect the actual SERVER_STATUS_IN_TRANS flag from MySQL.
6+
--EXTENSIONS--
7+
pdo_mysql
8+
--SKIPIF--
9+
<?php
10+
require_once __DIR__ . '/inc/mysql_pdo_test.inc';
11+
MySQLPDOTest::skip();
12+
if (!defined('PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS')) {
13+
die('skip PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS not available');
14+
}
15+
?>
16+
--FILE--
17+
<?php
18+
require_once __DIR__ . '/inc/mysql_pdo_test.inc';
19+
20+
$db = MySQLPDOTest::factory();
21+
$db->setAttribute(PDO::ATTR_AUTOCOMMIT, false);
22+
$db->setAttribute(PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS, true);
23+
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
24+
25+
$db->exec('DROP TABLE IF EXISTS test_autocommit_aware_it');
26+
$db->exec('CREATE TABLE test_autocommit_aware_it (id INT) ENGINE=InnoDB');
27+
28+
// 1. After DDL (implicit commit clears flag) — flag not set
29+
var_dump($db->inTransaction()); // false
30+
31+
// 2. After a DML statement — flag set by implicit transaction
32+
$db->exec('INSERT INTO test_autocommit_aware_it VALUES (1)');
33+
var_dump($db->inTransaction()); // true
34+
35+
// 3. After COMMIT — flag cleared
36+
$db->commit();
37+
var_dump($db->inTransaction()); // false — unchanged behavior
38+
39+
// 4. After explicit BEGIN — flag set
40+
$db->beginTransaction();
41+
var_dump($db->inTransaction()); // true
42+
43+
// 5. After explicit COMMIT — flag cleared
44+
$db->commit();
45+
var_dump($db->inTransaction()); // false
46+
47+
echo "inTransaction() behavior is unchanged\n";
48+
49+
$db->exec('DROP TABLE test_autocommit_aware_it');
50+
?>
51+
--CLEAN--
52+
<?php
53+
require __DIR__ . '/inc/mysql_pdo_test.inc';
54+
$db = MySQLPDOTest::factory();
55+
$db->query('DROP TABLE IF EXISTS test_autocommit_aware_it');
56+
?>
57+
--EXPECT--
58+
bool(false)
59+
bool(true)
60+
bool(false)
61+
bool(true)
62+
bool(false)
63+
inTransaction() behavior is unchanged

0 commit comments

Comments
 (0)