From d31d8cef68d10cb1817446af9a1e492ce88808e9 Mon Sep 17 00:00:00 2001
From: Masahiko Sawada <sawada.mshk@gmail.com>
Date: Mon, 22 Jun 2026 13:14:31 -0700
Subject: [PATCH v2 4/4] Add test module for COPY custom format.

Author:
Reviewed-by:
Discussion: https://postgr.es/m/
Backpatch-through:
---
 src/test/modules/Makefile                     |   1 +
 src/test/modules/meson.build                  |   1 +
 .../modules/test_copy_custom_format/Makefile  |  20 +++
 .../expected/test_copy_custom_format.out      | 105 +++++++++++
 .../test_copy_custom_format/meson.build       |  32 ++++
 .../sql/test_copy_custom_format.sql           |  32 ++++
 .../test_copy_custom_format.c                 | 169 ++++++++++++++++++
 src/tools/pgindent/typedefs.list              |   1 +
 8 files changed, 361 insertions(+)
 create mode 100644 src/test/modules/test_copy_custom_format/Makefile
 create mode 100644 src/test/modules/test_copy_custom_format/expected/test_copy_custom_format.out
 create mode 100644 src/test/modules/test_copy_custom_format/meson.build
 create mode 100644 src/test/modules/test_copy_custom_format/sql/test_copy_custom_format.sql
 create mode 100644 src/test/modules/test_copy_custom_format/test_copy_custom_format.c

diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 0a74ab5c86f..6dcb66174f5 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -23,6 +23,7 @@ SUBDIRS = \
 		  test_cloexec \
 		  test_checksums \
 		  test_copy_callbacks \
+		  test_copy_custom_format \
 		  test_custom_rmgrs \
 		  test_custom_stats \
 		  test_custom_types \
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 4bca42bb370..adfa413fe58 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -23,6 +23,7 @@ subdir('test_bloomfilter')
 subdir('test_cloexec')
 subdir('test_checksums')
 subdir('test_copy_callbacks')
+subdir('test_copy_custom_format')
 subdir('test_cplusplusext')
 subdir('test_custom_rmgrs')
 subdir('test_custom_stats')
