Commit f4a17fdefd1 for php.net
commit f4a17fdefd18ab40807a82c4526c52fdcb4a875a
Author: 武田 憲太郎 <takeda@youmind.jp>
Date: Tue Aug 25 23:08:37 2026 +0000
ext/pdo_pgsql: Add Pdo\Pgsql::ATTR_CHUNK_SIZE for chunked result fetching
Generalizes the lazy fetch added in GH-15287 by letting a statement choose
how many rows it fetches per chunk, trading memory for speed. ext/pgsql
already exposed this as pg_set_chunked_rows_size(); ext/pdo_pgsql had no
way to reach it.
ATTR_PREFETCH and ATTR_CHUNK_SIZE replace each other: setting one on the
connection clears the other, a statement given either does not inherit the
connection's value, and a chunk size of 1 or more wins when both are set on
the same statement. A chunk size cannot be combined with a statement-level
PDO::ATTR_CURSOR => PDO::CURSOR_SCROLL, which throws a ValueError; a
connection-level chunk size is simply disabled by a scrollable statement.
Requires libpq 17 or later.
Close GH-23210
diff --git a/NEWS b/NEWS
index 41ee6be929d..d7c338421cf 100644
--- a/NEWS
+++ b/NEWS
@@ -12,6 +12,10 @@ PHP NEWS
. Fixed a leak in Locale::getKeywords() when a keyword value cannot be
read. (iliaal)
+- PDO_PGSQL:
+ . Added Pdo\Pgsql::ATTR_CHUNK_SIZE to fetch a result set in chunks of the
+ given number of rows. (KentarouTakeda)
+
- Phar:
. Fixed bug GH-23418 (Use-after-free when looking up mounted directories).
(Weilin Du)
diff --git a/UPGRADING b/UPGRADING
index aa6e15aaea6..10364c88b65 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -417,6 +417,14 @@ 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. Setting PDO::ATTR_PREFETCH replaces the chunk
+ size. Statements that are prepared with neither it nor PDO::ATTR_PREFETCH
+ fall back to the value set on the connection.
+ 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 1137a911c81..406f08465e9 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 <libpq-fe.h>]
+ )
+
+ 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 8322b88ca8d..6d4a1d0e263 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 80e87862b08..80127659a56 100644
Binary files a/ext/pdo_pgsql/pdo_pgsql_arginfo.h and b/ext/pdo_pgsql/pdo_pgsql_arginfo.h differ
diff --git a/ext/pdo_pgsql/pgsql_driver.c b/ext/pdo_pgsql/pgsql_driver.c
index 5c96071c677..cbfd55fc1aa 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;
@@ -285,13 +297,47 @@ static bool pgsql_handle_preparer(pdo_dbh_t *dbh, zend_string *sql, pdo_stmt_t *
scrollable = pdo_attr_lval(driver_options, PDO_ATTR_CURSOR,
PDO_CURSOR_FWDONLY) == PDO_CURSOR_SCROLL;
- S->is_unbuffered =
- driver_options
- && (val = zend_hash_index_find(Z_ARRVAL_P(driver_options), PDO_ATTR_PREFETCH))
- && pdo_get_long_param(&lval, val)
+ bool prefetch_given = driver_options
+ && (val = zend_hash_index_find(Z_ARRVAL_P(driver_options), PDO_ATTR_PREFETCH));
+
+ S->is_unbuffered = prefetch_given && pdo_get_long_param(&lval, val)
? !lval
- : H->default_fetching_laziness
- ;
+ : 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 if (prefetch_given) {
+ /* the statement's own prefetch replaces an inherited chunk size */
+ S->chunk_size = 0;
+ } 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
if (scrollable) {
S->is_unbuffered = false;
@@ -474,6 +520,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,7 +1429,22 @@ static bool pdo_pgsql_set_attr(pdo_dbh_t *dbh, zend_long attr, zval *val)
return false;
}
H->default_fetching_laziness = !bval;
+ H->default_chunk_size = 0;
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 4deba242bbb..f6f87139958 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 47c9223540a..a9a09a24b70 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 00000000000..0dc2787c646
--- /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--
+<?php
+require __DIR__ . '/config.inc';
+require __DIR__ . '/../../../ext/pdo/tests/pdo_test.inc';
+PDOTest::skip();
+if (!defined('Pdo\Pgsql::ATTR_CHUNK_SIZE')) die('skip libpq >= 17 required');
+?>
+--FILE--
+<?php
+
+require __DIR__ . '/../../../ext/pdo/tests/pdo_test.inc';
+$pdo = PDOTest::test_factory(__DIR__ . '/common.phpt');
+$pdo->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 00000000000..7649b7b0e6b
--- /dev/null
+++ b/ext/pdo_pgsql/tests/chunk_size_attr.phpt
@@ -0,0 +1,121 @@
+--TEST--
+PDO PgSQL Pdo\Pgsql::ATTR_CHUNK_SIZE resolution between the connection and the statement
+--EXTENSIONS--
+pdo_pgsql
+--SKIPIF--
+<?php
+require __DIR__ . '/config.inc';
+require __DIR__ . '/../../../ext/pdo/tests/pdo_test.inc';
+PDOTest::skip();
+if (!defined('Pdo\Pgsql::ATTR_CHUNK_SIZE')) die('skip libpq >= 17 required');
+?>
+--FILE--
+<?php
+
+require __DIR__ . '/../../../ext/pdo/tests/pdo_test.inc';
+$pdo = PDOTest::test_factory(__DIR__ . '/common.phpt');
+$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+
+// rowCount() reports the size of the chunk being consumed
+function report(PDO $pdo, PDOStatement $stmt): void
+{
+ $stmt->fetch();
+
+ printf(
+ " connection: %s, statement: %s, rows delivered at once: %d\n",
+ var_export($pdo->getAttribute(Pdo\Pgsql::ATTR_CHUNK_SIZE), true),
+ var_export($stmt->getAttribute(Pdo\Pgsql::ATTR_CHUNK_SIZE), true),
+ $stmt->rowCount(),
+ );
+}
+
+echo "=== nothing set ===\n";
+report($pdo, $pdo->query("SELECT * FROM generate_series(1, 10)"));
+
+echo "=== query() inherits a lazy ATTR_PREFETCH ===\n";
+$pdo->setAttribute(PDO::ATTR_PREFETCH, 0);
+report($pdo, $pdo->query("SELECT * FROM generate_series(1, 10)"));
+
+echo "=== the statement overrides an inherited ATTR_PREFETCH ===\n";
+$stmt = $pdo->prepare("SELECT * FROM generate_series(1, 10)", [PDO::ATTR_PREFETCH => 1]);
+$stmt->execute();
+report($pdo, $stmt);
+unset($stmt);
+$pdo->setAttribute(PDO::ATTR_PREFETCH, 1);
+
+echo "=== set on the statement only ===\n";
+$stmt = $pdo->prepare("SELECT * FROM generate_series(1, 10)", [Pdo\Pgsql::ATTR_CHUNK_SIZE => 4]);
+$stmt->execute();
+report($pdo, $stmt);
+unset($stmt);
+
+echo "=== set on the connection ===\n";
+$pdo->setAttribute(Pdo\Pgsql::ATTR_CHUNK_SIZE, 3);
+$stmt = $pdo->prepare("SELECT * FROM generate_series(1, 10)");
+$stmt->execute();
+report($pdo, $stmt);
+unset($stmt);
+
+echo "=== query() inherits the connection ===\n";
+report($pdo, $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();
+report($pdo, $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($pdo, $stmt);
+unset($stmt);
+
+echo "=== the statement asks to buffer ===\n";
+$stmt = $pdo->prepare("SELECT * FROM generate_series(1, 10)", [PDO::ATTR_PREFETCH => 1]);
+$stmt->execute();
+report($pdo, $stmt);
+unset($stmt);
+
+echo "=== the statement asks for lazy ===\n";
+$stmt = $pdo->prepare("SELECT * FROM generate_series(1, 10)", [PDO::ATTR_PREFETCH => 0]);
+$stmt->execute();
+report($pdo, $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($pdo, $stmt);
+unset($stmt);
+
+echo "=== back to 0 ===\n";
+$pdo->setAttribute(Pdo\Pgsql::ATTR_CHUNK_SIZE, 0);
+report($pdo, $pdo->query("SELECT * FROM generate_series(1, 10)"));
+?>
+--EXPECT--
+=== nothing set ===
+ connection: 0, statement: 0, rows delivered at once: 10
+=== query() inherits a lazy ATTR_PREFETCH ===
+ connection: 0, statement: 0, rows delivered at once: 1
+=== the statement overrides an inherited ATTR_PREFETCH ===
+ connection: 0, statement: 0, rows delivered at once: 10
+=== set on the statement only ===
+ connection: 0, statement: 4, rows delivered at once: 4
+=== set on the connection ===
+ connection: 3, statement: 3, rows delivered at once: 3
+=== query() inherits the connection ===
+ connection: 3, statement: 3, rows delivered at once: 3
+=== the statement overrides the connection ===
+ connection: 3, statement: 6, rows delivered at once: 6
+=== a chunk size wins over a buffering ATTR_PREFETCH ===
+ connection: 3, statement: 2, rows delivered at once: 2
+=== the statement asks to buffer ===
+ connection: 3, statement: 0, rows delivered at once: 10
+=== the statement asks for lazy ===
+ connection: 3, statement: 0, rows delivered at once: 1
+=== a statement can opt out of an inherited chunk size ===
+ connection: 3, statement: 0, rows delivered at once: 10
+=== back to 0 ===
+ connection: 0, statement: 0, rows delivered at once: 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 00000000000..670db99b79e
--- /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--
+<?php
+require __DIR__ . '/config.inc';
+require __DIR__ . '/../../../ext/pdo/tests/pdo_test.inc';
+PDOTest::skip();
+if (!defined('Pdo\Pgsql::ATTR_CHUNK_SIZE')) die('skip libpq >= 17 required');
+?>
+--FILE--
+<?php
+
+require __DIR__ . '/../../../ext/pdo/tests/pdo_test.inc';
+$pdo = PDOTest::test_factory(__DIR__ . '/common.phpt');
+$pdo->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 00000000000..b6da59d3e07
--- /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--
+<?php
+require __DIR__ . '/config.inc';
+require __DIR__ . '/../../../ext/pdo/tests/pdo_test.inc';
+PDOTest::skip();
+if (!defined('Pdo\Pgsql::ATTR_CHUNK_SIZE')) die('skip libpq >= 17 required');
+if (PHP_INT_SIZE < 8) die('skip a value above INT_MAX needs 64-bit');
+?>
+--FILE--
+<?php
+
+require __DIR__ . '/../../../ext/pdo/tests/pdo_test.inc';
+$pdo = PDOTest::test_factory(__DIR__ . '/common.phpt');
+$pdo->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 00000000000..f0195b92b03
--- /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--
+<?php
+require __DIR__ . '/config.inc';
+require __DIR__ . '/../../../ext/pdo/tests/pdo_test.inc';
+PDOTest::skip();
+if (!defined('Pdo\Pgsql::ATTR_CHUNK_SIZE')) die('skip libpq >= 17 required');
+?>
+--FILE--
+<?php
+
+require __DIR__ . '/../../../ext/pdo/tests/pdo_test.inc';
+$pdo = PDOTest::test_factory(__DIR__ . '/common.phpt');
+$pdo->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_prefetch.phpt b/ext/pdo_pgsql/tests/chunk_size_prefetch.phpt
new file mode 100644
index 00000000000..c845178a1aa
--- /dev/null
+++ b/ext/pdo_pgsql/tests/chunk_size_prefetch.phpt
@@ -0,0 +1,38 @@
+--TEST--
+PDO PgSQL a connection's PDO::ATTR_PREFETCH replaces its Pdo\Pgsql::ATTR_CHUNK_SIZE
+--EXTENSIONS--
+pdo_pgsql
+--SKIPIF--
+<?php
+require __DIR__ . '/config.inc';
+require __DIR__ . '/../../../ext/pdo/tests/pdo_test.inc';
+PDOTest::skip();
+if (!defined('Pdo\Pgsql::ATTR_CHUNK_SIZE')) die('skip libpq >= 17 required');
+?>
+--FILE--
+<?php
+
+require __DIR__ . '/../../../ext/pdo/tests/pdo_test.inc';
+$pdo = PDOTest::test_factory(__DIR__ . '/common.phpt');
+$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+
+// rowCount() reports the size of the chunk being consumed while fetching unbuffered
+function report(PDO $pdo, int $prefetch): void
+{
+ $pdo->setAttribute(Pdo\Pgsql::ATTR_CHUNK_SIZE, 5);
+ $pdo->setAttribute(PDO::ATTR_PREFETCH, $prefetch);
+
+ $stmt = $pdo->query("SELECT * FROM generate_series(1, 10)");
+ $stmt->fetch();
+
+ printf("PDO::ATTR_PREFETCH => %d: chunk size %d, rows delivered at once: %d\n",
+ $prefetch, $pdo->getAttribute(Pdo\Pgsql::ATTR_CHUNK_SIZE), $stmt->rowCount());
+}
+
+report($pdo, 1);
+report($pdo, 0);
+
+?>
+--EXPECT--
+PDO::ATTR_PREFETCH => 1: chunk size 0, rows delivered at once: 10
+PDO::ATTR_PREFETCH => 0: chunk size 0, rows delivered at once: 1
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 00000000000..08a5837d0a0
--- /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--
+<?php
+require __DIR__ . '/config.inc';
+require __DIR__ . '/../../../ext/pdo/tests/pdo_test.inc';
+PDOTest::skip();
+if (!defined('Pdo\Pgsql::ATTR_CHUNK_SIZE')) die('skip libpq >= 17 required');
+?>
+--FILE--
+<?php
+
+require __DIR__ . '/../../../ext/pdo/tests/pdo_test.inc';
+$pdo = PDOTest::test_factory(__DIR__ . '/common.phpt');
+$pdo->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