Jan, Michael,
Many thanks for all the great feedback! I applied all refactorings
except for the following.
> - Much better for me: add_int_reloption() to force a custom scan to
> return a number of rows you want with a CREATE TABLE .. WITH
> (repeat_count = N). That's doable once the library is loaded, and
> that should be a few extra lines of code. So let's add the reloption
> to control the scans rather than hardcoding it.
Perhaps I'm not fully understand the proposal but it doesn't seem to
work. Particularly after `git apply test.txt` I get:
```
TRAP: failed Assert("numoptions <= num_relopt_elems"), File:
"../src/backend/access/common/reloptions.c", Line: 2246, PID: 2077291
```
... in postmaster.log for a regular CREATE TABLE without the options.
Maybe you could clarify?
> - GUC to force a new per-query repeat_count.
Done this way.
--
Best regards,
Aleksander Alekseev
diff --git a/src/test/modules/test_extensible/test_extensible.c
b/src/test/modules/test_extensible/test_extensible.c
index d7710eee41b..ac4cdef28c2 100644
--- a/src/test/modules/test_extensible/test_extensible.c
+++ b/src/test/modules/test_extensible/test_extensible.c
@@ -14,6 +14,7 @@
*/
#include "postgres.h"
+#include "access/reloptions.h"
#include "access/table.h"
#include "access/tableam.h"
#include "executor/executor.h"
@@ -675,6 +676,10 @@ _PG_init(void)
/* Register the extensible node type */
RegisterExtensibleNodeMethods(&test_ext_node_methods);
+ add_int_reloption(RELOPT_KIND_HEAP, "repeat_count",
+ "Times each row is returned by the
test custom scan",
+ 2, 1, 100, AccessExclusiveLock);
+
DefineCustomIntVariable("test_extensible.repeat_count",
"Number of times the
custom scan returns each row.",
NULL,
From fd41ea8bab0d5d791238cf604e2386b974174ef8 Mon Sep 17 00:00:00 2001
From: Aleksander Alekseev <[email protected]>
Date: Tue, 31 Mar 2026 15:38:21 +0300
Subject: [PATCH v4] Add test module for src/backend/nodes/extensible.c
The new test implements a CustomScan that uses an ExtensibleNode to carry
private planning data from the planner into the executor. A SQL query against
a test table exercises the full pipeline end-to-end, while thin wrapper
functions cover GetExtensibleNodeMethods(), GetCustomScanMethods() and the
four ExtensibleNodeMethods callbacks. The CustomPath is parallel safe, so
debug_parallel_query also exercises nodeOut and nodeRead in the executor.
Author: Aleksander Alekseev <[email protected]>
Reviewed-by: Rafia Sabih <[email protected]>
Reviewed-by: Jan Nidzwetzki <[email protected]>
Reviewed-by: Michael Paquier <[email protected]>
Discussion: https://postgr.es/m/CAJ7c6TNfn9Fv_Je1etA6rrgq1onVvXbjwBTkbkd4kVQhcu11gg%40mail.gmail.com
---
src/test/modules/Makefile | 1 +
src/test/modules/meson.build | 1 +
src/test/modules/test_extensible/.gitignore | 4 +
src/test/modules/test_extensible/Makefile | 27 +
.../expected/test_extensible.out | 248 +++++++
src/test/modules/test_extensible/meson.build | 35 +
.../test_extensible/sql/test_extensible.sql | 127 ++++
.../test_extensible/test_extensible--1.0.sql | 25 +
.../modules/test_extensible/test_extensible.c | 694 ++++++++++++++++++
.../test_extensible/test_extensible.conf | 1 +
.../test_extensible/test_extensible.control | 4 +
src/tools/pgindent/typedefs.list | 2 +
12 files changed, 1169 insertions(+)
create mode 100644 src/test/modules/test_extensible/.gitignore
create mode 100644 src/test/modules/test_extensible/Makefile
create mode 100644 src/test/modules/test_extensible/expected/test_extensible.out
create mode 100644 src/test/modules/test_extensible/meson.build
create mode 100644 src/test/modules/test_extensible/sql/test_extensible.sql
create mode 100644 src/test/modules/test_extensible/test_extensible--1.0.sql
create mode 100644 src/test/modules/test_extensible/test_extensible.c
create mode 100644 src/test/modules/test_extensible/test_extensible.conf
create mode 100644 src/test/modules/test_extensible/test_extensible.control
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index bb88b3058ed..71a2e65ad70 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -30,6 +30,7 @@ SUBDIRS = \
test_dsa \
test_dsm_registry \
test_escape \
+ test_extensible \
test_extensions \
test_ginpostinglist \
test_int128 \
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index ce09e00531d..77e1a2810e5 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -31,6 +31,7 @@ subdir('test_ddl_deparse')
subdir('test_dsa')
subdir('test_dsm_registry')
subdir('test_escape')
+subdir('test_extensible')
subdir('test_extensions')
subdir('test_ginpostinglist')
subdir('test_int128')
diff --git a/src/test/modules/test_extensible/.gitignore b/src/test/modules/test_extensible/.gitignore
new file mode 100644
index 00000000000..5dcb3ff9723
--- /dev/null
+++ b/src/test/modules/test_extensible/.gitignore
@@ -0,0 +1,4 @@
+# Generated subdirectories
+/log/
+/results/
+/tmp_check/
diff --git a/src/test/modules/test_extensible/Makefile b/src/test/modules/test_extensible/Makefile
new file mode 100644
index 00000000000..1369ae8e52b
--- /dev/null
+++ b/src/test/modules/test_extensible/Makefile
@@ -0,0 +1,27 @@
+# src/test/modules/test_extensible/Makefile
+
+MODULE_big = test_extensible
+OBJS = \
+ $(WIN32RES) \
+ test_extensible.o
+PGFILEDESC = "test_extensible - test module for extensible node and custom scan registration"
+
+EXTENSION = test_extensible
+DATA = test_extensible--1.0.sql
+
+REGRESS_OPTS = --temp-config $(top_srcdir)/src/test/modules/test_extensible/test_extensible.conf
+REGRESS = test_extensible
+# Disabled because these tests require "shared_preload_libraries=test_extensible",
+# which typical installcheck users do not have (e.g. buildfarm clients).
+NO_INSTALLCHECK = 1
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/test_extensible
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/test_extensible/expected/test_extensible.out b/src/test/modules/test_extensible/expected/test_extensible.out
new file mode 100644
index 00000000000..3fff7183e2d
--- /dev/null
+++ b/src/test/modules/test_extensible/expected/test_extensible.out
@@ -0,0 +1,248 @@
+-- Tests for extensible node and custom scan registration
+-- (src/backend/nodes/extensible.c)
+CREATE EXTENSION test_extensible;
+-- ----------------------------------------------------------------
+-- GetExtensibleNodeMethods() and GetCustomScanMethods() lookup tests
+-- ----------------------------------------------------------------
+-- GetExtensibleNodeMethods: known name returns the registered extnodename.
+SELECT test_get_extensible_node_methods('TestExtNode', false);
+ test_get_extensible_node_methods
+----------------------------------
+ TestExtNode
+(1 row)
+
+-- GetExtensibleNodeMethods: unknown name with missing_ok=true returns NULL.
+SELECT test_get_extensible_node_methods('NoSuchExtNode', true);
+ test_get_extensible_node_methods
+----------------------------------
+
+(1 row)
+
+-- GetExtensibleNodeMethods: unknown name with missing_ok=false raises ERROR.
+SELECT test_get_extensible_node_methods('NoSuchExtNode', false);
+ERROR: ExtensibleNodeMethods "NoSuchExtNode" was not registered
+-- GetCustomScanMethods: known name returns the registered CustomName.
+SELECT test_get_custom_scan_methods('TestCustomScan', false);
+ test_get_custom_scan_methods
+------------------------------
+ TestCustomScan
+(1 row)
+
+-- GetCustomScanMethods: unknown name with missing_ok=true returns NULL.
+SELECT test_get_custom_scan_methods('NoSuchCustomScan', true);
+ test_get_custom_scan_methods
+------------------------------
+
+(1 row)
+
+-- GetCustomScanMethods: unknown name with missing_ok=false raises ERROR.
+SELECT test_get_custom_scan_methods('NoSuchCustomScan', false);
+ERROR: ExtensibleNodeMethods "NoSuchCustomScan" was not registered
+-- Both lookup functions are STRICT, so a NULL argument yields NULL rather than
+-- reaching the C code, which would dereference the argument unconditionally.
+SELECT test_get_extensible_node_methods(NULL, false) IS NULL AS null_name;
+ null_name
+-----------
+ t
+(1 row)
+
+SELECT test_get_extensible_node_methods('TestExtNode', NULL) IS NULL AS null_missing_ok;
+ null_missing_ok
+-----------------
+ t
+(1 row)
+
+SELECT test_get_custom_scan_methods(NULL, false) IS NULL AS null_name;
+ null_name
+-----------
+ t
+(1 row)
+
+SELECT test_get_custom_scan_methods('TestCustomScan', NULL) IS NULL AS null_missing_ok;
+ null_missing_ok
+-----------------
+ t
+(1 row)
+
+-- ----------------------------------------------------------------
+-- ExtensibleNodeMethods callbacks: nodeCopy, nodeEqual, nodeOut, nodeRead
+-- ----------------------------------------------------------------
+-- A TestExtNode travels between these functions in its serialized form, so
+-- every one of them runs nodeOut, nodeRead or both. The text below is what the
+-- nodeOut callback produced.
+SELECT test_ext_node_make('1234'::oid, 2);
+ test_ext_node_make
+-----------------------------------------------------------------------
+ {EXTENSIBLENODE :extnodename TestExtNode :relid 1234 :repeat_count 2}
+(1 row)
+
+-- nodeRead has to restore both fields exactly as nodeOut wrote them.
+SELECT test_ext_node_get_relid(n) AS relid,
+ test_ext_node_get_repeat_count(n) AS repeat_count
+ FROM (SELECT test_ext_node_make('1234'::oid, 2)) AS s(n);
+ relid | repeat_count
+-------+--------------
+ 1234 | 2
+(1 row)
+
+-- nodeCopy: the copy compares equal to the original and, since it has to copy
+-- every field, serializes to the very same string.
+SELECT test_ext_node_equal(n, test_ext_node_copy(n)) AS copy_is_equal,
+ test_ext_node_copy(n) = n AS copy_is_identical
+ FROM (SELECT test_ext_node_make('1234'::oid, 2)) AS s(n);
+ copy_is_equal | copy_is_identical
+---------------+-------------------
+ t | t
+(1 row)
+
+-- nodeEqual: nodes differing in either field must not compare equal.
+SELECT test_ext_node_equal(test_ext_node_make('1234'::oid, 2),
+ test_ext_node_make('1234'::oid, 3)) AS other_repeat_count,
+ test_ext_node_equal(test_ext_node_make('1234'::oid, 2),
+ test_ext_node_make('5678'::oid, 2)) AS other_relid;
+ other_repeat_count | other_relid
+--------------------+-------------
+ f | f
+(1 row)
+
+-- A string describing some other kind of node is rejected rather than being
+-- misinterpreted as a TestExtNode. '(b 1 2)' is a serialized Bitmapset.
+SELECT test_ext_node_get_repeat_count('(b 1 2)');
+ERROR: argument is not a serialized "TestExtNode"
+-- A node of the right type but with fields missing is rejected as well, rather
+-- than reaching the field conversions with a NULL token.
+SELECT test_ext_node_get_relid('{EXTENSIBLENODE :extnodename TestExtNode}');
+ERROR: unexpected end of "TestExtNode"
+-- ----------------------------------------------------------------
+-- End-to-end CustomScan test
+-- ----------------------------------------------------------------
+CREATE TABLE test_extensible_tbl (id integer, val text);
+INSERT INTO test_extensible_tbl VALUES (1, 'one'), (2, 'two'), (3, 'three');
+-- Verify the planner chose our CustomScan (not a SeqScan).
+EXPLAIN (COSTS OFF) SELECT id, val FROM test_extensible_tbl ORDER BY id;
+ QUERY PLAN
+-----------------------------------------------------------
+ Sort
+ Sort Key: id
+ -> Custom Scan (TestCustomScan) on test_extensible_tbl
+(3 rows)
+
+-- Execute through the CustomScan; each of the 3 inserted rows appears
+-- twice (6 rows total), proving the custom scan logic is in effect.
+SELECT id, val FROM test_extensible_tbl ORDER BY id;
+ id | val
+----+-------
+ 1 | one
+ 1 | one
+ 2 | two
+ 2 | two
+ 3 | three
+ 3 | three
+(6 rows)
+
+-- The restriction clauses are passed to the CustomScan as its qual.
+EXPLAIN (COSTS OFF) SELECT val FROM test_extensible_tbl WHERE id > 1;
+ QUERY PLAN
+-----------------------------------------------------
+ Custom Scan (TestCustomScan) on test_extensible_tbl
+ Filter: (id > 1)
+(2 rows)
+
+SELECT val FROM test_extensible_tbl WHERE id > 1 ORDER BY id;
+ val
+-------
+ two
+ two
+ three
+ three
+(4 rows)
+
+-- How many times a row is repeated is controlled by a GUC. It is read while
+-- planning and stored in the ExtensibleNode, so it is the value in effect at
+-- plan time that counts.
+SET test_extensible.repeat_count = 3;
+SELECT id, val FROM test_extensible_tbl ORDER BY id;
+ id | val
+----+-------
+ 1 | one
+ 1 | one
+ 1 | one
+ 2 | two
+ 2 | two
+ 2 | two
+ 3 | three
+ 3 | three
+ 3 | three
+(9 rows)
+
+RESET test_extensible.repeat_count;
+-- Out-of-range values are rejected by the GUC machinery.
+SET test_extensible.repeat_count = 0;
+ERROR: 0 is outside the valid range for parameter "test_extensible.repeat_count" (1 .. 100)
+-- Scans our executor callbacks cannot implement are left to the core. Without
+-- this the sample scan would be replaced and the whole table returned.
+EXPLAIN (COSTS OFF) SELECT id FROM test_extensible_tbl TABLESAMPLE SYSTEM (0);
+ QUERY PLAN
+------------------------------------
+ Sample Scan on test_extensible_tbl
+ Sampling: system ('0'::real)
+(2 rows)
+
+SELECT count(*) FROM test_extensible_tbl TABLESAMPLE SYSTEM (0);
+ count
+-------
+ 0
+(1 row)
+
+-- ----------------------------------------------------------------
+-- The CustomScan below a Gather
+-- ----------------------------------------------------------------
+-- Our CustomPath is parallel safe, so the plan can be pushed below a Gather.
+-- This is the only case in which the core round-trips our ExtensibleNode
+-- through nodeOut and nodeRead on its own: the plan tree is serialized into
+-- dynamic shared memory and read back by the parallel worker.
+--
+-- max_parallel_workers_per_gather is set explicitly because a value of 0 would
+-- silently switch parallelism off altogether, leaving these queries passing
+-- but testing nothing.
+SET max_parallel_workers_per_gather = 2;
+-- With debug_parallel_query = on the Gather is visible, which is what proves
+-- the plan really is being serialized.
+SET debug_parallel_query = on;
+EXPLAIN (COSTS OFF) SELECT id, val FROM test_extensible_tbl ORDER BY id;
+ QUERY PLAN
+-----------------------------------------------------------------
+ Gather
+ Workers Planned: 1
+ Single Copy: true
+ -> Sort
+ Sort Key: id
+ -> Custom Scan (TestCustomScan) on test_extensible_tbl
+(6 rows)
+
+-- In the regress mode the Gather is hidden, so the plan is expected to look
+-- exactly like the serial one further up.
+SET debug_parallel_query = regress;
+EXPLAIN (COSTS OFF) SELECT id, val FROM test_extensible_tbl ORDER BY id;
+ QUERY PLAN
+-----------------------------------------------------------
+ Sort
+ Sort Key: id
+ -> Custom Scan (TestCustomScan) on test_extensible_tbl
+(3 rows)
+
+SELECT id, val FROM test_extensible_tbl ORDER BY id;
+ id | val
+----+-------
+ 1 | one
+ 1 | one
+ 2 | two
+ 2 | two
+ 3 | three
+ 3 | three
+(6 rows)
+
+RESET debug_parallel_query;
+RESET max_parallel_workers_per_gather;
+DROP TABLE test_extensible_tbl;
+DROP EXTENSION test_extensible;
diff --git a/src/test/modules/test_extensible/meson.build b/src/test/modules/test_extensible/meson.build
new file mode 100644
index 00000000000..bcd5312d1a9
--- /dev/null
+++ b/src/test/modules/test_extensible/meson.build
@@ -0,0 +1,35 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+test_extensible_sources = files(
+ 'test_extensible.c',
+)
+
+if host_system == 'windows'
+ test_extensible_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+ '--NAME', 'test_extensible',
+ '--FILEDESC', 'test_extensible - test module for extensible node and custom scan registration',])
+endif
+
+test_extensible = shared_module('test_extensible',
+ test_extensible_sources,
+ kwargs: pg_test_mod_args,
+)
+test_install_libs += test_extensible
+
+test_install_data += files(
+ 'test_extensible.control',
+ 'test_extensible--1.0.sql',
+)
+
+tests += {
+ 'name': 'test_extensible',
+ 'sd': meson.current_source_dir(),
+ 'bd': meson.current_build_dir(),
+ 'regress': {
+ 'sql': [
+ 'test_extensible',
+ ],
+ 'regress_args': ['--temp-config', files('test_extensible.conf')],
+ 'runningcheck': false,
+ },
+}
diff --git a/src/test/modules/test_extensible/sql/test_extensible.sql b/src/test/modules/test_extensible/sql/test_extensible.sql
new file mode 100644
index 00000000000..e9e791b6786
--- /dev/null
+++ b/src/test/modules/test_extensible/sql/test_extensible.sql
@@ -0,0 +1,127 @@
+-- Tests for extensible node and custom scan registration
+-- (src/backend/nodes/extensible.c)
+
+CREATE EXTENSION test_extensible;
+
+-- ----------------------------------------------------------------
+-- GetExtensibleNodeMethods() and GetCustomScanMethods() lookup tests
+-- ----------------------------------------------------------------
+
+-- GetExtensibleNodeMethods: known name returns the registered extnodename.
+SELECT test_get_extensible_node_methods('TestExtNode', false);
+-- GetExtensibleNodeMethods: unknown name with missing_ok=true returns NULL.
+SELECT test_get_extensible_node_methods('NoSuchExtNode', true);
+-- GetExtensibleNodeMethods: unknown name with missing_ok=false raises ERROR.
+SELECT test_get_extensible_node_methods('NoSuchExtNode', false);
+
+-- GetCustomScanMethods: known name returns the registered CustomName.
+SELECT test_get_custom_scan_methods('TestCustomScan', false);
+-- GetCustomScanMethods: unknown name with missing_ok=true returns NULL.
+SELECT test_get_custom_scan_methods('NoSuchCustomScan', true);
+-- GetCustomScanMethods: unknown name with missing_ok=false raises ERROR.
+SELECT test_get_custom_scan_methods('NoSuchCustomScan', false);
+
+-- Both lookup functions are STRICT, so a NULL argument yields NULL rather than
+-- reaching the C code, which would dereference the argument unconditionally.
+SELECT test_get_extensible_node_methods(NULL, false) IS NULL AS null_name;
+SELECT test_get_extensible_node_methods('TestExtNode', NULL) IS NULL AS null_missing_ok;
+SELECT test_get_custom_scan_methods(NULL, false) IS NULL AS null_name;
+SELECT test_get_custom_scan_methods('TestCustomScan', NULL) IS NULL AS null_missing_ok;
+
+-- ----------------------------------------------------------------
+-- ExtensibleNodeMethods callbacks: nodeCopy, nodeEqual, nodeOut, nodeRead
+-- ----------------------------------------------------------------
+
+-- A TestExtNode travels between these functions in its serialized form, so
+-- every one of them runs nodeOut, nodeRead or both. The text below is what the
+-- nodeOut callback produced.
+SELECT test_ext_node_make('1234'::oid, 2);
+
+-- nodeRead has to restore both fields exactly as nodeOut wrote them.
+SELECT test_ext_node_get_relid(n) AS relid,
+ test_ext_node_get_repeat_count(n) AS repeat_count
+ FROM (SELECT test_ext_node_make('1234'::oid, 2)) AS s(n);
+
+-- nodeCopy: the copy compares equal to the original and, since it has to copy
+-- every field, serializes to the very same string.
+SELECT test_ext_node_equal(n, test_ext_node_copy(n)) AS copy_is_equal,
+ test_ext_node_copy(n) = n AS copy_is_identical
+ FROM (SELECT test_ext_node_make('1234'::oid, 2)) AS s(n);
+
+-- nodeEqual: nodes differing in either field must not compare equal.
+SELECT test_ext_node_equal(test_ext_node_make('1234'::oid, 2),
+ test_ext_node_make('1234'::oid, 3)) AS other_repeat_count,
+ test_ext_node_equal(test_ext_node_make('1234'::oid, 2),
+ test_ext_node_make('5678'::oid, 2)) AS other_relid;
+
+-- A string describing some other kind of node is rejected rather than being
+-- misinterpreted as a TestExtNode. '(b 1 2)' is a serialized Bitmapset.
+SELECT test_ext_node_get_repeat_count('(b 1 2)');
+
+-- A node of the right type but with fields missing is rejected as well, rather
+-- than reaching the field conversions with a NULL token.
+SELECT test_ext_node_get_relid('{EXTENSIBLENODE :extnodename TestExtNode}');
+
+-- ----------------------------------------------------------------
+-- End-to-end CustomScan test
+-- ----------------------------------------------------------------
+
+CREATE TABLE test_extensible_tbl (id integer, val text);
+INSERT INTO test_extensible_tbl VALUES (1, 'one'), (2, 'two'), (3, 'three');
+
+-- Verify the planner chose our CustomScan (not a SeqScan).
+EXPLAIN (COSTS OFF) SELECT id, val FROM test_extensible_tbl ORDER BY id;
+
+-- Execute through the CustomScan; each of the 3 inserted rows appears
+-- twice (6 rows total), proving the custom scan logic is in effect.
+SELECT id, val FROM test_extensible_tbl ORDER BY id;
+
+-- The restriction clauses are passed to the CustomScan as its qual.
+EXPLAIN (COSTS OFF) SELECT val FROM test_extensible_tbl WHERE id > 1;
+SELECT val FROM test_extensible_tbl WHERE id > 1 ORDER BY id;
+
+-- How many times a row is repeated is controlled by a GUC. It is read while
+-- planning and stored in the ExtensibleNode, so it is the value in effect at
+-- plan time that counts.
+SET test_extensible.repeat_count = 3;
+SELECT id, val FROM test_extensible_tbl ORDER BY id;
+RESET test_extensible.repeat_count;
+
+-- Out-of-range values are rejected by the GUC machinery.
+SET test_extensible.repeat_count = 0;
+
+-- Scans our executor callbacks cannot implement are left to the core. Without
+-- this the sample scan would be replaced and the whole table returned.
+EXPLAIN (COSTS OFF) SELECT id FROM test_extensible_tbl TABLESAMPLE SYSTEM (0);
+SELECT count(*) FROM test_extensible_tbl TABLESAMPLE SYSTEM (0);
+
+-- ----------------------------------------------------------------
+-- The CustomScan below a Gather
+-- ----------------------------------------------------------------
+
+-- Our CustomPath is parallel safe, so the plan can be pushed below a Gather.
+-- This is the only case in which the core round-trips our ExtensibleNode
+-- through nodeOut and nodeRead on its own: the plan tree is serialized into
+-- dynamic shared memory and read back by the parallel worker.
+--
+-- max_parallel_workers_per_gather is set explicitly because a value of 0 would
+-- silently switch parallelism off altogether, leaving these queries passing
+-- but testing nothing.
+SET max_parallel_workers_per_gather = 2;
+
+-- With debug_parallel_query = on the Gather is visible, which is what proves
+-- the plan really is being serialized.
+SET debug_parallel_query = on;
+EXPLAIN (COSTS OFF) SELECT id, val FROM test_extensible_tbl ORDER BY id;
+
+-- In the regress mode the Gather is hidden, so the plan is expected to look
+-- exactly like the serial one further up.
+SET debug_parallel_query = regress;
+EXPLAIN (COSTS OFF) SELECT id, val FROM test_extensible_tbl ORDER BY id;
+SELECT id, val FROM test_extensible_tbl ORDER BY id;
+RESET debug_parallel_query;
+RESET max_parallel_workers_per_gather;
+
+DROP TABLE test_extensible_tbl;
+
+DROP EXTENSION test_extensible;
diff --git a/src/test/modules/test_extensible/test_extensible--1.0.sql b/src/test/modules/test_extensible/test_extensible--1.0.sql
new file mode 100644
index 00000000000..cd904317e7a
--- /dev/null
+++ b/src/test/modules/test_extensible/test_extensible--1.0.sql
@@ -0,0 +1,25 @@
+/* src/test/modules/test_extensible/test_extensible--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION test_extensible" to load this file. \quit
+
+CREATE FUNCTION test_get_extensible_node_methods(text, bool) RETURNS text
+ AS 'MODULE_PATHNAME', 'test_get_extensible_node_methods' LANGUAGE C STRICT;
+
+CREATE FUNCTION test_get_custom_scan_methods(text, bool) RETURNS text
+ AS 'MODULE_PATHNAME', 'test_get_custom_scan_methods' LANGUAGE C STRICT;
+
+CREATE FUNCTION test_ext_node_make(oid, int) RETURNS text
+ AS 'MODULE_PATHNAME', 'test_ext_node_make' LANGUAGE C STRICT;
+
+CREATE FUNCTION test_ext_node_copy(text) RETURNS text
+ AS 'MODULE_PATHNAME', 'test_ext_node_copy' LANGUAGE C STRICT;
+
+CREATE FUNCTION test_ext_node_equal(text, text) RETURNS bool
+ AS 'MODULE_PATHNAME', 'test_ext_node_equal' LANGUAGE C STRICT;
+
+CREATE FUNCTION test_ext_node_get_relid(text) RETURNS oid
+ AS 'MODULE_PATHNAME', 'test_ext_node_get_relid' LANGUAGE C STRICT;
+
+CREATE FUNCTION test_ext_node_get_repeat_count(text) RETURNS int
+ AS 'MODULE_PATHNAME', 'test_ext_node_get_repeat_count' LANGUAGE C STRICT;
diff --git a/src/test/modules/test_extensible/test_extensible.c b/src/test/modules/test_extensible/test_extensible.c
new file mode 100644
index 00000000000..d7710eee41b
--- /dev/null
+++ b/src/test/modules/test_extensible/test_extensible.c
@@ -0,0 +1,694 @@
+/*-------------------------------------------------------------------------
+ *
+ * test_extensible.c
+ * Test correctness of extensible node and custom scan registration
+ * functions. For more details see "Writing a Custom Scan Provider" in
+ * the documentation.
+ *
+ * Copyright (c) 2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ * src/test/modules/test_extensible/test_extensible.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/table.h"
+#include "access/tableam.h"
+#include "executor/executor.h"
+#include "fmgr.h"
+#include "miscadmin.h"
+#include "nodes/extensible.h"
+#include "nodes/nodes.h"
+#include "nodes/plannodes.h"
+#include "nodes/readfuncs.h"
+#include "optimizer/pathnode.h"
+#include "optimizer/paths.h"
+#include "optimizer/restrictinfo.h"
+#include "utils/builtins.h"
+#include "utils/guc.h"
+#include "utils/lsyscache.h"
+
+PG_MODULE_MAGIC;
+
+/* Name of the test table that triggers our CustomScan injection */
+#define TEST_TABLE_NAME "test_extensible_tbl"
+
+/*
+ * TestExtNode - an ExtensibleNode subtype carrying our planning data.
+ */
+typedef struct TestExtNode
+{
+ ExtensibleNode base; /* must be first */
+ Oid relid; /* OID of the relation being scanned */
+ int repeat_count; /* how many times to return each scanned row */
+} TestExtNode;
+
+#define TEST_EXT_NODE_NAME "TestExtNode"
+#define TEST_CUSTOM_SCAN_NAME "TestCustomScan"
+
+/* GUC: how many times the custom scan returns each row */
+static int test_repeat_count = 2;
+
+/*
+ * ExtensibleNodeMethods callbacks
+ *
+ * We never call these ourselves. The generic node routines dispatch to them
+ * via GetExtensibleNodeMethods() whenever they meet a T_ExtensibleNode:
+ * copyObject() calls nodeCopy, equal() calls nodeEqual, nodeToString() calls
+ * nodeOut and stringToNode() calls nodeRead. Note that nodeOut and nodeRead
+ * have to agree on the set and the order of the serialized fields.
+ */
+
+static void
+test_ext_node_copy_cb(ExtensibleNode *newnode, const ExtensibleNode *oldnode)
+{
+ ((TestExtNode *) newnode)->relid = ((const TestExtNode *) oldnode)->relid;
+ ((TestExtNode *) newnode)->repeat_count =
+ ((const TestExtNode *) oldnode)->repeat_count;
+}
+
+static bool
+test_ext_node_equal_cb(const ExtensibleNode *a, const ExtensibleNode *b)
+{
+ return ((const TestExtNode *) a)->relid ==
+ ((const TestExtNode *) b)->relid &&
+ ((const TestExtNode *) a)->repeat_count ==
+ ((const TestExtNode *) b)->repeat_count;
+}
+
+static void
+test_ext_node_out_cb(StringInfo str, const ExtensibleNode *node)
+{
+ appendStringInfo(str, " :relid %u", ((const TestExtNode *) node)->relid);
+ appendStringInfo(str, " :repeat_count %d",
+ ((const TestExtNode *) node)->repeat_count);
+}
+
+/*
+ * Fetch the next token, which is required to be there.
+ *
+ * readfuncs.c can afford to skip this check because it only ever parses
+ * strings that the core itself produced. This callback is also reachable with
+ * an arbitrary string through the SQL functions at the bottom of this file, so
+ * a truncated node must not be allowed to reach atoi() with a NULL pointer.
+ */
+static const char *
+test_ext_node_next_token(ReadNodeContext *ctx)
+{
+ int length;
+ const char *token = pg_strtok(ctx, &length);
+
+ if (token == NULL)
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
+ errmsg("unexpected end of \"%s\"", TEST_EXT_NODE_NAME)));
+
+ return token;
+}
+
+static void
+test_ext_node_read_cb(ReadNodeContext *ctx, ExtensibleNode *node)
+{
+ TestExtNode *tnode = (TestExtNode *) node;
+
+ (void) test_ext_node_next_token(ctx); /* skip :relid */
+ tnode->relid = atooid(test_ext_node_next_token(ctx));
+
+ (void) test_ext_node_next_token(ctx); /* skip :repeat_count */
+ tnode->repeat_count = atoi(test_ext_node_next_token(ctx));
+}
+
+static const ExtensibleNodeMethods test_ext_node_methods =
+{
+ .extnodename = TEST_EXT_NODE_NAME,
+ .node_size = sizeof(TestExtNode),
+ .nodeCopy = test_ext_node_copy_cb,
+ .nodeEqual = test_ext_node_equal_cb,
+ .nodeOut = test_ext_node_out_cb,
+ .nodeRead = test_ext_node_read_cb,
+};
+
+/*
+ * TestCustomScanState - execution state for the custom scan
+ */
+typedef struct TestCustomScanState
+{
+ CustomScanState css; /* must be first */
+ TableScanDesc scandesc;
+ int repeat_count; /* repeat_count from TestExtNode */
+ int repeats_left; /* how many more times to return current row */
+} TestCustomScanState;
+
+/*
+ * Executor callbacks
+ */
+
+/*
+ * Retrieve our private planning data from a CustomScan node. This is the place
+ * where the ExtensibleNode crosses from the plan tree into the executor.
+ */
+static TestExtNode *
+test_get_ext_node(CustomScan *cscan)
+{
+ TestExtNode *tnode;
+
+ Assert(list_length(cscan->custom_private) == 1);
+ tnode = (TestExtNode *) linitial(cscan->custom_private);
+ Assert(IsA(tnode, ExtensibleNode));
+ Assert(strcmp(tnode->base.extnodename, TEST_EXT_NODE_NAME) == 0);
+
+ return tnode;
+}
+
+/*
+ * BeginCustomScan is the last thing ExecInitCustomScan() does, so the generic
+ * parts of the CustomScanState - the scan relation and the tuple slots among
+ * them - are ready by now. See "Custom Scan Execution Callbacks".
+ */
+static void
+test_begin_custom_scan(CustomScanState *node, EState *estate, int eflags)
+{
+ TestCustomScanState *tstate = (TestCustomScanState *) node;
+ TestExtNode *tnode = test_get_ext_node((CustomScan *) node->ss.ps.plan);
+ Relation rel = node->ss.ss_currentRelation;
+
+ Assert(tnode->repeat_count > 0);
+ tstate->repeat_count = tnode->repeat_count;
+ tstate->repeats_left = 0;
+ tstate->scandesc = NULL;
+
+ /*
+ * A plain EXPLAIN initializes the plan but never executes it, so there is
+ * no point in starting a scan. The built-in scan nodes all return early
+ * here as well.
+ */
+ if (eflags & EXEC_FLAG_EXPLAIN_ONLY)
+ return;
+
+ /* Start a plain sequential table scan */
+ tstate->scandesc = table_beginscan(rel, estate->es_snapshot, 0, NULL,
+ SO_NONE);
+}
+
+/*
+ * Access method for ExecScan(): return the next tuple to be considered, or
+ * NULL when the scan is done
+ */
+static TupleTableSlot *
+test_scan_next(ScanState *node)
+{
+ TestCustomScanState *tstate = (TestCustomScanState *) node;
+ TupleTableSlot *slot = node->ss_ScanTupleSlot;
+
+ /*
+ * If the current tuple still has repeats remaining, return it again
+ * without advancing the heap scan. The repeat count comes from the
+ * TestExtNode that was read in BeginCustomScan.
+ */
+ if (tstate->repeats_left > 0)
+ {
+ tstate->repeats_left--;
+ return slot;
+ }
+
+ /* Fetch the next tuple from the heap */
+ if (!table_scan_getnextslot(tstate->scandesc, ForwardScanDirection, slot))
+ return NULL;
+
+ /* Schedule (repeat_count - 1) additional returns of this tuple */
+ tstate->repeats_left = tstate->repeat_count - 1;
+ return slot;
+}
+
+/*
+ * Recheck method for ExecScan(), used only during EvalPlanQual rechecks. Our
+ * tuples come straight from an MVCC-checked table scan and we evaluate no
+ * quals of our own, so there is nothing to recheck.
+ */
+static bool
+test_scan_recheck(ScanState *node, TupleTableSlot *slot)
+{
+ return true;
+}
+
+static TupleTableSlot *
+test_exec_custom_scan(CustomScanState *node)
+{
+ /*
+ * The core code applies neither the qual nor the projection to the tuples
+ * we return, so we hand the work over to the generic ExecScan()
+ * machinery, exactly as the built-in scan nodes do
+ */
+ return ExecScan(&node->ss, test_scan_next, test_scan_recheck);
+}
+
+static void
+test_end_custom_scan(CustomScanState *node)
+{
+ TestCustomScanState *tstate = (TestCustomScanState *) node;
+
+ /* No scan was started under EXEC_FLAG_EXPLAIN_ONLY */
+ if (tstate->scandesc != NULL)
+ table_endscan(tstate->scandesc);
+}
+
+/*
+ * ReScanCustomScan has to reset our own state as well as the state kept by the
+ * ExecScan() machinery
+ */
+static void
+test_rescan_custom_scan(CustomScanState *node)
+{
+ TestCustomScanState *tstate = (TestCustomScanState *) node;
+
+ tstate->repeats_left = 0;
+ table_rescan(tstate->scandesc, NULL);
+ ExecScanReScan(&node->ss);
+}
+
+static const CustomExecMethods test_custom_exec_methods =
+{
+ .CustomName = TEST_CUSTOM_SCAN_NAME,
+ .BeginCustomScan = test_begin_custom_scan,
+ .ExecCustomScan = test_exec_custom_scan,
+ .EndCustomScan = test_end_custom_scan,
+ .ReScanCustomScan = test_rescan_custom_scan,
+};
+
+/*
+ * CreateCustomScanState allocates the CustomScanState and fills in its node
+ * tag and its methods; everything else is left to ExecInitCustomScan(). See
+ * "Custom Scan Plan Callbacks".
+ *
+ * slotOps is the one exception to the "leave the other fields zeroed" rule
+ * stated there: ExecInitCustomScan() reads it before creating the scan tuple
+ * slot, so this callback is the only place where a provider can choose
+ * anything other than the default TTSOpsVirtual.
+ */
+static Node *
+test_create_custom_scan_state(CustomScan *cscan)
+{
+ TestCustomScanState *tstate;
+ TestExtNode *tnode = test_get_ext_node(cscan);
+ Relation rel;
+
+ tstate = (TestCustomScanState *)
+ newNode(sizeof(TestCustomScanState), T_CustomScanState);
+ tstate->css.methods = &test_custom_exec_methods;
+
+ /*
+ * Tell ExecInitCustomScan() which kind of tuple slot our ExecCustomScan
+ * callback is going to fill, so that table_scan_getnextslot() can store
+ * tuples in it directly. The answer depends on the table AM, hence
+ * table_slot_callbacks().
+ *
+ * ExecInitCustomScan() opens the scan relation only after this callback
+ * returns, but the slot type has to be known by then. So we open the
+ * relation ourselves. Its OID was passed down from the planner in our
+ * ExtensibleNode.
+ *
+ * We have to take the lock rather than pass NoLock: in a parallel worker
+ * nothing has locked the relation yet at this point. The worker acquires
+ * its own lock later, in ExecGetRangeTableRelation(), once
+ * ExecInitCustomScan() gets to open the relation. AccessShareLock is what
+ * a plain scan uses, so this never introduces an extra conflict, and it
+ * is held until end of transaction as usual.
+ */
+ rel = table_open(tnode->relid, AccessShareLock);
+ tstate->css.slotOps = table_slot_callbacks(rel);
+ table_close(rel, NoLock);
+
+ return (Node *) tstate;
+}
+
+static const CustomScanMethods test_custom_scan_methods =
+{
+ .CustomName = TEST_CUSTOM_SCAN_NAME,
+ .CreateCustomScanState = test_create_custom_scan_state,
+};
+
+/*
+ * Planner callbacks
+ */
+
+/*
+ * PlanCustomPath turns our CustomPath into the CustomScan plan node that the
+ * executor is going to run. The cost data and custom_relids are copied from
+ * the path by the core once we return, so we only have to describe what the
+ * scan does.
+ */
+static Plan *
+test_plan_custom_path(PlannerInfo *root,
+ RelOptInfo *rel,
+ struct CustomPath *best_path,
+ List *tlist,
+ List *clauses,
+ List *custom_plans)
+{
+ CustomScan *cscan = makeNode(CustomScan);
+
+ cscan->scan.plan.targetlist = tlist;
+
+ /*
+ * Unlike the built-in scan nodes, a custom scan provider receives the
+ * restriction clauses as RestrictInfo nodes rather than as bare
+ * expressions. That is deliberate: the provider is free to evaluate some
+ * of the clauses on its own (say, push them down to a remote server), and
+ * the planner data in RestrictInfo helps it decide which ones. The
+ * clauses we want the executor to check have to be reduced to bare
+ * expressions and stored in the plan's qual, as "Creating Custom Scan
+ * Plans" requires the scan to be initialized like any other one.
+ *
+ * The second argument of extract_actual_clauses() selects the regular
+ * clauses rather than the pseudoconstant ones (constant-TRUE clauses are
+ * dropped in either case). Pseudoconstants must not end up in the qual:
+ * they do not depend on the scanned tuple, so the core evaluates them
+ * once in a gating Result node placed above us.
+ */
+ cscan->scan.plan.qual = extract_actual_clauses(clauses, false);
+ cscan->scan.scanrelid = rel->relid;
+ cscan->flags = best_path->flags;
+
+ /* Our CustomPath has no child paths */
+ cscan->custom_plans = custom_plans;
+ cscan->custom_exprs = NIL;
+
+ /*
+ * Pass the ExtensibleNode from the path to the plan via custom_private.
+ * This is the recommended pattern for conveying private planning data
+ * from a CustomPath to its corresponding CustomScan.
+ */
+ cscan->custom_private = best_path->custom_private;
+ cscan->custom_scan_tlist = NIL;
+ cscan->custom_relids = NULL;
+ cscan->methods = &test_custom_scan_methods;
+
+ return (Plan *) cscan;
+}
+
+static const CustomPathMethods test_custom_path_methods =
+{
+ .CustomName = TEST_CUSTOM_SCAN_NAME,
+ .PlanCustomPath = test_plan_custom_path,
+};
+
+static set_rel_pathlist_hook_type prev_set_rel_pathlist_hook = NULL;
+
+static void
+test_set_rel_pathlist(PlannerInfo *root, RelOptInfo *rel,
+ Index rti, RangeTblEntry *rte)
+{
+ CustomPath *cpath;
+ TestExtNode *tnode;
+ char *relname;
+
+ /* Let previous hooks run first */
+ if (prev_set_rel_pathlist_hook)
+ prev_set_rel_pathlist_hook(root, rel, rti, rte);
+
+ /*
+ * Only handle plain base relations that our executor callbacks can
+ * actually scan. The relkind test matters: table_slot_callbacks() and
+ * table_beginscan() require a relation with a table AM, which rules out
+ * foreign tables, partitioned tables and the like. TABLESAMPLE is
+ * excluded too, as we would otherwise override the sample scan and return
+ * the whole relation.
+ */
+ if (rel->reloptkind != RELOPT_BASEREL || rte->rtekind != RTE_RELATION)
+ return;
+ if (rte->relkind != RELKIND_RELATION && rte->relkind != RELKIND_MATVIEW)
+ return;
+ if (rte->tablesample != NULL)
+ return;
+
+ /*
+ * Only inject our CustomPath for the specific marker table. This prevents
+ * interference with system-catalog scans.
+ */
+ relname = get_rel_name(rte->relid);
+ if (relname == NULL || strcmp(relname, TEST_TABLE_NAME) != 0)
+ return;
+
+ /*
+ * Build a TestExtNode carrying the data our executor callbacks are going
+ * to need
+ */
+ tnode = (TestExtNode *) newNode(sizeof(TestExtNode), T_ExtensibleNode);
+ tnode->base.extnodename = TEST_EXT_NODE_NAME;
+ tnode->relid = rte->relid;
+
+ /*
+ * The repeat count is read once, here, and travels to the executor inside
+ * the ExtensibleNode. Changing the GUC afterwards therefore has no effect
+ * on an already planned query, which is the behavior one wants from
+ * something the plan depends on.
+ */
+ tnode->repeat_count = test_repeat_count;
+
+ /*
+ * Build the CustomPath. Claiming zero cost is what gets our path chosen
+ * over the sequential scan here; a real provider would estimate the cost
+ * honestly. Note that this is not a guarantee: add_path() compares costs
+ * fuzzily and falls back on other path properties when they tie, so a
+ * provider must not assume that the cheapest-looking path always wins.
+ * The row count, on the other hand, is reported truthfully: we hand back
+ * each scanned row repeat_count times.
+ */
+ cpath = makeNode(CustomPath);
+ cpath->path.pathtype = T_CustomScan;
+ cpath->path.parent = rel;
+ cpath->path.pathtarget = rel->reltarget;
+ cpath->path.rows = rel->rows * tnode->repeat_count;
+ cpath->path.startup_cost = 0;
+ cpath->path.total_cost = 0;
+
+ /*
+ * Our scan touches nothing but its own relation and keeps no state
+ * outside the CustomScanState, so it is safe to run inside a parallel
+ * worker. rel->consider_parallel tells us whether the planner deems
+ * parallelism legal for this relation in the first place, which is what
+ * the core scan paths key off as well.
+ *
+ * This is what lets the plan be pushed below a Gather, and that in turn
+ * is the only way the nodeOut and nodeRead callbacks of our
+ * ExtensibleNode get exercised by the core: the plan tree, custom_private
+ * included, is serialized into dynamic shared memory and read back by the
+ * worker.
+ *
+ * Note that the path is parallel safe but not parallel *aware*: our scan
+ * does not divide the work between workers, so we never offer it to
+ * add_partial_path().
+ */
+ cpath->path.parallel_safe = rel->consider_parallel;
+
+ cpath->flags = 0;
+ cpath->custom_paths = NIL;
+ cpath->custom_private = list_make1(tnode);
+ cpath->methods = &test_custom_path_methods;
+
+ add_path(rel, (Path *) cpath);
+}
+
+/*
+ * SQL-callable test functions
+ */
+PG_FUNCTION_INFO_V1(test_get_extensible_node_methods);
+PG_FUNCTION_INFO_V1(test_get_custom_scan_methods);
+PG_FUNCTION_INFO_V1(test_ext_node_make);
+PG_FUNCTION_INFO_V1(test_ext_node_copy);
+PG_FUNCTION_INFO_V1(test_ext_node_equal);
+PG_FUNCTION_INFO_V1(test_ext_node_get_relid);
+PG_FUNCTION_INFO_V1(test_ext_node_get_repeat_count);
+
+/*
+ * test_get_extensible_node_methods(name text, missing_ok bool)
+ *
+ * Thin wrapper around GetExtensibleNodeMethods(). Returns the registered
+ * extnodename, or NULL when missing_ok = true and the name is not found.
+ * Raises ERROR when missing_ok = false and the name is not found.
+ */
+Datum
+test_get_extensible_node_methods(PG_FUNCTION_ARGS)
+{
+ char *name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+ bool missing_ok = PG_GETARG_BOOL(1);
+ const ExtensibleNodeMethods *methods;
+
+ methods = GetExtensibleNodeMethods(name, missing_ok);
+ if (methods == NULL)
+ PG_RETURN_NULL();
+
+ PG_RETURN_TEXT_P(cstring_to_text(methods->extnodename));
+}
+
+/*
+ * test_get_custom_scan_methods(name text, missing_ok bool)
+ *
+ * Thin wrapper around GetCustomScanMethods(). Returns the registered
+ * CustomName, or NULL when missing_ok = true and the name is not found.
+ * Raises ERROR when missing_ok = false and the name is not found.
+ */
+Datum
+test_get_custom_scan_methods(PG_FUNCTION_ARGS)
+{
+ char *name = text_to_cstring(PG_GETARG_TEXT_PP(0));
+ bool missing_ok = PG_GETARG_BOOL(1);
+ const CustomScanMethods *methods;
+
+ methods = GetCustomScanMethods(name, missing_ok);
+ if (methods == NULL)
+ PG_RETURN_NULL();
+
+ PG_RETURN_TEXT_P(cstring_to_text(methods->CustomName));
+}
+
+/*
+ * The functions below publish the ExtensibleNodeMethods callbacks to the SQL
+ * level as thin wrappers, so that each operation can be driven and checked
+ * individually from the regression test rather than from C.
+ *
+ * A TestExtNode is passed around as the text produced by nodeToString(), the
+ * same way test_bitmapset.c passes Bitmapsets around. That representation is
+ * what makes the nodes composable in SQL, and it has the pleasant side effect
+ * of running nodeOut and nodeRead on every hop.
+ */
+
+/* Encode a TestExtNode into its serialized representation */
+#define TEST_EXT_NODE_TO_TEXT(node) cstring_to_text(nodeToString(node))
+
+/* Decode a function argument back into a TestExtNode */
+#define PG_GETARG_TEST_EXT_NODE(n) text_to_test_ext_node(PG_GETARG_TEXT_PP(n))
+
+/*
+ * Decode a TestExtNode, i.e. run the nodeRead callback via stringToNode().
+ *
+ * The result is checked rather than blindly cast, so that a string describing
+ * some other kind of node is reported as an error instead of being
+ * misinterpreted as one of ours. Note that this says nothing about strings
+ * that stringToNode() itself cannot digest: like the rest of readfuncs.c, it
+ * assumes well-formed input.
+ */
+static TestExtNode *
+text_to_test_ext_node(text *txt)
+{
+ Node *node = stringToNode(text_to_cstring(txt));
+
+ if (node == NULL || !IsA(node, ExtensibleNode) ||
+ strcmp(((ExtensibleNode *) node)->extnodename, TEST_EXT_NODE_NAME) != 0)
+ ereport(ERROR,
+ (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+ errmsg("argument is not a serialized \"%s\"",
+ TEST_EXT_NODE_NAME)));
+
+ return (TestExtNode *) node;
+}
+
+/*
+ * test_ext_node_make(relid oid, repeat_count int)
+ *
+ * Builds a TestExtNode and returns it. The returned text is the output of the
+ * nodeOut callback.
+ */
+Datum
+test_ext_node_make(PG_FUNCTION_ARGS)
+{
+ TestExtNode *tnode;
+
+ tnode = (TestExtNode *) newNode(sizeof(TestExtNode), T_ExtensibleNode);
+ tnode->base.extnodename = TEST_EXT_NODE_NAME;
+ tnode->relid = PG_GETARG_OID(0);
+ tnode->repeat_count = PG_GETARG_INT32(1);
+
+ PG_RETURN_TEXT_P(TEST_EXT_NODE_TO_TEXT(tnode));
+}
+
+/*
+ * test_ext_node_copy(node text)
+ *
+ * Wrapper around copyObject(), which reaches the nodeCopy callback.
+ */
+Datum
+test_ext_node_copy(PG_FUNCTION_ARGS)
+{
+ TestExtNode *tnode = PG_GETARG_TEST_EXT_NODE(0);
+
+ PG_RETURN_TEXT_P(TEST_EXT_NODE_TO_TEXT(copyObject(tnode)));
+}
+
+/*
+ * test_ext_node_equal(a text, b text)
+ *
+ * Wrapper around equal(), which reaches the nodeEqual callback.
+ */
+Datum
+test_ext_node_equal(PG_FUNCTION_ARGS)
+{
+ TestExtNode *a = PG_GETARG_TEST_EXT_NODE(0);
+ TestExtNode *b = PG_GETARG_TEST_EXT_NODE(1);
+
+ PG_RETURN_BOOL(equal(a, b));
+}
+
+/*
+ * test_ext_node_get_relid(node text)
+ * test_ext_node_get_repeat_count(node text)
+ *
+ * Field accessors. They exist to check that nodeRead restores what nodeOut
+ * wrote, without relying on the exact serialized text.
+ */
+Datum
+test_ext_node_get_relid(PG_FUNCTION_ARGS)
+{
+ PG_RETURN_OID(PG_GETARG_TEST_EXT_NODE(0)->relid);
+}
+
+Datum
+test_ext_node_get_repeat_count(PG_FUNCTION_ARGS)
+{
+ PG_RETURN_INT32(PG_GETARG_TEST_EXT_NODE(0)->repeat_count);
+}
+
+/*
+ * Module initialization
+ */
+void
+_PG_init(void)
+{
+ if (!process_shared_preload_libraries_in_progress)
+ ereport(ERROR,
+ (errmsg("cannot load \"%s\" after startup",
+ "test_extensible"),
+ errdetail("\"%s\" must be loaded with "
+ "\"shared_preload_libraries\".",
+ "test_extensible")));
+
+ /*
+ * Register the custom scan methods. Every backend that plans or executes
+ * such a scan has to do this, because the plan tree refers to the methods
+ * by name only; that includes parallel workers, which read the plan back
+ * with stringToNode(). Hence the shared_preload_libraries requirement.
+ */
+ RegisterCustomScanMethods(&test_custom_scan_methods);
+
+ /* Register the extensible node type */
+ RegisterExtensibleNodeMethods(&test_ext_node_methods);
+
+ DefineCustomIntVariable("test_extensible.repeat_count",
+ "Number of times the custom scan returns each row.",
+ NULL,
+ &test_repeat_count,
+ 2,
+ 1,
+ 100,
+ PGC_USERSET,
+ 0,
+ NULL, NULL, NULL);
+
+ MarkGUCPrefixReserved("test_extensible");
+
+ /* Install the path-list hook to inject CustomPaths for the test table */
+ prev_set_rel_pathlist_hook = set_rel_pathlist_hook;
+ set_rel_pathlist_hook = test_set_rel_pathlist;
+}
diff --git a/src/test/modules/test_extensible/test_extensible.conf b/src/test/modules/test_extensible/test_extensible.conf
new file mode 100644
index 00000000000..a5b643cb256
--- /dev/null
+++ b/src/test/modules/test_extensible/test_extensible.conf
@@ -0,0 +1 @@
+shared_preload_libraries = 'test_extensible'
diff --git a/src/test/modules/test_extensible/test_extensible.control b/src/test/modules/test_extensible/test_extensible.control
new file mode 100644
index 00000000000..7a19fa6570e
--- /dev/null
+++ b/src/test/modules/test_extensible/test_extensible.control
@@ -0,0 +1,4 @@
+comment = 'Test code for extensible node and custom scan registration'
+default_version = '1.0'
+module_pathname = '$libdir/test_extensible'
+relocatable = false
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 6c366d3a523..5bb056040f9 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -3189,10 +3189,12 @@ Tcl_Obj
Tcl_Size
Tcl_Time
TempNamespaceStatus
+TestCustomScanState
TestDSMRegistryHashEntry
TestDSMRegistryStruct
TestDecodingData
TestDecodingTxnData
+TestExtNode
TestShmemData
TestSpec
TestValueType
--
2.43.0