[php-src] master: ext/pdo_pgsql: Add Pdo\Pgsql::ATTR_CHUNK_SIZE for chunked result fetching

武田 憲太郎 via David Carlier <[email protected]>
Newsgroups gmane.comp.php.cvs.general
Message-ID <[email protected]>
Author: 武田 憲太郎 (KentarouTakeda)
Committer: David Carlier (devnexen)
Date: 2026-08-27T05:12:15+01:00

Commit: https://github.com/php/php-src/commit/f4a17fdefd18ab40807a82c4526c52fdcb4a875a
Raw diff: https://github.com/php/php-src/commit/f4a17fdefd18ab40807a82c4526c52fdcb4a875a.diff

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

Changed paths:
  A  ext/pdo_pgsql/tests/chunk_size.phpt
  A  ext/pdo_pgsql/tests/chunk_size_attr.phpt
  A  ext/pdo_pgsql/tests/chunk_size_error.phpt
  A  ext/pdo_pgsql/tests/chunk_size_error_64bit.phpt
  A  ext/pdo_pgsql/tests/chunk_size_fetch.phpt
  A  ext/pdo_pgsql/tests/chunk_size_prefetch.phpt
  A  ext/pdo_pgsql/tests/chunk_size_takeover.phpt
  M  NEWS
  M  UPGRADING
  M  ext/pdo_pgsql/config.m4
  M  ext/pdo_pgsql/pdo_pgsql.stub.php
  M  ext/pdo_pgsql/pdo_pgsql_arginfo.h
  M  ext/pdo_pgsql/pgsql_driver.c
  M  ext/pdo_pgsql/pgsql_statement.c
  M  ext/pdo_pgsql/php_pdo_pgsql_int.h


Diff:

diff --git a/NEWS b/NEWS
index 41ee6be929d6..d7c338421cf4 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 aa6e15aaea6a..10364c88b658 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 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 <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 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 5c96071c6774..cbfd55fc1aa6 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 4deba242bbb6..f6f87139958c 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--
+<?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 000000000000..7649b7b0e6ba
--- /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 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--
+<?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 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--
+<?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 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--
+<?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 000000000000..c845178a1aa9
--- /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 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--
+<?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
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.