From b530708732cac0b888db6ab86ca36aa1118087f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AD=A6=E7=94=B0=20=E6=86=B2=E5=A4=AA=E9=83=8E?= Date: Thu, 6 Aug 2026 12:07:06 +0000 Subject: [PATCH 1/5] ext/pdo_pgsql: End a COPY before draining a lazy fetch --- NEWS | 4 +++ ext/pdo_pgsql/pgsql_statement.c | 27 ++++++++++++++++++ ext/pdo_pgsql/tests/lazy_fetch_copy.phpt | 35 ++++++++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 ext/pdo_pgsql/tests/lazy_fetch_copy.phpt diff --git a/NEWS b/NEWS index 35d78d928995..79b0f377130b 100644 --- a/NEWS +++ b/NEWS @@ -77,6 +77,10 @@ PHP NEWS . Fixed bug GH-23016 (NULL values in long columns come back as garbage binary strings). (Calvin Buckley, iliaal) +- PDO_PGSQL: + . Fixed an infinite loop when cleaning up a lazy fetch + (PDO::ATTR_PREFETCH => 0) left in a COPY. (KentarouTakeda) + - Readline: . Fixed the interactive shell not waiting for the pager process to exit. (Weilin Du) diff --git a/ext/pdo_pgsql/pgsql_statement.c b/ext/pdo_pgsql/pgsql_statement.c index 1620544556b6..dc15b6c9037e 100644 --- a/ext/pdo_pgsql/pgsql_statement.c +++ b/ext/pdo_pgsql/pgsql_statement.c @@ -88,8 +88,35 @@ static void pgsql_stmt_finish(pdo_pgsql_stmt *S, int fin_mode) // instead of discarding results we could store them to their statement // so that their fetch() will get them (albeit not in lazy mode anymore). while ((S->result = PQgetResult(H->server))) { + ExecStatusType status = PQresultStatus(S->result); + PQclear(S->result); S->result = NULL; + + /* PQgetResult() keeps handing out the same result while the + * connection is copying: only these calls can end it */ + if (status == PGRES_COPY_IN || status == PGRES_COPY_BOTH) { + /* fail a copy in, so that abandoning a statement cannot + * commit it; a replication stream only accepts a clean end */ + const char *error = status == PGRES_COPY_IN + ? "COPY terminated by PDO" + : NULL; + + if (PQputCopyEnd(H->server, error) < 0) { + break; + } + } + if (status == PGRES_COPY_OUT || status == PGRES_COPY_BOTH) { + char *buf; + int nbytes; + + while ((nbytes = PQgetCopyData(H->server, &buf, 0)) > 0) { + PQfreemem(buf); + } + if (nbytes < -1) { + break; + } + } } S->is_running_unbuffered = false; } diff --git a/ext/pdo_pgsql/tests/lazy_fetch_copy.phpt b/ext/pdo_pgsql/tests/lazy_fetch_copy.phpt new file mode 100644 index 000000000000..91321e2bcde2 --- /dev/null +++ b/ext/pdo_pgsql/tests/lazy_fetch_copy.phpt @@ -0,0 +1,35 @@ +--TEST-- +PDO PgSQL a lazy fetch left in a COPY does not hang the connection cleanup +--EXTENSIONS-- +pdo_pgsql +--SKIPIF-- + +--FILE-- +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT); +$pdo->setAttribute(PDO::ATTR_PREFETCH, 0); +$pdo->exec("CREATE TEMPORARY TABLE lazy_fetch_copy (i int)"); + +foreach ([ + 'COPY OUT' => "COPY (SELECT 1) TO STDOUT", + 'COPY IN' => "COPY lazy_fetch_copy FROM STDIN", +] as $label => $sql) { + $copy = $pdo->prepare($sql); + $copy->execute(); + + $stmt = $pdo->prepare("VALUES (1), (2)"); + $stmt->execute(); + echo "$label: "; + var_dump((bool) $stmt->fetchAll()); +} +?> +--EXPECT-- +COPY OUT: bool(true) +COPY IN: bool(true) From 684662278609900e70b8379f561c1aba13cf0480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AD=A6=E7=94=B0=20=E6=86=B2=E5=A4=AA=E9=83=8E?= Date: Thu, 6 Aug 2026 12:07:31 +0000 Subject: [PATCH 2/5] ext/pdo_pgsql: Clear the connection's pointer to a destroyed statement --- NEWS | 2 ++ ext/pdo_pgsql/pgsql_statement.c | 7 ++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/NEWS b/NEWS index 79b0f377130b..3dee53640546 100644 --- a/NEWS +++ b/NEWS @@ -80,6 +80,8 @@ PHP NEWS - PDO_PGSQL: . Fixed an infinite loop when cleaning up a lazy fetch (PDO::ATTR_PREFETCH => 0) left in a COPY. (KentarouTakeda) + . Fixed a use-after-free when a lazy statement with emulated or disabled + prepares is destroyed. (KentarouTakeda) - Readline: . Fixed the interactive shell not waiting for the pager process to exit. diff --git a/ext/pdo_pgsql/pgsql_statement.c b/ext/pdo_pgsql/pgsql_statement.c index dc15b6c9037e..dd9943f7a0a4 100644 --- a/ext/pdo_pgsql/pgsql_statement.c +++ b/ext/pdo_pgsql/pgsql_statement.c @@ -138,9 +138,6 @@ static void pgsql_stmt_finish(pdo_pgsql_stmt *S, int fin_mode) } S->is_prepared = false; - if (H->running_stmt == S) { - H->running_stmt = NULL; - } } } @@ -151,6 +148,10 @@ static int pgsql_stmt_dtor(pdo_stmt_t *stmt) pgsql_stmt_finish(S, FIN_DISCARD|(server_obj_usable ? FIN_CLOSE|FIN_ABORT : 0)); + if (server_obj_usable && S->H->running_stmt == S) { + S->H->running_stmt = NULL; + } + if (S->stmt_name) { efree(S->stmt_name); S->stmt_name = NULL; From 53ba6160c968abeda8fd338b39c57de3105ff65b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AD=A6=E7=94=B0=20=E6=86=B2=E5=A4=AA=E9=83=8E?= Date: Thu, 6 Aug 2026 12:07:29 +0000 Subject: [PATCH 3/5] ext/pdo_pgsql: Drain the connection when a lazy fetch ends --- NEWS | 2 ++ ext/pdo_pgsql/pgsql_statement.c | 7 ++--- ext/pdo_pgsql/tests/lazy_fetch_cancel.phpt | 36 ++++++++++++++++++++++ ext/pdo_pgsql/tests/lazy_fetch_drain.phpt | 36 ++++++++++++++++++++++ 4 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 ext/pdo_pgsql/tests/lazy_fetch_cancel.phpt create mode 100644 ext/pdo_pgsql/tests/lazy_fetch_drain.phpt diff --git a/NEWS b/NEWS index 3dee53640546..eddcb52dac1a 100644 --- a/NEWS +++ b/NEWS @@ -82,6 +82,8 @@ PHP NEWS (PDO::ATTR_PREFETCH => 0) left in a COPY. (KentarouTakeda) . Fixed a use-after-free when a lazy statement with emulated or disabled prepares is destroyed. (KentarouTakeda) + . Fixed a lazy fetch with emulated or disabled prepares leaving the + connection busy for the next one. (KentarouTakeda) - Readline: . Fixed the interactive shell not waiting for the pager process to exit. diff --git a/ext/pdo_pgsql/pgsql_statement.c b/ext/pdo_pgsql/pgsql_statement.c index dd9943f7a0a4..669f54a09ec0 100644 --- a/ext/pdo_pgsql/pgsql_statement.c +++ b/ext/pdo_pgsql/pgsql_statement.c @@ -64,12 +64,12 @@ static void pgsql_stmt_finish(pdo_pgsql_stmt *S, int fin_mode) { pdo_pgsql_db_handle *H = S->H; - if (S->is_running_unbuffered && S->result && (fin_mode & FIN_ABORT)) { + /* a buffered query may have already drained this statement's stream */ + if (S->is_running_unbuffered && H->running_stmt == S && S->result && (fin_mode & FIN_ABORT)) { PGcancel *cancel = PQgetCancel(H->server); char errbuf[256]; PQcancel(cancel, errbuf, 256); PQfreeCancel(cancel); - S->is_running_unbuffered = false; } if (S->result) { @@ -78,7 +78,7 @@ static void pgsql_stmt_finish(pdo_pgsql_stmt *S, int fin_mode) S->result = NULL; } - if (S->is_running_unbuffered) { + if (S->is_running_unbuffered && H->running_stmt == S) { /* https://postgresql.org/docs/current/libpq-async.html: * "PQsendQuery cannot be called again until PQgetResult has returned NULL" * And as all single-row functions are connection-wise instead of statement-wise, @@ -616,7 +616,6 @@ static int pgsql_stmt_fetch(pdo_stmt_t *stmt, S->current_row = 0; if (!stmt->row_count) { - S->is_running_unbuffered = false; /* libpq requires looping until getResult returns null */ pgsql_stmt_finish(S, 0); } diff --git a/ext/pdo_pgsql/tests/lazy_fetch_cancel.phpt b/ext/pdo_pgsql/tests/lazy_fetch_cancel.phpt new file mode 100644 index 000000000000..7968c2653206 --- /dev/null +++ b/ext/pdo_pgsql/tests/lazy_fetch_cancel.phpt @@ -0,0 +1,36 @@ +--TEST-- +PDO PgSQL an abandoned lazy fetch frees the connection without a prepared statement +--EXTENSIONS-- +pdo_pgsql +--SKIPIF-- + +--FILE-- +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +foreach ([ + 'PDO::ATTR_EMULATE_PREPARES' => [PDO::ATTR_EMULATE_PREPARES => true], + 'Pdo\Pgsql::ATTR_DISABLE_PREPARES' => [Pdo\Pgsql::ATTR_DISABLE_PREPARES => true], +] as $label => $options) { + $options[PDO::ATTR_PREFETCH] = 0; + + $stmt = $pdo->prepare("VALUES (1), (2)", $options); + $stmt->execute(); + $stmt = null; + + $stmt = $pdo->prepare("VALUES (1), (2)", $options); + $stmt->execute(); + echo "$label: "; + var_dump((bool) $stmt->fetchAll()); +} +?> +--EXPECT-- +PDO::ATTR_EMULATE_PREPARES: bool(true) +Pdo\Pgsql::ATTR_DISABLE_PREPARES: bool(true) diff --git a/ext/pdo_pgsql/tests/lazy_fetch_drain.phpt b/ext/pdo_pgsql/tests/lazy_fetch_drain.phpt new file mode 100644 index 000000000000..a650628d3cce --- /dev/null +++ b/ext/pdo_pgsql/tests/lazy_fetch_drain.phpt @@ -0,0 +1,36 @@ +--TEST-- +PDO PgSQL a drained lazy fetch frees the connection without a prepared statement +--EXTENSIONS-- +pdo_pgsql +--SKIPIF-- + +--FILE-- +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +foreach ([ + 'PDO::ATTR_EMULATE_PREPARES' => [PDO::ATTR_EMULATE_PREPARES => true], + 'Pdo\Pgsql::ATTR_DISABLE_PREPARES' => [Pdo\Pgsql::ATTR_DISABLE_PREPARES => true], +] as $label => $options) { + $options[PDO::ATTR_PREFETCH] = 0; + + $stmt = $pdo->prepare("VALUES (1), (2)", $options); + $stmt->execute(); + $stmt->fetchAll(); + + $stmt = $pdo->prepare("VALUES (1), (2)", $options); + $stmt->execute(); + echo "$label: "; + var_dump((bool) $stmt->fetchAll()); +} +?> +--EXPECT-- +PDO::ATTR_EMULATE_PREPARES: bool(true) +Pdo\Pgsql::ATTR_DISABLE_PREPARES: bool(true) From 853bf6f70ea03034c7e1353f87dcf28409563055 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AD=A6=E7=94=B0=20=E6=86=B2=E5=A4=AA=E9=83=8E?= Date: Thu, 6 Aug 2026 12:07:48 +0000 Subject: [PATCH 4/5] ext/pdo_pgsql: Do not deliver rows of a result another statement freed --- NEWS | 2 ++ ext/pdo_pgsql/pgsql_statement.c | 3 ++- ext/pdo_pgsql/tests/lazy_fetch_takeover.phpt | 28 ++++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 ext/pdo_pgsql/tests/lazy_fetch_takeover.phpt diff --git a/NEWS b/NEWS index eddcb52dac1a..3cc115ddac4b 100644 --- a/NEWS +++ b/NEWS @@ -84,6 +84,8 @@ PHP NEWS prepares is destroyed. (KentarouTakeda) . Fixed a lazy fetch with emulated or disabled prepares leaving the connection busy for the next one. (KentarouTakeda) + . Fixed a lazy fetch returning a row of NULLs after another statement + took over the connection. (KentarouTakeda) - Readline: . Fixed the interactive shell not waiting for the pager process to exit. diff --git a/ext/pdo_pgsql/pgsql_statement.c b/ext/pdo_pgsql/pgsql_statement.c index 669f54a09ec0..a75096e59adb 100644 --- a/ext/pdo_pgsql/pgsql_statement.c +++ b/ext/pdo_pgsql/pgsql_statement.c @@ -620,7 +620,8 @@ static int pgsql_stmt_fetch(pdo_stmt_t *stmt, pgsql_stmt_finish(S, 0); } } - if (S->current_row < stmt->row_count) { + /* another statement may have taken over and freed the result */ + if (S->result && S->current_row < stmt->row_count) { S->current_row++; return 1; } else { diff --git a/ext/pdo_pgsql/tests/lazy_fetch_takeover.phpt b/ext/pdo_pgsql/tests/lazy_fetch_takeover.phpt new file mode 100644 index 000000000000..eace678310de --- /dev/null +++ b/ext/pdo_pgsql/tests/lazy_fetch_takeover.phpt @@ -0,0 +1,28 @@ +--TEST-- +PDO PgSQL a lazy fetch whose stream was taken over reports no leftover rows +--EXTENSIONS-- +pdo_pgsql +--SKIPIF-- + +--FILE-- +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +$pdo->setAttribute(PDO::ATTR_PREFETCH, 0); + +$first = $pdo->prepare("VALUES (1), (2)"); +$first->execute(); + +$pdo->prepare("VALUES (1), (2)")->execute(); + +var_dump($first->fetchAll(PDO::FETCH_NUM)); +?> +--EXPECT-- +array(0) { +} From 62266ca783c68830baf5fd20295d3ad2cda233f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AD=A6=E7=94=B0=20=E6=86=B2=E5=A4=AA=E9=83=8E?= Date: Wed, 5 Aug 2026 12:30:09 +0000 Subject: [PATCH 5/5] ext/pdo_pgsql: Add `Pdo\Pgsql::ATTR_CHUNK_SIZE` for chunked result fetching --- NEWS | 2 + UPGRADING | 5 + ext/pdo_pgsql/config.m4 | 14 +++ ext/pdo_pgsql/pdo_pgsql.stub.php | 5 + ext/pdo_pgsql/pdo_pgsql_arginfo.h | 10 +- ext/pdo_pgsql/pgsql_driver.c | 64 +++++++++++ ext/pdo_pgsql/pgsql_statement.c | 35 +++++- ext/pdo_pgsql/php_pdo_pgsql_int.h | 3 + ext/pdo_pgsql/tests/chunk_size.phpt | 63 +++++++++++ ext/pdo_pgsql/tests/chunk_size_attr.phpt | 101 ++++++++++++++++++ ext/pdo_pgsql/tests/chunk_size_error.phpt | 96 +++++++++++++++++ .../tests/chunk_size_error_64bit.phpt | 45 ++++++++ ext/pdo_pgsql/tests/chunk_size_fetch.phpt | 85 +++++++++++++++ ext/pdo_pgsql/tests/chunk_size_takeover.phpt | 36 +++++++ 14 files changed, 560 insertions(+), 4 deletions(-) create mode 100644 ext/pdo_pgsql/tests/chunk_size.phpt create mode 100644 ext/pdo_pgsql/tests/chunk_size_attr.phpt create mode 100644 ext/pdo_pgsql/tests/chunk_size_error.phpt create mode 100644 ext/pdo_pgsql/tests/chunk_size_error_64bit.phpt create mode 100644 ext/pdo_pgsql/tests/chunk_size_fetch.phpt create mode 100644 ext/pdo_pgsql/tests/chunk_size_takeover.phpt diff --git a/NEWS b/NEWS index 3cc115ddac4b..0709527cfa7b 100644 --- a/NEWS +++ b/NEWS @@ -86,6 +86,8 @@ PHP NEWS connection busy for the next one. (KentarouTakeda) . Fixed a lazy fetch returning a row of NULLs after another statement took over the connection. (KentarouTakeda) + . Added Pdo\Pgsql::ATTR_CHUNK_SIZE to fetch a result set in chunks of the + given number of rows. (KentarouTakeda) - Readline: . Fixed the interactive shell not waiting for the pager process to exit. diff --git a/UPGRADING b/UPGRADING index 779c95ae9530..cfc0508edf28 100644 --- a/UPGRADING +++ b/UPGRADING @@ -389,6 +389,11 @@ PHP 8.6 UPGRADE NOTES outcome is reported as 'accepted', 'rejected' or 'not_sent' in the early_data key of the crypto stream_get_meta_data() array. +- PDO_PGSQL: + . Added Pdo\Pgsql::ATTR_CHUNK_SIZE, the number of rows a statement fetches + per chunk. A value of 1 or more enters the lazy fetch mode of + PDO::ATTR_PREFETCH => 0. Requires libpq 17 or later. + - Phar: . Overriding the getMTime() and getPathname() methods of SplFileInfo now influences the result of the phar buildFrom family of functions. diff --git a/ext/pdo_pgsql/config.m4 b/ext/pdo_pgsql/config.m4 index 1137a911c811..406f08465e90 100644 --- a/ext/pdo_pgsql/config.m4 +++ b/ext/pdo_pgsql/config.m4 @@ -25,6 +25,20 @@ if test "$PHP_PDO_PGSQL" != "no"; then or later).])],, [$PGSQL_LIBS]) + old_CFLAGS=$CFLAGS + CFLAGS="$CFLAGS $PGSQL_CFLAGS" + + AC_CHECK_DECL([PGRES_TUPLES_CHUNK], + PHP_CHECK_LIBRARY([pq], [PQsetChunkedRowsMode], + [AC_DEFINE([HAVE_PG_SET_CHUNKED_ROWS_SIZE], [1], + [Define to 1 if libpq has the 'PQsetChunkedRowsMode' function (PostgreSQL + 17 or later).])],, + [$PGSQL_LIBS]),, + [#include ] + ) + + CFLAGS=$old_CFLAGS + PHP_CHECK_PDO_INCLUDES PHP_NEW_EXTENSION([pdo_pgsql], diff --git a/ext/pdo_pgsql/pdo_pgsql.stub.php b/ext/pdo_pgsql/pdo_pgsql.stub.php index 8322b88ca8db..6d4a1d0e263e 100644 --- a/ext/pdo_pgsql/pdo_pgsql.stub.php +++ b/ext/pdo_pgsql/pdo_pgsql.stub.php @@ -18,6 +18,11 @@ class Pgsql extends \PDO public const int ATTR_RESULT_MEMORY_SIZE = UNKNOWN; #endif +#ifdef HAVE_PG_SET_CHUNKED_ROWS_SIZE + /** @cvalue PDO_PGSQL_ATTR_CHUNK_SIZE */ + public const int ATTR_CHUNK_SIZE = UNKNOWN; +#endif + /** @cvalue PGSQL_TRANSACTION_IDLE */ #[\Deprecated(since: "8.5", message: "as it has no effect")] public const int TRANSACTION_IDLE = UNKNOWN; diff --git a/ext/pdo_pgsql/pdo_pgsql_arginfo.h b/ext/pdo_pgsql/pdo_pgsql_arginfo.h index 80e87862b08b..80127659a567 100644 --- a/ext/pdo_pgsql/pdo_pgsql_arginfo.h +++ b/ext/pdo_pgsql/pdo_pgsql_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit pdo_pgsql.stub.php instead. - * Stub hash: 0ea21010467d661416f0858f2bda095583ea3a36 */ + * Stub hash: 3f62627e74ad08de8e95c9862e3d209ae63d971a */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Pdo_Pgsql_escapeIdentifier, 0, 1, IS_STRING, 0) ZEND_ARG_TYPE_INFO(0, input, IS_STRING, 0) @@ -101,6 +101,14 @@ static zend_class_entry *register_class_Pdo_Pgsql(zend_class_entry *class_entry_ zend_declare_typed_class_constant(class_entry, const_ATTR_RESULT_MEMORY_SIZE_name, &const_ATTR_RESULT_MEMORY_SIZE_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_LONG)); zend_string_release_ex(const_ATTR_RESULT_MEMORY_SIZE_name, true); #endif +#if defined(HAVE_PG_SET_CHUNKED_ROWS_SIZE) + + zval const_ATTR_CHUNK_SIZE_value; + ZVAL_LONG(&const_ATTR_CHUNK_SIZE_value, PDO_PGSQL_ATTR_CHUNK_SIZE); + zend_string *const_ATTR_CHUNK_SIZE_name = zend_string_init_interned("ATTR_CHUNK_SIZE", sizeof("ATTR_CHUNK_SIZE") - 1, true); + zend_declare_typed_class_constant(class_entry, const_ATTR_CHUNK_SIZE_name, &const_ATTR_CHUNK_SIZE_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_LONG)); + zend_string_release_ex(const_ATTR_CHUNK_SIZE_name, true); +#endif zval const_TRANSACTION_IDLE_value; ZVAL_LONG(&const_TRANSACTION_IDLE_value, PGSQL_TRANSACTION_IDLE); diff --git a/ext/pdo_pgsql/pgsql_driver.c b/ext/pdo_pgsql/pgsql_driver.c index 52aae986dee1..a1504cfd5f93 100644 --- a/ext/pdo_pgsql/pgsql_driver.c +++ b/ext/pdo_pgsql/pgsql_driver.c @@ -266,6 +266,18 @@ static void pgsql_handle_closer(pdo_dbh_t *dbh) /* {{{ */ } /* }}} */ +#ifdef HAVE_PG_SET_CHUNKED_ROWS_SIZE +static bool pdo_pgsql_check_chunk_size(zend_long size) +{ + if (size < 0 || ZEND_LONG_EXCEEDS_INT(size)) { + zend_value_error("Pdo\\Pgsql::ATTR_CHUNK_SIZE must be between 0 and %d", INT_MAX); + return false; + } + + return true; +} +#endif + static bool pgsql_handle_preparer(pdo_dbh_t *dbh, zend_string *sql, pdo_stmt_t *stmt, zval *driver_options) { pdo_pgsql_db_handle *H = (pdo_pgsql_db_handle *)dbh->driver_data; @@ -318,6 +330,38 @@ static bool pgsql_handle_preparer(pdo_dbh_t *dbh, zend_string *sql, pdo_stmt_t * : H->default_fetching_laziness ; +#ifdef HAVE_PG_SET_CHUNKED_ROWS_SIZE + bool chunk_size_given = driver_options + && (val = zend_hash_index_find(Z_ARRVAL_P(driver_options), PDO_PGSQL_ATTR_CHUNK_SIZE)); + + if (chunk_size_given) { + if (!pdo_get_long_param(&lval, val)) { + return false; + } + S->chunk_size = lval; + } else { + S->chunk_size = H->default_chunk_size; + } + + if (!pdo_pgsql_check_chunk_size(S->chunk_size)) { + return false; + } + + if (S->chunk_size >= 1 && scrollable) { + if (chunk_size_given) { + zend_value_error("Pdo\\Pgsql::ATTR_CHUNK_SIZE cannot be combined with " + "PDO::ATTR_CURSOR set to PDO::CURSOR_SCROLL"); + return false; + } + + S->chunk_size = 0; + } + + if (S->chunk_size >= 1) { + S->is_unbuffered = true; + } +#endif + ret = pdo_parse_params(stmt, sql, &nsql); if (ret == -1) { @@ -473,6 +517,12 @@ static int pdo_pgsql_get_attribute(pdo_dbh_t *dbh, zend_long attr, zval *return_ ZVAL_BOOL(return_value, H->disable_prepares); break; +#ifdef HAVE_PG_SET_CHUNKED_ROWS_SIZE + case PDO_PGSQL_ATTR_CHUNK_SIZE: + ZVAL_LONG(return_value, H->default_chunk_size); + break; +#endif + case PDO_ATTR_CLIENT_VERSION: { char buf[16]; pdo_libpq_version(buf, sizeof(buf)); @@ -1377,6 +1427,20 @@ static bool pdo_pgsql_set_attr(pdo_dbh_t *dbh, zend_long attr, zval *val) } H->default_fetching_laziness = !bval; return true; +#ifdef HAVE_PG_SET_CHUNKED_ROWS_SIZE + case PDO_PGSQL_ATTR_CHUNK_SIZE: { + zend_long lval; + + if (!pdo_get_long_param(&lval, val)) { + return false; + } + if (!pdo_pgsql_check_chunk_size(lval)) { + return false; + } + H->default_chunk_size = lval; + return true; + } +#endif default: return false; } diff --git a/ext/pdo_pgsql/pgsql_statement.c b/ext/pdo_pgsql/pgsql_statement.c index a75096e59adb..7162876fcd28 100644 --- a/ext/pdo_pgsql/pgsql_statement.c +++ b/ext/pdo_pgsql/pgsql_statement.c @@ -58,6 +58,21 @@ #define FIN_CLOSE 0x2 #define FIN_ABORT 0x4 +static bool pgsql_result_status_ok(ExecStatusType status) +{ + switch (status) { + case PGRES_COMMAND_OK: + case PGRES_TUPLES_OK: + case PGRES_SINGLE_TUPLE: +#ifdef HAVE_PG_SET_CHUNKED_ROWS_SIZE + case PGRES_TUPLES_CHUNK: +#endif + return true; + default: + return false; + } +} + static void pgsql_stmt_finish(pdo_pgsql_stmt *S, int fin_mode) @@ -354,8 +369,16 @@ static int pgsql_stmt_execute(pdo_stmt_t *stmt) return 0; } S->is_running_unbuffered = true; + /* no matter if they return 0: PQ then transparently fallbacks to full result fetching */ +#ifdef HAVE_PG_SET_CHUNKED_ROWS_SIZE + if (S->chunk_size >= 1) { + (void)PQsetChunkedRowsMode(H->server, (int)S->chunk_size); + } else { + (void)PQsetSingleRowMode(H->server); + } +#else (void)PQsetSingleRowMode(H->server); - /* no matter if it returns 0: PQ then transparently fallbacks to full result fetching */ +#endif /* try a first fetch to at least have column names and so on */ S->result = PQgetResult(S->H->server); @@ -363,7 +386,7 @@ static int pgsql_stmt_execute(pdo_stmt_t *stmt) status = PQresultStatus(S->result); - if (status != PGRES_COMMAND_OK && status != PGRES_TUPLES_OK && status != PGRES_SINGLE_TUPLE) { + if (!pgsql_result_status_ok(status)) { pdo_pgsql_error_stmt(stmt, status, pdo_pgsql_sqlstate(S->result)); return 0; } @@ -607,7 +630,7 @@ static int pgsql_stmt_fetch(pdo_stmt_t *stmt, } status = PQresultStatus(S->result); - if (status != PGRES_COMMAND_OK && status != PGRES_TUPLES_OK && status != PGRES_SINGLE_TUPLE) { + if (!pgsql_result_status_ok(status)) { pdo_pgsql_error_stmt(stmt, status, pdo_pgsql_sqlstate(S->result)); return 0; } @@ -884,6 +907,12 @@ static int pgsql_stmt_get_attr(pdo_stmt_t *stmt, zend_long attr, zval *val) return 1; #endif +#ifdef HAVE_PG_SET_CHUNKED_ROWS_SIZE + case PDO_PGSQL_ATTR_CHUNK_SIZE: + ZVAL_LONG(val, S->chunk_size); + return 1; +#endif + default: (void)S; return 0; diff --git a/ext/pdo_pgsql/php_pdo_pgsql_int.h b/ext/pdo_pgsql/php_pdo_pgsql_int.h index 47c9223540ad..a9a09a24b707 100644 --- a/ext/pdo_pgsql/php_pdo_pgsql_int.h +++ b/ext/pdo_pgsql/php_pdo_pgsql_int.h @@ -47,6 +47,7 @@ typedef struct { HashTable *lob_streams; zend_fcall_info_cache *notice_callback; bool default_fetching_laziness; + zend_long default_chunk_size; pdo_pgsql_stmt *running_stmt; } pdo_pgsql_db_handle; @@ -66,6 +67,7 @@ struct pdo_pgsql_stmt { int *param_formats; Oid *param_types; int current_row; + zend_long chunk_size; bool is_prepared; bool is_unbuffered; bool is_running_unbuffered; @@ -93,6 +95,7 @@ extern const struct pdo_stmt_methods pgsql_stmt_methods; enum { PDO_PGSQL_ATTR_DISABLE_PREPARES = PDO_ATTR_DRIVER_SPECIFIC, PDO_PGSQL_ATTR_RESULT_MEMORY_SIZE, + PDO_PGSQL_ATTR_CHUNK_SIZE, }; struct pdo_pgsql_lob_self { diff --git a/ext/pdo_pgsql/tests/chunk_size.phpt b/ext/pdo_pgsql/tests/chunk_size.phpt new file mode 100644 index 000000000000..0dc2787c646e --- /dev/null +++ b/ext/pdo_pgsql/tests/chunk_size.phpt @@ -0,0 +1,63 @@ +--TEST-- +PDO PgSQL Pdo\Pgsql::ATTR_CHUNK_SIZE splits a result set into chunks of rows +--EXTENSIONS-- +pdo_pgsql +--SKIPIF-- += 17 required'); +?> +--FILE-- +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +// rowCount() reports the size of the chunk being consumed while fetching unbuffered +function run(PDO $pdo, string $label, array $options): void +{ + $stmt = $pdo->prepare("SELECT * FROM generate_series(1, 10)", $options); + $stmt->execute(); + + $values = []; + $sizes = []; + + while (($row = $stmt->fetch(PDO::FETCH_NUM))) { + $values[] = $row[0]; + $sizes[] = $stmt->rowCount(); + } + + printf("%s\n values=%s\n chunk sizes=%s\n", + $label, implode(',', $values), implode(',', $sizes)); +} + +run($pdo, 'buffered (default)', []); +run($pdo, 'ATTR_PREFETCH => 0', [PDO::ATTR_PREFETCH => 0]); +run($pdo, 'ATTR_CHUNK_SIZE => 1', [Pdo\Pgsql::ATTR_CHUNK_SIZE => 1]); +run($pdo, 'ATTR_CHUNK_SIZE => 4 (last chunk is partial)', [Pdo\Pgsql::ATTR_CHUNK_SIZE => 4]); +run($pdo, 'ATTR_CHUNK_SIZE => 5 (divides evenly)', [Pdo\Pgsql::ATTR_CHUNK_SIZE => 5]); +run($pdo, 'ATTR_CHUNK_SIZE => 99 (larger than the result)', [Pdo\Pgsql::ATTR_CHUNK_SIZE => 99]); + +?> +--EXPECT-- +buffered (default) + values=1,2,3,4,5,6,7,8,9,10 + chunk sizes=10,10,10,10,10,10,10,10,10,10 +ATTR_PREFETCH => 0 + values=1,2,3,4,5,6,7,8,9,10 + chunk sizes=1,1,1,1,1,1,1,1,1,1 +ATTR_CHUNK_SIZE => 1 + values=1,2,3,4,5,6,7,8,9,10 + chunk sizes=1,1,1,1,1,1,1,1,1,1 +ATTR_CHUNK_SIZE => 4 (last chunk is partial) + values=1,2,3,4,5,6,7,8,9,10 + chunk sizes=4,4,4,4,4,4,4,4,2,2 +ATTR_CHUNK_SIZE => 5 (divides evenly) + values=1,2,3,4,5,6,7,8,9,10 + chunk sizes=5,5,5,5,5,5,5,5,5,5 +ATTR_CHUNK_SIZE => 99 (larger than the result) + values=1,2,3,4,5,6,7,8,9,10 + chunk sizes=10,10,10,10,10,10,10,10,10,10 diff --git a/ext/pdo_pgsql/tests/chunk_size_attr.phpt b/ext/pdo_pgsql/tests/chunk_size_attr.phpt new file mode 100644 index 000000000000..7f69bff9082b --- /dev/null +++ b/ext/pdo_pgsql/tests/chunk_size_attr.phpt @@ -0,0 +1,101 @@ +--TEST-- +PDO PgSQL Pdo\Pgsql::ATTR_CHUNK_SIZE resolution between the connection and the statement +--EXTENSIONS-- +pdo_pgsql +--SKIPIF-- += 17 required'); +?> +--FILE-- +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +// rowCount() reports the size of the chunk being consumed +function report(PDOStatement $stmt): void +{ + $stmt->fetch(); + + echo " rows delivered at once: "; + var_dump($stmt->rowCount()); +} + +echo "=== nothing set ===\n"; +echo " connection attribute: "; +var_dump($pdo->getAttribute(Pdo\Pgsql::ATTR_CHUNK_SIZE)); +report($pdo->query("SELECT * FROM generate_series(1, 10)")); + +echo "=== set on the statement only ===\n"; +$stmt = $pdo->prepare("SELECT * FROM generate_series(1, 10)", [Pdo\Pgsql::ATTR_CHUNK_SIZE => 4]); +$stmt->execute(); +echo " statement attribute: "; +var_dump($stmt->getAttribute(Pdo\Pgsql::ATTR_CHUNK_SIZE)); +report($stmt); +unset($stmt); + +echo "=== set on the connection ===\n"; +$pdo->setAttribute(Pdo\Pgsql::ATTR_CHUNK_SIZE, 3); +echo " connection attribute: "; +var_dump($pdo->getAttribute(Pdo\Pgsql::ATTR_CHUNK_SIZE)); +$stmt = $pdo->prepare("SELECT * FROM generate_series(1, 10)"); +$stmt->execute(); +report($stmt); +unset($stmt); + +echo "=== query() inherits the connection ===\n"; +report($pdo->query("SELECT * FROM generate_series(1, 10)")); + +echo "=== the statement overrides the connection ===\n"; +$stmt = $pdo->prepare("SELECT * FROM generate_series(1, 10)", [Pdo\Pgsql::ATTR_CHUNK_SIZE => 6]); +$stmt->execute(); +echo " statement attribute: "; +var_dump($stmt->getAttribute(Pdo\Pgsql::ATTR_CHUNK_SIZE)); +report($stmt); +unset($stmt); + +echo "=== a chunk size wins over a buffering ATTR_PREFETCH ===\n"; +$stmt = $pdo->prepare("SELECT * FROM generate_series(1, 10)", + [PDO::ATTR_PREFETCH => 1, Pdo\Pgsql::ATTR_CHUNK_SIZE => 2]); +$stmt->execute(); +report($stmt); +unset($stmt); + +echo "=== a statement can opt out of an inherited chunk size ===\n"; +$stmt = $pdo->prepare("SELECT * FROM generate_series(1, 10)", [Pdo\Pgsql::ATTR_CHUNK_SIZE => 0]); +$stmt->execute(); +report($stmt); +unset($stmt); + +echo "=== back to 0 ===\n"; +$pdo->setAttribute(Pdo\Pgsql::ATTR_CHUNK_SIZE, 0); +echo " connection attribute: "; +var_dump($pdo->getAttribute(Pdo\Pgsql::ATTR_CHUNK_SIZE)); +report($pdo->query("SELECT * FROM generate_series(1, 10)")); +?> +--EXPECT-- +=== nothing set === + connection attribute: int(0) + rows delivered at once: int(10) +=== set on the statement only === + statement attribute: int(4) + rows delivered at once: int(4) +=== set on the connection === + connection attribute: int(3) + rows delivered at once: int(3) +=== query() inherits the connection === + rows delivered at once: int(3) +=== the statement overrides the connection === + statement attribute: int(6) + rows delivered at once: int(6) +=== a chunk size wins over a buffering ATTR_PREFETCH === + rows delivered at once: int(2) +=== a statement can opt out of an inherited chunk size === + rows delivered at once: int(10) +=== back to 0 === + connection attribute: int(0) + rows delivered at once: int(10) diff --git a/ext/pdo_pgsql/tests/chunk_size_error.phpt b/ext/pdo_pgsql/tests/chunk_size_error.phpt new file mode 100644 index 000000000000..670db99b79ed --- /dev/null +++ b/ext/pdo_pgsql/tests/chunk_size_error.phpt @@ -0,0 +1,96 @@ +--TEST-- +PDO PgSQL Pdo\Pgsql::ATTR_CHUNK_SIZE rejects values it cannot honour +--EXTENSIONS-- +pdo_pgsql +--SKIPIF-- += 17 required'); +?> +--FILE-- +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +echo "=== a negative value is refused by setAttribute() ===\n"; +try { + $pdo->setAttribute(Pdo\Pgsql::ATTR_CHUNK_SIZE, -1); +} catch (ValueError $e) { + echo $e::class, ': ', $e->getMessage(), "\n"; +} +echo "the attribute kept its previous value: "; +var_dump($pdo->getAttribute(Pdo\Pgsql::ATTR_CHUNK_SIZE)); + +echo "=== and by the prepare() driver options ===\n"; +try { + $pdo->prepare("SELECT 1", [Pdo\Pgsql::ATTR_CHUNK_SIZE => -42]); +} catch (ValueError $e) { + echo $e::class, ': ', $e->getMessage(), "\n"; +} + +echo "=== and by the constructor options ===\n"; +$user = getenv('PDOTEST_USER'); +$pass = getenv('PDOTEST_PASS'); +try { + new PDO(getenv('PDOTEST_DSN'), $user === false ? null : $user, + $pass === false ? null : $pass, [Pdo\Pgsql::ATTR_CHUNK_SIZE => -1]); +} catch (ValueError $e) { + echo $e::class, ': ', $e->getMessage(), "\n"; +} + +echo "=== values that are not integers ===\n"; +foreach ([1.5, "8", [8], null, new stdClass()] as $value) { + try { + $pdo->setAttribute(Pdo\Pgsql::ATTR_CHUNK_SIZE, $value); + printf("%-8s accepted as %d\n", get_debug_type($value), + $pdo->getAttribute(Pdo\Pgsql::ATTR_CHUNK_SIZE)); + } catch (TypeError $e) { + printf("%-8s %s: %s\n", get_debug_type($value), $e::class, $e->getMessage()); + } +} +$pdo->setAttribute(Pdo\Pgsql::ATTR_CHUNK_SIZE, 0); + +echo "=== scrollable cursors have nothing to stream ===\n"; +try { + $pdo->prepare("SELECT 1", [ + PDO::ATTR_CURSOR => PDO::CURSOR_SCROLL, + Pdo\Pgsql::ATTR_CHUNK_SIZE => 4, + ]); +} catch (ValueError $e) { + echo $e::class, ': ', $e->getMessage(), "\n"; +} +$pdo->setAttribute(Pdo\Pgsql::ATTR_CHUNK_SIZE, 4); +$stmt = $pdo->prepare("SELECT 1", [PDO::ATTR_CURSOR => PDO::CURSOR_SCROLL]); +$stmt->execute(); +echo "an inherited chunk size leaves the cursor alone: "; +var_dump($stmt->fetchColumn()); +unset($stmt); +$pdo->setAttribute(Pdo\Pgsql::ATTR_CHUNK_SIZE, 0); + +echo "=== a rejected value leaves the connection usable ===\n"; +var_dump($pdo->query("SELECT 1")->fetchColumn()); + +?> +--EXPECT-- +=== a negative value is refused by setAttribute() === +ValueError: Pdo\Pgsql::ATTR_CHUNK_SIZE must be between 0 and 2147483647 +the attribute kept its previous value: int(0) +=== and by the prepare() driver options === +ValueError: Pdo\Pgsql::ATTR_CHUNK_SIZE must be between 0 and 2147483647 +=== and by the constructor options === +ValueError: Pdo\Pgsql::ATTR_CHUNK_SIZE must be between 0 and 2147483647 +=== values that are not integers === +float TypeError: Attribute value must be of type int for selected attribute, float given +string accepted as 8 +array TypeError: Attribute value must be of type int for selected attribute, array given +null TypeError: Attribute value must be of type int for selected attribute, null given +stdClass TypeError: Attribute value must be of type int for selected attribute, stdClass given +=== scrollable cursors have nothing to stream === +ValueError: Pdo\Pgsql::ATTR_CHUNK_SIZE cannot be combined with PDO::ATTR_CURSOR set to PDO::CURSOR_SCROLL +an inherited chunk size leaves the cursor alone: string(1) "1" +=== a rejected value leaves the connection usable === +string(1) "1" diff --git a/ext/pdo_pgsql/tests/chunk_size_error_64bit.phpt b/ext/pdo_pgsql/tests/chunk_size_error_64bit.phpt new file mode 100644 index 000000000000..b6da59d3e07a --- /dev/null +++ b/ext/pdo_pgsql/tests/chunk_size_error_64bit.phpt @@ -0,0 +1,45 @@ +--TEST-- +PDO PgSQL Pdo\Pgsql::ATTR_CHUNK_SIZE upper bound, 64-bit +--EXTENSIONS-- +pdo_pgsql +--SKIPIF-- += 17 required'); +if (PHP_INT_SIZE < 8) die('skip a value above INT_MAX needs 64-bit'); +?> +--FILE-- +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +echo "=== INT_MAX is the largest chunk size ===\n"; +$pdo->setAttribute(Pdo\Pgsql::ATTR_CHUNK_SIZE, 2147483647); +echo "accepted as: "; +var_dump($pdo->getAttribute(Pdo\Pgsql::ATTR_CHUNK_SIZE)); + +echo "=== one more is refused ===\n"; +try { + $pdo->setAttribute(Pdo\Pgsql::ATTR_CHUNK_SIZE, 2147483648); +} catch (ValueError $e) { + echo 'setAttribute(): ', $e::class, ': ', $e->getMessage(), "\n"; +} +try { + $pdo->prepare("SELECT 1", [Pdo\Pgsql::ATTR_CHUNK_SIZE => PHP_INT_MAX]); +} catch (ValueError $e) { + echo 'prepare(): ', $e::class, ': ', $e->getMessage(), "\n"; +} +echo "the attribute kept the accepted value: "; +var_dump($pdo->getAttribute(Pdo\Pgsql::ATTR_CHUNK_SIZE)); +?> +--EXPECT-- +=== INT_MAX is the largest chunk size === +accepted as: int(2147483647) +=== one more is refused === +setAttribute(): ValueError: Pdo\Pgsql::ATTR_CHUNK_SIZE must be between 0 and 2147483647 +prepare(): ValueError: Pdo\Pgsql::ATTR_CHUNK_SIZE must be between 0 and 2147483647 +the attribute kept the accepted value: int(2147483647) diff --git a/ext/pdo_pgsql/tests/chunk_size_fetch.phpt b/ext/pdo_pgsql/tests/chunk_size_fetch.phpt new file mode 100644 index 000000000000..f0195b92b03e --- /dev/null +++ b/ext/pdo_pgsql/tests/chunk_size_fetch.phpt @@ -0,0 +1,85 @@ +--TEST-- +PDO PgSQL fetching while Pdo\Pgsql::ATTR_CHUNK_SIZE is in effect +--EXTENSIONS-- +pdo_pgsql +--SKIPIF-- += 17 required'); +?> +--FILE-- +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +$pdo->setAttribute(Pdo\Pgsql::ATTR_CHUNK_SIZE, 3); + +$pdo->exec("CREATE TEMP TABLE t AS SELECT g AS n FROM generate_series(1, 10) g"); + +function values(PDOStatement $stmt): string +{ + $values = []; + + while (($row = $stmt->fetch(PDO::FETCH_NUM))) { + $values[] = $row[0]; + } + + return implode(',', $values); +} + +echo "=== fetch() ===\n"; +echo " values: ", values($pdo->query("SELECT n FROM t ORDER BY n")), "\n"; + +echo "=== fetchAll() ===\n"; +echo " values: ", implode(',', array_column( + $pdo->query("SELECT n FROM t ORDER BY n")->fetchAll(PDO::FETCH_NUM), 0)), "\n"; + +echo "=== empty result set ===\n"; +echo " first fetch() returns: "; +var_dump($pdo->query("SELECT n FROM t WHERE false")->fetch()); + +echo "=== fewer rows than one chunk ===\n"; +echo " values: ", values($pdo->query("SELECT n FROM t WHERE n <= 2 ORDER BY n")), "\n"; + +echo "=== re-executing, including a run left unfinished ===\n"; +$stmt = $pdo->prepare("SELECT n FROM t WHERE n <= ? ORDER BY n"); +$stmt->execute([5]); +echo " first run, drained: ", values($stmt), "\n"; +$stmt->execute([10]); +$stmt->fetch(); +$stmt->execute([4]); +echo " third run, after abandoning the second: ", values($stmt), "\n"; +unset($stmt); + +echo "=== inside a transaction ===\n"; +$pdo->beginTransaction(); +echo " values: ", values($pdo->query("SELECT n FROM t WHERE n <= 7 ORDER BY n")), "\n"; +$pdo->commit(); + +echo "=== DML reports its own row count ===\n"; +$pdo->exec("CREATE TEMP TABLE t2 (n int)"); +$stmt = $pdo->prepare("INSERT INTO t2 SELECT n FROM t WHERE n <= 7"); +$stmt->execute(); +echo " inserted rows: "; +var_dump($stmt->rowCount()); + +?> +--EXPECT-- +=== fetch() === + values: 1,2,3,4,5,6,7,8,9,10 +=== fetchAll() === + values: 1,2,3,4,5,6,7,8,9,10 +=== empty result set === + first fetch() returns: bool(false) +=== fewer rows than one chunk === + values: 1,2 +=== re-executing, including a run left unfinished === + first run, drained: 1,2,3,4,5 + third run, after abandoning the second: 1,2,3,4 +=== inside a transaction === + values: 1,2,3,4,5,6,7 +=== DML reports its own row count === + inserted rows: int(7) diff --git a/ext/pdo_pgsql/tests/chunk_size_takeover.phpt b/ext/pdo_pgsql/tests/chunk_size_takeover.phpt new file mode 100644 index 000000000000..08a5837d0a01 --- /dev/null +++ b/ext/pdo_pgsql/tests/chunk_size_takeover.phpt @@ -0,0 +1,36 @@ +--TEST-- +PDO PgSQL a chunked statement whose stream was taken over reports no leftover rows +--EXTENSIONS-- +pdo_pgsql +--SKIPIF-- += 17 required'); +?> +--FILE-- +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +$pdo->setAttribute(Pdo\Pgsql::ATTR_CHUNK_SIZE, 5); + +$first = $pdo->prepare("SELECT * FROM generate_series(1, 10)"); +$first->execute(); +echo "first row: ", $first->fetch(PDO::FETCH_NUM)[0], "\n"; + +$pdo->query("SELECT * FROM generate_series(1, 10)")->fetch(); + +$rest = []; +while (($row = $first->fetch(PDO::FETCH_NUM))) { + $rest[] = $row[0]; +} +echo "rows afterwards: ", var_export(implode(',', $rest), true), "\n"; +echo "error code: ", $first->errorCode(), "\n"; +?> +--EXPECT-- +first row: 1 +rows afterwards: '' +error code: 00000