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