From ed0339f5eb2140addefaacc084974f3a108e7d50 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Fri, 13 Mar 2026 18:17:40 -0400 Subject: [PATCH 1/4] Fix GH-20214: PDO::FETCH_DEFAULT unexpected behavior with PDOStatement::setFetchMode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When setFetchMode(PDO::FETCH_DEFAULT) is called, mode=0 (PDO_FETCH_USE_DEFAULT) gets stored as the statement's default fetch type. Later, do_fetch() tries to resolve PDO_FETCH_USE_DEFAULT by reading stmt->default_fetch_type, which is also 0 — circular reference that on 8.4 silently fell through to FETCH_BOTH and on master throws a ValueError. Resolve PDO_FETCH_USE_DEFAULT to the connection-level default early in pdo_stmt_setup_fetch_mode(), before flags extraction and the mode switch, so the rest of the function processes the actual fetch mode. Closes GH-20214 --- ext/pdo/pdo_stmt.c | 4 ++++ ext/pdo_sqlite/tests/gh20214.phpt | 38 +++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 ext/pdo_sqlite/tests/gh20214.phpt diff --git a/ext/pdo/pdo_stmt.c b/ext/pdo/pdo_stmt.c index 9ba82e822b676..2a38dd93a0f74 100644 --- a/ext/pdo/pdo_stmt.c +++ b/ext/pdo/pdo_stmt.c @@ -1731,6 +1731,10 @@ bool pdo_stmt_setup_fetch_mode(pdo_stmt_t *stmt, zend_long mode, uint32_t mode_a stmt->default_fetch_type = PDO_FETCH_BOTH; + if ((mode & ~PDO_FETCH_FLAGS) == PDO_FETCH_USE_DEFAULT) { + mode = stmt->dbh->default_fetch_type; + } + flags = mode & PDO_FETCH_FLAGS; if (!pdo_stmt_verify_mode(stmt, mode, mode_arg_num, false)) { diff --git a/ext/pdo_sqlite/tests/gh20214.phpt b/ext/pdo_sqlite/tests/gh20214.phpt new file mode 100644 index 0000000000000..29adc50b0b747 --- /dev/null +++ b/ext/pdo_sqlite/tests/gh20214.phpt @@ -0,0 +1,38 @@ +--TEST-- +GH-20214 (PDO::FETCH_DEFAULT unexpected behavior with PDOStatement::setFetchMode) +--EXTENSIONS-- +pdo_sqlite +--FILE-- +setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_OBJ); + +// setFetchMode with FETCH_DEFAULT should use connection default (FETCH_OBJ) +$stmt = $db->query("SELECT 'v1' AS c1, 'v2' AS c2"); +$stmt->setFetchMode(PDO::FETCH_DEFAULT); +$row = $stmt->fetch(); +var_dump($row instanceof stdClass); +var_dump($row->c1); + +// fetch with FETCH_DEFAULT should also use connection default +$stmt = $db->query("SELECT 'v1' AS c1, 'v2' AS c2"); +$row = $stmt->fetch(PDO::FETCH_DEFAULT); +var_dump($row instanceof stdClass); + +// fetchAll with FETCH_DEFAULT should also use connection default +$stmt = $db->query("SELECT 'v1' AS c1, 'v2' AS c2"); +$rows = $stmt->fetchAll(PDO::FETCH_DEFAULT); +var_dump($rows[0] instanceof stdClass); + +// setFetchMode then fetch without argument +$stmt = $db->query("SELECT 'v1' AS c1, 'v2' AS c2"); +$stmt->setFetchMode(PDO::FETCH_DEFAULT); +$row = $stmt->fetch(); +var_dump($row instanceof stdClass); +?> +--EXPECT-- +bool(true) +string(2) "v1" +bool(true) +bool(true) +bool(true) From bb3959634154f33ab8700b69965746590e9a25de Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Fri, 3 Apr 2026 08:36:24 -0400 Subject: [PATCH 2/4] Fix flags preservation and move test to PDO core Extract flags before resolving PDO_FETCH_USE_DEFAULT so caller-supplied flags (e.g. PDO_FETCH_GROUP) are preserved. Move test from pdo_sqlite to ext/pdo/tests/ for cross-driver coverage. Add test cases for query() with second argument and flags preservation. --- ext/pdo/pdo_stmt.c | 6 ++--- ext/{pdo_sqlite => pdo}/tests/gh20214.phpt | 28 ++++++++++++++++++++-- 2 files changed, 29 insertions(+), 5 deletions(-) rename ext/{pdo_sqlite => pdo}/tests/gh20214.phpt (54%) diff --git a/ext/pdo/pdo_stmt.c b/ext/pdo/pdo_stmt.c index 2a38dd93a0f74..2ae3b1dd9e8ca 100644 --- a/ext/pdo/pdo_stmt.c +++ b/ext/pdo/pdo_stmt.c @@ -1731,12 +1731,12 @@ bool pdo_stmt_setup_fetch_mode(pdo_stmt_t *stmt, zend_long mode, uint32_t mode_a stmt->default_fetch_type = PDO_FETCH_BOTH; + flags = mode & PDO_FETCH_FLAGS; + if ((mode & ~PDO_FETCH_FLAGS) == PDO_FETCH_USE_DEFAULT) { - mode = stmt->dbh->default_fetch_type; + mode = stmt->dbh->default_fetch_type | flags; } - flags = mode & PDO_FETCH_FLAGS; - if (!pdo_stmt_verify_mode(stmt, mode, mode_arg_num, false)) { return false; } diff --git a/ext/pdo_sqlite/tests/gh20214.phpt b/ext/pdo/tests/gh20214.phpt similarity index 54% rename from ext/pdo_sqlite/tests/gh20214.phpt rename to ext/pdo/tests/gh20214.phpt index 29adc50b0b747..750bc24e533f3 100644 --- a/ext/pdo_sqlite/tests/gh20214.phpt +++ b/ext/pdo/tests/gh20214.phpt @@ -1,10 +1,19 @@ --TEST-- GH-20214 (PDO::FETCH_DEFAULT unexpected behavior with PDOStatement::setFetchMode) --EXTENSIONS-- -pdo_sqlite +pdo +--SKIPIF-- + --FILE-- setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_OBJ); // setFetchMode with FETCH_DEFAULT should use connection default (FETCH_OBJ) @@ -29,6 +38,19 @@ $stmt = $db->query("SELECT 'v1' AS c1, 'v2' AS c2"); $stmt->setFetchMode(PDO::FETCH_DEFAULT); $row = $stmt->fetch(); var_dump($row instanceof stdClass); + +// query() with FETCH_DEFAULT as second argument +$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_NUM); +$stmt = $db->query("SELECT 'v1' AS c1, 'v2' AS c2", PDO::FETCH_DEFAULT); +$row = $stmt->fetch(); +var_dump(is_array($row) && isset($row[0])); + +// FETCH_DEFAULT with flags should preserve the flags +$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); +$stmt = $db->query("SELECT 'v1' AS c1, 'v2' AS c2"); +$stmt->setFetchMode(PDO::FETCH_DEFAULT | PDO::FETCH_GROUP); +$rows = $stmt->fetchAll(); +var_dump(is_array($rows) && array_key_exists('v1', $rows)); ?> --EXPECT-- bool(true) @@ -36,3 +58,5 @@ string(2) "v1" bool(true) bool(true) bool(true) +bool(true) +bool(true) From 40868e4842b97e196dca5c6a2a64df6ed3c05e38 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Fri, 3 Apr 2026 09:12:42 -0400 Subject: [PATCH 3/4] Use table-based queries in gh20214 test for Firebird compat Firebird requires SELECT ... FROM table syntax; bare SELECT 'val' AS col is not valid. Create a test table to make the test cross-driver compatible. --- ext/pdo/tests/gh20214.phpt | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/ext/pdo/tests/gh20214.phpt b/ext/pdo/tests/gh20214.phpt index 750bc24e533f3..8afd667558c4e 100644 --- a/ext/pdo/tests/gh20214.phpt +++ b/ext/pdo/tests/gh20214.phpt @@ -14,43 +14,46 @@ PDOTest::skip(); if (getenv('REDIR_TEST_DIR') === false) putenv('REDIR_TEST_DIR='.__DIR__ . '/../../pdo/tests/'); require_once getenv('REDIR_TEST_DIR') . 'pdo_test.inc'; $db = PDOTest::factory(); + +$db->exec('CREATE TABLE gh20214 (c1 VARCHAR(10), c2 VARCHAR(10))'); +$db->exec("INSERT INTO gh20214 VALUES ('v1', 'v2')"); + $db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_OBJ); // setFetchMode with FETCH_DEFAULT should use connection default (FETCH_OBJ) -$stmt = $db->query("SELECT 'v1' AS c1, 'v2' AS c2"); +$stmt = $db->query('SELECT c1, c2 FROM gh20214'); $stmt->setFetchMode(PDO::FETCH_DEFAULT); $row = $stmt->fetch(); var_dump($row instanceof stdClass); var_dump($row->c1); // fetch with FETCH_DEFAULT should also use connection default -$stmt = $db->query("SELECT 'v1' AS c1, 'v2' AS c2"); +$stmt = $db->query('SELECT c1, c2 FROM gh20214'); $row = $stmt->fetch(PDO::FETCH_DEFAULT); var_dump($row instanceof stdClass); // fetchAll with FETCH_DEFAULT should also use connection default -$stmt = $db->query("SELECT 'v1' AS c1, 'v2' AS c2"); +$stmt = $db->query('SELECT c1, c2 FROM gh20214'); $rows = $stmt->fetchAll(PDO::FETCH_DEFAULT); var_dump($rows[0] instanceof stdClass); // setFetchMode then fetch without argument -$stmt = $db->query("SELECT 'v1' AS c1, 'v2' AS c2"); +$stmt = $db->query('SELECT c1, c2 FROM gh20214'); $stmt->setFetchMode(PDO::FETCH_DEFAULT); $row = $stmt->fetch(); var_dump($row instanceof stdClass); // query() with FETCH_DEFAULT as second argument $db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_NUM); -$stmt = $db->query("SELECT 'v1' AS c1, 'v2' AS c2", PDO::FETCH_DEFAULT); +$stmt = $db->query('SELECT c1, c2 FROM gh20214', PDO::FETCH_DEFAULT); $row = $stmt->fetch(); var_dump(is_array($row) && isset($row[0])); - -// FETCH_DEFAULT with flags should preserve the flags -$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); -$stmt = $db->query("SELECT 'v1' AS c1, 'v2' AS c2"); -$stmt->setFetchMode(PDO::FETCH_DEFAULT | PDO::FETCH_GROUP); -$rows = $stmt->fetchAll(); -var_dump(is_array($rows) && array_key_exists('v1', $rows)); +?> +--CLEAN-- + --EXPECT-- bool(true) @@ -59,4 +62,3 @@ bool(true) bool(true) bool(true) bool(true) -bool(true) From 7196192958e46df803086c25c56d0814611631a9 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Sat, 4 Apr 2026 08:47:46 -0400 Subject: [PATCH 4/4] Trigger CI re-run