diff --git a/src/test/modules/test_copy_custom_format/Makefile b/src/test/modules/test_copy_custom_format/Makefile
new file mode 100644
index 00000000000..68a2a04ff09
--- /dev/null
+++ b/src/test/modules/test_copy_custom_format/Makefile
@@ -0,0 +1,20 @@
+# src/test/modules/test_copy_custom_format/Makefile
+
+MODULE_big = test_copy_custom_format
+OBJS = \
+	$(WIN32RES) \
+	test_copy_custom_format.o
+PGFILEDESC = "test_copy_custom_format - test custom COPY FORMAT"
+
+REGRESS = test_copy_custom_format
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/test_copy_custom_format
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/test_copy_custom_format/expected/test_copy_custom_format.out b/src/test/modules/test_copy_custom_format/expected/test_copy_custom_format.out
new file mode 100644
index 00000000000..817ca3fa60f
--- /dev/null
+++ b/src/test/modules/test_copy_custom_format/expected/test_copy_custom_format.out
@@ -0,0 +1,105 @@
+LOAD 'test_copy_custom_format';
+CREATE TABLE copy_data (a smallint, b integer, c bigint);
+INSERT INTO copy_data VALUES (1,2,3),(12,34,56),(123,456,789);
+COPY copy_data TO stdout WITH (format 'test_format');          -- Start, OutFunc x3, OneRow x3, End
+NOTICE:  CopyToOutFunc: attribute: smallint
+NOTICE:  CopyToOutFunc: attribute: integer
+NOTICE:  CopyToOutFunc: attribute: bigint
+NOTICE:  CopyToStart: the number of attributes of table: 3, the number of attributes to output: 3
+NOTICE:  CopyToOneRow: the number of valid values: 3
+NOTICE:  CopyToOneRow: the number of valid values: 3
+NOTICE:  CopyToOneRow: the number of valid values: 3
+NOTICE:  CopyToEnd
+COPY copy_data FROM stdin WITH (format 'test_format');         -- InFunc x3, Start, OneRow, End
+NOTICE:  CopyFromInFunc: attribute: smallint
+NOTICE:  CopyFromInFunc: attribute: integer
+NOTICE:  CopyFromInFunc: attribute: bigint
+NOTICE:  CopyFromStart: the number of attributes of table: 3, the number of attributes to input: 3
+NOTICE:  CopyFromOneRow
+NOTICE:  CopyFromEnd
+COPY copy_data (a, b) TO stdout WITH (format 'test_format');   -- Start: natts 2
+NOTICE:  CopyToOutFunc: attribute: smallint
+NOTICE:  CopyToOutFunc: attribute: integer
+NOTICE:  CopyToStart: the number of attributes of table: 3, the number of attributes to output: 2
+NOTICE:  CopyToOneRow: the number of valid values: 3
+NOTICE:  CopyToOneRow: the number of valid values: 3
+NOTICE:  CopyToOneRow: the number of valid values: 3
+NOTICE:  CopyToEnd
+COPY (SELECT a FROM copy_data) TO stdout WITH (format 'test_format'); -- Start: natts 1
+NOTICE:  CopyToOutFunc: attribute: smallint
+NOTICE:  CopyToStart: the number of attributes of table: 1, the number of attributes to output: 1
+NOTICE:  CopyToOneRow: the number of valid values: 1
+NOTICE:  CopyToOneRow: the number of valid values: 1
+NOTICE:  CopyToOneRow: the number of valid values: 1
+NOTICE:  CopyToEnd
+COPY copy_data TO stdout WITH (format 'nonexistent');          -- ERROR: not recognized
+ERROR:  COPY format "nonexistent" not recognized
+LINE 1: COPY copy_data TO stdout WITH (format 'nonexistent');
+                                       ^
+COPY copy_data TO stdout WITH (format 'text', format 'csv');   -- ERROR: conflicting
+ERROR:  conflicting or redundant options
+LINE 1: COPY copy_data TO stdout WITH (format 'text', format 'csv');
+                                                      ^
+COPY copy_data TO stdout WITH (format 'test_format', bogus 1); -- ERROR
+ERROR:  COPY format "test_format" does not accept option "bogus"
+LINE 1: ...PY copy_data TO stdout WITH (format 'test_format', bogus 1);
+                                                              ^
+COPY copy_data TO stdout WITH (format 'test_format', max_attributes 5); -- OK
+NOTICE:  CopyToOutFunc: attribute: smallint
+NOTICE:  CopyToOutFunc: attribute: integer
+NOTICE:  CopyToOutFunc: attribute: bigint
+NOTICE:  CopyToStart: the number of attributes of table: 3, the number of attributes to output: 3
+NOTICE:  CopyToOneRow: the number of valid values: 3
+NOTICE:  CopyToOneRow: the number of valid values: 3
+NOTICE:  CopyToOneRow: the number of valid values: 3
+NOTICE:  CopyToEnd
+COPY copy_data TO stdout WITH (format 'test_format', max_attributes 3); -- OK
+NOTICE:  CopyToOutFunc: attribute: smallint
+NOTICE:  CopyToOutFunc: attribute: integer
+NOTICE:  CopyToOutFunc: attribute: bigint
+NOTICE:  CopyToStart: the number of attributes of table: 3, the number of attributes to output: 3
+NOTICE:  CopyToOneRow: the number of valid values: 3
+NOTICE:  CopyToOneRow: the number of valid values: 3
+NOTICE:  CopyToOneRow: the number of valid values: 3
+NOTICE:  CopyToEnd
+COPY copy_data TO stdout WITH (format 'test_format', max_attributes 2); -- ERROR: 3 columns exceeds 2
+NOTICE:  CopyToOutFunc: attribute: smallint
+NOTICE:  CopyToOutFunc: attribute: integer
+NOTICE:  CopyToOutFunc: attribute: bigint
+ERROR:  relation has 3 columns, exceeds max_attributes 2
+COPY copy_data TO stdout WITH (format 'test_format', max_attributes 0);   -- ERROR: positive
+ERROR:  "max_attributes" must be a positive integer
+COPY copy_data TO stdout WITH (format 'test_format', max_attributes -1);  -- ERROR
+ERROR:  "max_attributes" must be a positive integer
+COPY copy_data TO stdout WITH (format 'test_format', max_attributes 'x'); -- ERROR: integer required
+ERROR:  max_attributes requires an integer value
+COPY copy_data FROM stdin WITH (format 'test_format', freeze true, disallow_freeze true); -- ERROR (validate)
+ERROR:  FREEZE cannot be used with "disallow_freeze" option
+COPY copy_data FROM stdin WITH (format 'test_format', disallow_freeze true); -- OK
+NOTICE:  CopyFromInFunc: attribute: smallint
+NOTICE:  CopyFromInFunc: attribute: integer
+NOTICE:  CopyFromInFunc: attribute: bigint
+NOTICE:  CopyFromStart: the number of attributes of table: 3, the number of attributes to input: 3
+NOTICE:  CopyFromOneRow
+NOTICE:  CopyFromEnd
+-- The built-in options are handled in the same way of built-in formats.
+COPY copy_data TO stdout WITH (format 'test_format', delimiter ',');
+NOTICE:  CopyToOutFunc: attribute: smallint
+NOTICE:  CopyToOutFunc: attribute: integer
+NOTICE:  CopyToOutFunc: attribute: bigint
+NOTICE:  CopyToStart: the number of attributes of table: 3, the number of attributes to output: 3
+NOTICE:  CopyToOneRow: the number of valid values: 3
+NOTICE:  CopyToOneRow: the number of valid values: 3
+NOTICE:  CopyToOneRow: the number of valid values: 3
+NOTICE:  CopyToEnd
+COPY copy_data TO stdout WITH (format 'test_format', quote '"');
+NOTICE:  CopyToOutFunc: attribute: smallint
+NOTICE:  CopyToOutFunc: attribute: integer
+NOTICE:  CopyToOutFunc: attribute: bigint
+NOTICE:  CopyToStart: the number of attributes of table: 3, the number of attributes to output: 3
+NOTICE:  CopyToOneRow: the number of valid values: 3
+NOTICE:  CopyToOneRow: the number of valid values: 3
+NOTICE:  CopyToOneRow: the number of valid values: 3
+NOTICE:  CopyToEnd
+COPY copy_data TO stdout WITH (format 'test_format', freeze true);     -- ERROR: FREEZE with COPY TO
+ERROR:  COPY FREEZE cannot be used with COPY TO
diff --git a/src/test/modules/test_copy_custom_format/meson.build b/src/test/modules/test_copy_custom_format/meson.build
new file mode 100644
index 00000000000..a231ed57649
--- /dev/null
+++ b/src/test/modules/test_copy_custom_format/meson.build
@@ -0,0 +1,32 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+test_copy_custom_format_sources = files(
+'test_copy_custom_format.c',
+)
+
+if host_system == 'windows'
+  test_copy_custom_format_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'test_copy_custom_format',
+    '--FILEDESC', 'test_copy_custom_format - test custom COPY FORMAT',])
+endif
+
+test_copy_custom_format = shared_module('test_copy_custom_format',
+  test_copy_custom_format_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += test_copy_custom_format
+
+tests += {
+  'name': 'test_copy_custom_format',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'test_copy_custom_format',
+    ],
+    # Disabled because these tests require
+    # "shared_preload_libraries=test_custom_copy_format", which typical
+    # runningcheck users do not have (e.g. buildfarm clients).
+    'runningcheck': false,
+  },
+}
diff --git a/src/test/modules/test_copy_custom_format/sql/test_copy_custom_format.sql b/src/test/modules/test_copy_custom_format/sql/test_copy_custom_format.sql
new file mode 100644
index 00000000000..59f58fa55a2
--- /dev/null
+++ b/src/test/modules/test_copy_custom_format/sql/test_copy_custom_format.sql
@@ -0,0 +1,32 @@
+LOAD 'test_copy_custom_format';
+
+CREATE TABLE copy_data (a smallint, b integer, c bigint);
+INSERT INTO copy_data VALUES (1,2,3),(12,34,56),(123,456,789);
+
+COPY copy_data TO stdout WITH (format 'test_format');          -- Start, OutFunc x3, OneRow x3, End
+COPY copy_data FROM stdin WITH (format 'test_format');         -- InFunc x3, Start, OneRow, End
+\.
+
+COPY copy_data (a, b) TO stdout WITH (format 'test_format');   -- Start: natts 2
+COPY (SELECT a FROM copy_data) TO stdout WITH (format 'test_format'); -- Start: natts 1
+
+COPY copy_data TO stdout WITH (format 'nonexistent');          -- ERROR: not recognized
+COPY copy_data TO stdout WITH (format 'text', format 'csv');   -- ERROR: conflicting
+
+COPY copy_data TO stdout WITH (format 'test_format', bogus 1); -- ERROR
+
+COPY copy_data TO stdout WITH (format 'test_format', max_attributes 5); -- OK
+COPY copy_data TO stdout WITH (format 'test_format', max_attributes 3); -- OK
+COPY copy_data TO stdout WITH (format 'test_format', max_attributes 2); -- ERROR: 3 columns exceeds 2
+COPY copy_data TO stdout WITH (format 'test_format', max_attributes 0);   -- ERROR: positive
+COPY copy_data TO stdout WITH (format 'test_format', max_attributes -1);  -- ERROR
+COPY copy_data TO stdout WITH (format 'test_format', max_attributes 'x'); -- ERROR: integer required
+
+COPY copy_data FROM stdin WITH (format 'test_format', freeze true, disallow_freeze true); -- ERROR (validate)
+COPY copy_data FROM stdin WITH (format 'test_format', disallow_freeze true); -- OK
+\.
+
+-- The built-in options are handled in the same way of built-in formats.
+COPY copy_data TO stdout WITH (format 'test_format', delimiter ',');
+COPY copy_data TO stdout WITH (format 'test_format', quote '"');
+COPY copy_data TO stdout WITH (format 'test_format', freeze true);     -- ERROR: FREEZE with COPY TO
diff --git a/src/test/modules/test_copy_custom_format/test_copy_custom_format.c b/src/test/modules/test_copy_custom_format/test_copy_custom_format.c
new file mode 100644
index 00000000000..ca25832fcb0
--- /dev/null
+++ b/src/test/modules/test_copy_custom_format/test_copy_custom_format.c
@@ -0,0 +1,169 @@
+/*--------------------------------------------------------------------------
+ *
+ * test_copy_custom_format.c
+ *		Code for testing custom COPY format.
+ *
+ * Portions Copyright (c) 2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *		src/test/modules/test_copy_custom_format/test_copy_custom_format.c
+ *
+ * -------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "commands/copy.h"
+#include "commands/copyapi.h"
+#include "commands/copy_state.h"
+#include "commands/defrem.h"
+#include "utils/builtins.h"
+
+PG_MODULE_MAGIC;
+
+typedef struct TestCopyOptions
+{
+	int			max_attributes;
+	bool		disallow_freeze;
+} TestCopyOptions;
+
+static bool
+TestCopyProcessOneOption(CopyFormatOptions *opts, bool is_from, DefElem *option)
+{
+	TestCopyOptions *t = (TestCopyOptions *) opts->format_private_opts;
+
+	if (t == NULL)
+	{
+		t = palloc0_object(TestCopyOptions);
+		opts->format_private_opts = (void *) t;
+	}
+
+	if (strcmp(option->defname, "max_attributes") == 0)
+	{
+		int			val = defGetInt32(option);
+
+		if (val < 1)
+			ereport(ERROR,
+					errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					errmsg("\"max_attributes\" must be a positive integer"));
+
+		t->max_attributes = val;
+		return true;
+	}
+	else if (strcmp(option->defname, "disallow_freeze") == 0)
+	{
+		t->disallow_freeze = defGetBoolean(option);
+		return true;
+	}
+
+	return false;
+}
+
+static void
+TestCopyValidateOptions(CopyFormatOptions *opts, bool is_from)
+{
+	TestCopyOptions *t = (TestCopyOptions *) opts->format_private_opts;
+
+	if (!t)
+		return;
+
+	if (t->disallow_freeze && opts->freeze)
+		ereport(ERROR,
+				errmsg("FREEZE cannot be used with \"disallow_freeze\" option"));
+}
+
+static void
+TestCopyFromInFunc(CopyFromState cstate, Oid atttypid, FmgrInfo *finfo, Oid *typioparam)
+{
+	ereport(NOTICE,
+			errmsg("CopyFromInFunc: attribute: %s", format_type_be(atttypid)));
+}
+
+static void
+check_max_attributes(CopyFormatOptions *opts, TupleDesc tupDesc)
+{
+	TestCopyOptions *t = (TestCopyOptions *) opts->format_private_opts;
+
+	if (t != NULL && t->max_attributes > 0 && tupDesc->natts > t->max_attributes)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("relation has %d columns, exceeds max_attributes %d",
+					   tupDesc->natts, t->max_attributes));
+}
+
+static void
+TestCopyFromStart(CopyFromState cstate, TupleDesc tupDesc)
+{
+	check_max_attributes(&cstate->opts, tupDesc);
+
+	ereport(NOTICE,
+			errmsg("CopyFromStart: the number of attributes of table: %d, the number of attributes to input: %d",
+				   tupDesc->natts, list_length(cstate->attnumlist)));
+}
+
+static bool
+TestCopyFromOneRow(CopyFromState cstate, ExprContext *econtext, Datum *values, bool *nulls)
+{
+	ereport(NOTICE, errmsg("CopyFromOneRow"));
+
+	return false;
+}
+
+static void
+TestCopyFromEnd(CopyFromState cstate)
+{
+	ereport(NOTICE, errmsg("CopyFromEnd"));
+}
+
+static void
+TestCopyToOutFunc(CopyToState cstate, Oid atttypid, FmgrInfo *finfo)
+{
+	ereport(NOTICE, errmsg("CopyToOutFunc: attribute: %s", format_type_be(atttypid)));
+}
+
+static void
+TestCopyToStart(CopyToState cstate, TupleDesc tupDesc)
+{
+	check_max_attributes(&cstate->opts, tupDesc);
+
+	ereport(NOTICE,
+			errmsg("CopyToStart: the number of attributes of table: %d, the number of attributes to output: %d",
+				   tupDesc->natts, list_length(cstate->attnumlist)));
+}
+
+static void
+TestCopyToOneRow(CopyToState cstate, TupleTableSlot *slot)
+{
+	ereport(NOTICE, (errmsg("CopyToOneRow: the number of valid values: %u", slot->tts_nvalid)));
+}
+
+static void
+TestCopyToEnd(CopyToState cstate)
+{
+	ereport(NOTICE, (errmsg("CopyToEnd")));
+}
+
+static const CopyToRoutine TestCopyToRoutine = {
+	.CopyToOutFunc = TestCopyToOutFunc,
+	.CopyToStart = TestCopyToStart,
+	.CopyToOneRow = TestCopyToOneRow,
+	.CopyToEnd = TestCopyToEnd,
+};
+
+
+static const CopyFromRoutine TestCopyFromRoutine = {
+	.CopyFromInFunc = TestCopyFromInFunc,
+	.CopyFromStart = TestCopyFromStart,
+	.CopyFromOneRow = TestCopyFromOneRow,
+	.CopyFromEnd = TestCopyFromEnd,
+};
+
+void
+_PG_init(void)
+{
+	RegisterCopyCustomFormat("test_format",
+							 &TestCopyToRoutine,
+							 &TestCopyFromRoutine,
+							 &TestCopyProcessOneOption,
+							 &TestCopyValidateOptions);
+}
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 5263710e451..552669abc5f 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -3179,6 +3179,7 @@ Tcl_Obj
 Tcl_Size
 Tcl_Time
 TempNamespaceStatus
+TestCopyOptions
 TestDSMRegistryHashEntry
 TestDSMRegistryStruct
 TestDecodingData
-- 
2.54.0

