agora inbox for pgsql-hackers@postgresql.org  
help / color / mirror / Atom feed
Allow table AMs to define their own reloptions
25+ messages / 8 participants
[nested] [flat]

* Allow table AMs to define their own reloptions
@ 2025-03-02 08:56  Julien Tachoires <julien@tachoires.me>
  0 siblings, 1 reply; 25+ messages in thread

From: Julien Tachoires @ 2025-03-02 08:56 UTC (permalink / raw)
  To: pgsql-hackers

Hi,

With the help of the new TAM routine 'relation_options', table access 
methods can with this patch define their own reloptions 
parser/validator.

These reloptions can be set via the following commands:
1. CREATE TABLE ... USING table_am
       WITH (option1='value1', option2='value2');
2. ALTER TABLE ...
       SET (option1 'value1', option2 'value2');
3. ALTER TABLE ... SET ACCESS METHOD table_am
       OPTIONS (option1 'value1', option2 'value2');

When changing table's access method, the settings inherited from the 
former TAM can be dropped (if not supported by the new TAM) via: DROP 
option, or, updated via: SET option 'value'.

Currently, tables using different TAMs than heap are able to use heap's 
reloptions (fillfactor, toast_tuple_target, etc...). With this patch 
applied, this is not the case anymore: if the TAM needs to have access 
to similar settings to heap ones, they have to explicitly define them.

The 2nd patch file includes a new test module 'dummy_table_am' which 
implements a dummy table access method utilized to exercise TAM 
reloptions. This test module is strongly based on what we already have 
in 'dummy_index_am'. 'dummy_table_am' provides a complete example of TAM 
reloptions definition.

This work is directly derived from SadhuPrasad's patch here [2]. Others 
attempts were posted here [1] and here [3].

[1] https://www.postgresql.org/message-id/flat/429fb58fa3218221bb17c7bf9e70e1aa6cfc6b5d.camel%40j-davis....
[2] https://www.postgresql.org/message-id/flat/CAFF0-CG4KZHdtYHMsonWiXNzj16gWZpduXAn8yF7pDDub+GQMg@mail....
[3] https://www.postgresql.org/message-id/flat/AMUA1wBBBxfc3tKRLLdU64rb.1.1683276279979.Hmail.wuhao%40ha...

-- 
Julien Tachoires

Attachments:

  [text/x-diff] v1-0001-Allow-table-AMs-to-define-their-own-reloptions.patch (25.0K, ../../20250302085641.hmjom5ru3w554t2n@poseidon.home.virt/2-v1-0001-Allow-table-AMs-to-define-their-own-reloptions.patch)
  download | inline diff:
From 8968bb1cf92e373523377c79ff42e76dc9fc20ed Mon Sep 17 00:00:00 2001
From: Julien Tachoires <julien@tachoires.me>
Date: Sat, 1 Mar 2025 17:59:49 +0100
Subject: [PATCH 1/2] Allow table AMs to define their own reloptions

With the help of the new routine 'relation_options', table access
methods can now define their own reloptions.

These options can be set via the following commands:
1. CREATE TABLE ... USING table_am
       WITH (option1='value1', option2='value2');
2. ALTER TABLE ...
       SET (option1 'value1', option2 'value2');
3. ALTER TABLE ... SET ACCESS METHOD table_am
       OPTIONS (option1 'value1', option2 'value2');

When changing table's access method, the settings from the former
TAM can be dropped (if not supported by the new TAM) via:
DROP option, or, updated via: SET option 'value'.

Before this commit, tables using different TAMs than heap were able
to use heap's reloptions (fillfactor, toast_tuple_target, etc...).
Now, this is not the case anymore: if the TAM needs to have access
to settings similar to heap ones, they must explicitly define them.

This work is directly derived from SadhuPrasad's patch named:
v4-0001-PATCH-V4-Per-table-storage-parameters-for-TableAM.patch
---
 doc/src/sgml/ref/alter_table.sgml        |  13 +-
 doc/src/sgml/ref/create_table.sgml       |   3 +-
 src/backend/access/common/reloptions.c   |  66 ++++++++-
 src/backend/access/heap/heapam_handler.c |   2 +
 src/backend/commands/foreigncmds.c       |   2 +-
 src/backend/commands/tablecmds.c         | 180 ++++++++++++++++++++---
 src/backend/parser/gram.y                |   9 ++
 src/backend/postmaster/autovacuum.c      |  18 ++-
 src/backend/utils/cache/relcache.c       |  11 +-
 src/include/access/reloptions.h          |   6 +-
 src/include/access/tableam.h             |  10 ++
 src/include/commands/defrem.h            |   1 +
 12 files changed, 286 insertions(+), 35 deletions(-)

diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index 8e56b8e59b0..e38200e20d2 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -76,7 +76,7 @@ ALTER TABLE [ IF EXISTS ] <replaceable class="parameter">name</replaceable>
     CLUSTER ON <replaceable class="parameter">index_name</replaceable>
     SET WITHOUT CLUSTER
     SET WITHOUT OIDS
-    SET ACCESS METHOD { <replaceable class="parameter">new_access_method</replaceable> | DEFAULT }
+    SET ACCESS METHOD { <replaceable class="parameter">new_access_method</replaceable> | DEFAULT } [ OPTIONS ( [ ADD | SET | DROP ] <replaceable class="parameter">option</replaceable> ['<replaceable class="parameter">value</replaceable>'] [, ... ] ) ]
     SET TABLESPACE <replaceable class="parameter">new_tablespace</replaceable>
     SET { LOGGED | UNLOGGED }
     SET ( <replaceable class="parameter">storage_parameter</replaceable> [= <replaceable class="parameter">value</replaceable>] [, ... ] )
@@ -734,7 +734,7 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
    </varlistentry>
 
    <varlistentry id="sql-altertable-desc-set-access-method">
-    <term><literal>SET ACCESS METHOD</literal></term>
+    <term><literal>SET ACCESS METHOD { <replaceable class="parameter">new_access_method</replaceable> | DEFAULT } [ OPTIONS ( [ ADD | SET | DROP ] <replaceable class="parameter">option</replaceable> ['<replaceable class="parameter">value</replaceable>'] [, ... ] ) ]</literal></term>
     <listitem>
      <para>
       This form changes the access method of the table by rewriting it
@@ -752,6 +752,15 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
       causing future partitions to default to
       <varname>default_table_access_method</varname>.
      </para>
+     <para>
+      Specifying <literal>OPTIONS</literal> allows to change options for
+      the table when changing the table access method.
+      <literal>ADD</literal>, <literal>SET</literal>, and
+      <literal>DROP</literal> specify the action to be performed.
+      <literal>ADD</literal> is assumed if no operation is explicitly
+      specified.  Option names must be unique; names and values are also
+      validated using the table access method's library.
+     </para>
     </listitem>
    </varlistentry>
 
diff --git a/doc/src/sgml/ref/create_table.sgml b/doc/src/sgml/ref/create_table.sgml
index 0a3e520f215..96ecb2ee060 100644
--- a/doc/src/sgml/ref/create_table.sgml
+++ b/doc/src/sgml/ref/create_table.sgml
@@ -1548,7 +1548,8 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
     Storage parameters for
     indexes are documented in <xref linkend="sql-createindex"/>.
     The storage parameters currently
-    available for tables are listed below.  For many of these parameters, as
+    available for tables are listed below. Each table may have different set of storage
+    parameters through different access methods. For many of these parameters, as
     shown, there is an additional parameter with the same name prefixed with
     <literal>toast.</literal>, which controls the behavior of the
     table's secondary <acronym>TOAST</acronym> table, if any
diff --git a/src/backend/access/common/reloptions.c b/src/backend/access/common/reloptions.c
index 59fb53e7707..93561936043 100644
--- a/src/backend/access/common/reloptions.c
+++ b/src/backend/access/common/reloptions.c
@@ -25,6 +25,7 @@
 #include "access/reloptions.h"
 #include "access/spgist_private.h"
 #include "catalog/pg_type.h"
+#include "catalog/pg_am.h"
 #include "commands/defrem.h"
 #include "commands/tablespace.h"
 #include "nodes/makefuncs.h"
@@ -34,6 +35,7 @@
 #include "utils/guc.h"
 #include "utils/memutils.h"
 #include "utils/rel.h"
+#include "utils/syscache.h"
 
 /*
  * Contents of pg_class.reloptions
@@ -1396,7 +1398,7 @@ untransformRelOptions(Datum options)
  */
 bytea *
 extractRelOptions(HeapTuple tuple, TupleDesc tupdesc,
-				  amoptions_function amoptions)
+				  amoptions_function amoptions, reloptions_function reloptsfun)
 {
 	bytea	   *options;
 	bool		isnull;
@@ -1418,7 +1420,8 @@ extractRelOptions(HeapTuple tuple, TupleDesc tupdesc,
 		case RELKIND_RELATION:
 		case RELKIND_TOASTVALUE:
 		case RELKIND_MATVIEW:
-			options = heap_reloptions(classForm->relkind, datum, false);
+			options = table_reloptions(reloptsfun, InvalidOid, classForm->relkind,
+									   datum, false);
 			break;
 		case RELKIND_PARTITIONED_TABLE:
 			options = partitioned_table_reloptions(datum, false);
@@ -2036,7 +2039,8 @@ view_reloptions(Datum reloptions, bool validate)
 }
 
 /*
- * Parse options for heaps, views and toast tables.
+ * Parse options for heaps, views and toast tables. This is the implementation
+ * of relOptions for the access method heap.
  */
 bytea *
 heap_reloptions(char relkind, Datum reloptions, bool validate)
@@ -2066,6 +2070,62 @@ heap_reloptions(char relkind, Datum reloptions, bool validate)
 }
 
 
+/*
+ * Parse options for tables.
+ *
+ *	reloptsfun	Table AM's option parser function. Can be NULL if amid is
+ *				valid. In this case we load the new TAM and use its option
+ *				parser function.
+ *	amid		New table AM's Oid if any.
+ *	relkind		relation kind
+ *	reloptions	options as text[] datum
+ *	validate	error flag
+ */
+bytea *
+table_reloptions(reloptions_function reloptsfun, Oid amid, char relkind,
+				 Datum reloptions, bool validate)
+{
+	/* amid and reloptsfun are mutually exclusive */
+	Assert((!OidIsValid(amid) && (reloptsfun != NULL)) || \
+		   (OidIsValid(amid) && (reloptsfun == NULL)));
+
+	/* Parse/validate options using reloptsfun */
+	if (!OidIsValid(amid) && reloptsfun != NULL)
+	{
+		/* Assume function is strict */
+		if (!PointerIsValid(DatumGetPointer(reloptions)))
+			return NULL;
+
+		return reloptsfun(relkind, reloptions, validate);
+	}
+	/* Parse/validate options using the API of the new Table AM */
+	else if (OidIsValid(amid) && (reloptsfun == NULL))
+	{
+		const TableAmRoutine *routine;
+		HeapTuple	atuple;
+		Form_pg_am	aform;
+
+		atuple = SearchSysCache1(AMOID, ObjectIdGetDatum(amid));
+
+		if (!HeapTupleIsValid(atuple))
+			elog(ERROR, "cache lookup failed for access method %u", amid);
+
+		aform = (Form_pg_am) GETSTRUCT(atuple);
+		routine = GetTableAmRoutine(aform->amhandler);
+		ReleaseSysCache(atuple);
+
+		if (routine->relation_options != NULL)
+			return routine->relation_options(relkind, reloptions, validate);
+
+		return NULL;
+	}
+	else
+	{
+		/* Should not happen */
+		return NULL;
+	}
+}
+
 /*
  * Parse options for indexes.
  *
diff --git a/src/backend/access/heap/heapam_handler.c b/src/backend/access/heap/heapam_handler.c
index e78682c3cef..23451c5af92 100644
--- a/src/backend/access/heap/heapam_handler.c
+++ b/src/backend/access/heap/heapam_handler.c
@@ -24,6 +24,7 @@
 #include "access/heaptoast.h"
 #include "access/multixact.h"
 #include "access/rewriteheap.h"
+#include "access/reloptions.h"
 #include "access/syncscan.h"
 #include "access/tableam.h"
 #include "access/tsmapi.h"
@@ -2678,6 +2679,7 @@ static const TableAmRoutine heapam_methods = {
 	.index_build_range_scan = heapam_index_build_range_scan,
 	.index_validate_scan = heapam_index_validate_scan,
 
+	.relation_options = heap_reloptions,
 	.relation_size = table_block_relation_size,
 	.relation_needs_toast_table = heapam_relation_needs_toast_table,
 	.relation_toast_am = heapam_relation_toast_am,
diff --git a/src/backend/commands/foreigncmds.c b/src/backend/commands/foreigncmds.c
index c14e038d54f..9dab5dfb999 100644
--- a/src/backend/commands/foreigncmds.c
+++ b/src/backend/commands/foreigncmds.c
@@ -62,7 +62,7 @@ static void import_error_callback(void *arg);
  * processing, hence any validation should be done before this
  * conversion.
  */
-static Datum
+Datum
 optionListToArray(List *options)
 {
 	ArrayBuildState *astate = NULL;
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index ce7d115667e..660de70fe9f 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -635,6 +635,8 @@ static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
 								const char *tablespacename, LOCKMODE lockmode);
 static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
 static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
+static void ATExecSetAccessMethodOptions(Relation rel, List *defList, AlterTableType operation,
+										 LOCKMODE lockmode, Oid newAccessMethodId);
 static void ATExecSetRelOptions(Relation rel, List *defList,
 								AlterTableType operation,
 								LOCKMODE lockmode);
@@ -884,24 +886,6 @@ DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId,
 	if (!OidIsValid(ownerId))
 		ownerId = GetUserId();
 
-	/*
-	 * Parse and validate reloptions, if any.
-	 */
-	reloptions = transformRelOptions((Datum) 0, stmt->options, NULL, validnsps,
-									 true, false);
-
-	switch (relkind)
-	{
-		case RELKIND_VIEW:
-			(void) view_reloptions(reloptions, true);
-			break;
-		case RELKIND_PARTITIONED_TABLE:
-			(void) partitioned_table_reloptions(reloptions, true);
-			break;
-		default:
-			(void) heap_reloptions(relkind, reloptions, true);
-	}
-
 	if (stmt->ofTypename)
 	{
 		AclResult	aclresult;
@@ -1016,6 +1000,29 @@ DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId,
 			accessMethodId = get_table_am_oid(default_table_access_method, false);
 	}
 
+	/*
+	 * Parse and validate reloptions, if any.
+	 */
+	reloptions = transformRelOptions((Datum) 0, stmt->options, NULL, validnsps,
+									 true, false);
+	switch (relkind)
+	{
+		case RELKIND_VIEW:
+			(void) view_reloptions(reloptions, true);
+			break;
+		case RELKIND_PARTITIONED_TABLE:
+			(void) partitioned_table_reloptions(reloptions, true);
+			break;
+		case RELKIND_RELATION:
+		case RELKIND_TOASTVALUE:
+		case RELKIND_MATVIEW:
+			(void) table_reloptions(NULL, accessMethodId, relkind, reloptions,
+									true);
+			break;
+		default:
+			(void) heap_reloptions(relkind, reloptions, true);
+	}
+
 	/*
 	 * Create the relation.  Inherited defaults and CHECK constraints are
 	 * passed in for immediate handling --- since they don't need parsing,
@@ -5497,6 +5504,9 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
 			if (rel->rd_rel->relkind == RELKIND_PARTITIONED_TABLE &&
 				tab->chgAccessMethod)
 				ATExecSetAccessMethodNoStorage(rel, tab->newAccessMethod);
+
+			ATExecSetAccessMethodOptions(rel, (List *) cmd->def, cmd->subtype,
+										 lockmode, tab->newAccessMethod);
 			break;
 		case AT_SetTableSpace:	/* SET TABLESPACE */
 
@@ -15690,6 +15700,138 @@ ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel, const char *tablespacen
 	tab->newTableSpace = tablespaceId;
 }
 
+/* SET, ADD or DROP options in ALTER TABLE SET ACCESS METHOD */
+static void
+ATExecSetAccessMethodOptions(Relation rel, List *options, AlterTableType operation,
+							 LOCKMODE lockmode, Oid newAccessMethodId)
+{
+	Oid			relid;
+	Relation	pgclass;
+	HeapTuple	tuple;
+	HeapTuple	newtuple;
+	Datum		datum;
+	bool		isnull;
+	Datum		newOptions;
+	Datum		repl_val[Natts_pg_class];
+	bool		repl_null[Natts_pg_class];
+	bool		repl_repl[Natts_pg_class];
+	List	   *resultOptions;
+	ListCell   *optcell;
+
+	pgclass = table_open(RelationRelationId, RowExclusiveLock);
+
+	/* Fetch heap tuple */
+	relid = RelationGetRelid(rel);
+	tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
+	if (!HeapTupleIsValid(tuple))
+		elog(ERROR, "cache lookup failed for relation %u", relid);
+
+	/* Get the old reloptions */
+	datum = SysCacheGetAttr(RELOID, tuple, Anum_pg_class_reloptions, &isnull);
+
+	if (isnull)
+		datum = PointerGetDatum(NULL);
+
+	resultOptions = untransformRelOptions(datum);
+
+	foreach(optcell, options)
+	{
+		DefElem    *od = lfirst(optcell);
+		ListCell   *cell;
+
+		/* Search in existing options */
+		foreach(cell, resultOptions)
+		{
+			DefElem    *def = lfirst(cell);
+
+			if (strcmp(def->defname, od->defname) == 0)
+				break;
+		}
+
+		/*
+		 * It is possible to perform multiple SET/DROP actions on the same
+		 * option.  The standard permits this, as long as the options to be
+		 * added are unique.  Note that an unspecified action is taken to be
+		 * ADD.
+		 */
+		switch (od->defaction)
+		{
+			case DEFELEM_DROP:
+				if (!cell)
+					ereport(ERROR,
+							(errcode(ERRCODE_UNDEFINED_OBJECT),
+							 errmsg("option \"%s\" not found",
+									od->defname)));
+				resultOptions = list_delete_cell(resultOptions, cell);
+				break;
+
+			case DEFELEM_SET:
+				if (!cell)
+					ereport(ERROR,
+							(errcode(ERRCODE_UNDEFINED_OBJECT),
+							 errmsg("option \"%s\" not found",
+									od->defname)));
+				lfirst(cell) = od;
+				break;
+
+			case DEFELEM_ADD:
+			case DEFELEM_UNSPEC:
+				if (cell)
+					ereport(ERROR,
+							(errcode(ERRCODE_DUPLICATE_OBJECT),
+							 errmsg("option \"%s\" provided more than once",
+									od->defname)));
+				resultOptions = lappend(resultOptions, od);
+				break;
+
+			default:
+				elog(ERROR, "unrecognized action %d on option \"%s\"",
+					 (int) od->defaction, od->defname);
+				break;
+		}
+	}
+
+	newOptions = optionListToArray(resultOptions);
+
+	/*
+	 * If the new table access method was not explicitly defined, then use the
+	 * default one.
+	 */
+	if (!OidIsValid(newAccessMethodId))
+		newAccessMethodId = get_table_am_oid(default_table_access_method, false);
+
+	/* Validate new options via the new Table Access Method API */
+	(void) table_reloptions(NULL, newAccessMethodId, rel->rd_rel->relkind,
+							newOptions, true);
+
+	/* Initialize buffers for new tuple values */
+	memset(repl_val, 0, sizeof(repl_val));
+	memset(repl_null, false, sizeof(repl_null));
+	memset(repl_repl, false, sizeof(repl_repl));
+
+	if (newOptions != (Datum) 0)
+		repl_val[Anum_pg_class_reloptions - 1] = newOptions;
+	else
+		repl_null[Anum_pg_class_reloptions - 1] = true;
+
+	repl_repl[Anum_pg_class_reloptions - 1] = true;
+
+	/* Everything looks good - update the tuple */
+	newtuple = heap_modify_tuple(tuple, RelationGetDescr(pgclass),
+								 repl_val, repl_null, repl_repl);
+
+	CatalogTupleUpdate(pgclass, &newtuple->t_self, newtuple);
+
+	InvokeObjectPostAlterHook(RelationRelationId, RelationGetRelid(rel),
+							  InvalidOid);
+
+	ReleaseSysCache(tuple);
+
+	table_close(pgclass, RowExclusiveLock);
+
+	heap_freetuple(newtuple);
+}
+
 /*
  * Set, reset, or replace reloptions.
  */
@@ -15747,7 +15889,7 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
 		case RELKIND_RELATION:
 		case RELKIND_TOASTVALUE:
 		case RELKIND_MATVIEW:
-			(void) heap_reloptions(rel->rd_rel->relkind, newOptions, true);
+			rel->rd_tableam->relation_options(rel->rd_rel->relkind, newOptions, true);
 			break;
 		case RELKIND_PARTITIONED_TABLE:
 			(void) partitioned_table_reloptions(newOptions, true);
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index 7d99c9355c6..9f38463626f 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -2901,6 +2901,15 @@ alter_table_cmd:
 					n->name = $4;
 					$$ = (Node *) n;
 				}
+			/* ALTER TABLE <name> SET ACCESS METHOD <amname> [OPTIONS]*/
+			| SET ACCESS METHOD name alter_generic_options
+				{
+					AlterTableCmd *n = makeNode(AlterTableCmd);
+					n->subtype = AT_SetAccessMethod;
+					n->name = $4;
+					n->def = (Node *) $5;
+					$$ = (Node *)n;
+				}
 			/* ALTER TABLE <name> SET TABLESPACE <tablespacename> */
 			| SET TABLESPACE name
 				{
diff --git a/src/backend/postmaster/autovacuum.c b/src/backend/postmaster/autovacuum.c
index ddb303f5201..20058327297 100644
--- a/src/backend/postmaster/autovacuum.c
+++ b/src/backend/postmaster/autovacuum.c
@@ -331,6 +331,7 @@ static void FreeWorkerInfo(int code, Datum arg);
 
 static autovac_table *table_recheck_autovac(Oid relid, HTAB *table_toast_map,
 											TupleDesc pg_class_desc,
+											reloptions_function reloptions,
 											int effective_multixact_freeze_max_age);
 static void recheck_relation_needs_vacanalyze(Oid relid, AutoVacOpts *avopts,
 											  Form_pg_class classForm,
@@ -345,7 +346,7 @@ static void relation_needs_vacanalyze(Oid relid, AutoVacOpts *relopts,
 static void autovacuum_do_vac_analyze(autovac_table *tab,
 									  BufferAccessStrategy bstrategy);
 static AutoVacOpts *extract_autovac_opts(HeapTuple tup,
-										 TupleDesc pg_class_desc);
+										 TupleDesc pg_class_desc, reloptions_function reloptions);
 static void perform_work_item(AutoVacuumWorkItem *workitem);
 static void autovac_report_activity(autovac_table *tab);
 static void autovac_report_workitem(AutoVacuumWorkItem *workitem,
@@ -2031,7 +2032,8 @@ do_autovacuum(void)
 		}
 
 		/* Fetch reloptions and the pgstat entry for this table */
-		relopts = extract_autovac_opts(tuple, pg_class_desc);
+		relopts = extract_autovac_opts(tuple, pg_class_desc,
+									   classRel->rd_tableam->relation_options);
 		tabentry = pgstat_fetch_stat_tabentry_ext(classForm->relisshared,
 												  relid);
 
@@ -2104,7 +2106,8 @@ do_autovacuum(void)
 		 * fetch reloptions -- if this toast table does not have them, try the
 		 * main rel
 		 */
-		relopts = extract_autovac_opts(tuple, pg_class_desc);
+		relopts = extract_autovac_opts(tuple, pg_class_desc,
+									   classRel->rd_tableam->relation_options);
 		if (relopts == NULL)
 		{
 			av_relation *hentry;
@@ -2362,6 +2365,7 @@ do_autovacuum(void)
 		 */
 		MemoryContextSwitchTo(AutovacMemCxt);
 		tab = table_recheck_autovac(relid, table_toast_map, pg_class_desc,
+									classRel->rd_tableam->relation_options,
 									effective_multixact_freeze_max_age);
 		if (tab == NULL)
 		{
@@ -2687,7 +2691,8 @@ deleted2:
  * be a risk; fortunately, it doesn't.
  */
 static AutoVacOpts *
-extract_autovac_opts(HeapTuple tup, TupleDesc pg_class_desc)
+extract_autovac_opts(HeapTuple tup, TupleDesc pg_class_desc,
+					 reloptions_function reloptions)
 {
 	bytea	   *relopts;
 	AutoVacOpts *av;
@@ -2696,7 +2701,7 @@ extract_autovac_opts(HeapTuple tup, TupleDesc pg_class_desc)
 		   ((Form_pg_class) GETSTRUCT(tup))->relkind == RELKIND_MATVIEW ||
 		   ((Form_pg_class) GETSTRUCT(tup))->relkind == RELKIND_TOASTVALUE);
 
-	relopts = extractRelOptions(tup, pg_class_desc, NULL);
+	relopts = extractRelOptions(tup, pg_class_desc, NULL, reloptions);
 	if (relopts == NULL)
 		return NULL;
 
@@ -2719,6 +2724,7 @@ extract_autovac_opts(HeapTuple tup, TupleDesc pg_class_desc)
 static autovac_table *
 table_recheck_autovac(Oid relid, HTAB *table_toast_map,
 					  TupleDesc pg_class_desc,
+					  reloptions_function reloptions,
 					  int effective_multixact_freeze_max_age)
 {
 	Form_pg_class classForm;
@@ -2739,7 +2745,7 @@ table_recheck_autovac(Oid relid, HTAB *table_toast_map,
 	 * Get the applicable reloptions.  If it is a TOAST table, try to get the
 	 * main table reloptions if the toast table itself doesn't have.
 	 */
-	avopts = extract_autovac_opts(classTup, pg_class_desc);
+	avopts = extract_autovac_opts(classTup, pg_class_desc, reloptions);
 	if (classForm->relkind == RELKIND_TOASTVALUE &&
 		avopts == NULL && table_toast_map != NULL)
 	{
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index 398114373e9..b6c309c8bd2 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -466,6 +466,7 @@ RelationParseRelOptions(Relation relation, HeapTuple tuple)
 {
 	bytea	   *options;
 	amoptions_function amoptsfn;
+	reloptions_function reloptsfn;
 
 	relation->rd_options = NULL;
 
@@ -477,13 +478,18 @@ RelationParseRelOptions(Relation relation, HeapTuple tuple)
 	{
 		case RELKIND_RELATION:
 		case RELKIND_TOASTVALUE:
-		case RELKIND_VIEW:
 		case RELKIND_MATVIEW:
+			reloptsfn = relation->rd_tableam->relation_options;
+			amoptsfn = NULL;
+			break;
+		case RELKIND_VIEW:
 		case RELKIND_PARTITIONED_TABLE:
+			reloptsfn = NULL;
 			amoptsfn = NULL;
 			break;
 		case RELKIND_INDEX:
 		case RELKIND_PARTITIONED_INDEX:
+			reloptsfn = NULL;
 			amoptsfn = relation->rd_indam->amoptions;
 			break;
 		default:
@@ -495,7 +501,8 @@ RelationParseRelOptions(Relation relation, HeapTuple tuple)
 	 * we might not have any other for pg_class yet (consider executing this
 	 * code for pg_class itself)
 	 */
-	options = extractRelOptions(tuple, GetPgClassDescriptor(), amoptsfn);
+	options = extractRelOptions(tuple, GetPgClassDescriptor(),
+								amoptsfn, reloptsfn);
 
 	/*
 	 * Copy parsed data into CacheMemoryContext.  To guard against the
diff --git a/src/include/access/reloptions.h b/src/include/access/reloptions.h
index 43445cdcc6c..d0ef7918856 100644
--- a/src/include/access/reloptions.h
+++ b/src/include/access/reloptions.h
@@ -21,6 +21,7 @@
 
 #include "access/amapi.h"
 #include "access/htup.h"
+#include "access/tableam.h"
 #include "access/tupdesc.h"
 #include "nodes/pg_list.h"
 #include "storage/lock.h"
@@ -224,7 +225,8 @@ extern Datum transformRelOptions(Datum oldOptions, List *defList,
 								 bool acceptOidsOff, bool isReset);
 extern List *untransformRelOptions(Datum options);
 extern bytea *extractRelOptions(HeapTuple tuple, TupleDesc tupdesc,
-								amoptions_function amoptions);
+								amoptions_function amoptions,
+								reloptions_function reloptsfun);
 extern void *build_reloptions(Datum reloptions, bool validate,
 							  relopt_kind kind,
 							  Size relopt_struct_size,
@@ -238,6 +240,8 @@ extern bytea *default_reloptions(Datum reloptions, bool validate,
 extern bytea *heap_reloptions(char relkind, Datum reloptions, bool validate);
 extern bytea *view_reloptions(Datum reloptions, bool validate);
 extern bytea *partitioned_table_reloptions(Datum reloptions, bool validate);
+extern bytea *table_reloptions(reloptions_function reloptsfun, Oid amid, char relkind,
+							   Datum reloptions, bool validate);
 extern bytea *index_reloptions(amoptions_function amoptions, Datum reloptions,
 							   bool validate);
 extern bytea *attribute_reloptions(Datum reloptions, bool validate);
diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h
index 131c050c15f..79ad91d201c 100644
--- a/src/include/access/tableam.h
+++ b/src/include/access/tableam.h
@@ -276,6 +276,14 @@ typedef void (*IndexBuildCallback) (Relation index,
 									bool tupleIsAlive,
 									void *state);
 
+/*
+ * Callback in charge of parsing and validating the table reloptions.
+ * It returns parsed options in bytea format.
+ */
+typedef bytea *(*reloptions_function) (char relkind,
+									   Datum reloptions,
+									   bool validate);
+
 /*
  * API struct for a table AM.  Note this must be allocated in a
  * server-lifetime manner, typically as a static const struct, which then gets
@@ -715,6 +723,8 @@ typedef struct TableAmRoutine
 	 * ------------------------------------------------------------------------
 	 */
 
+	reloptions_function relation_options;
+
 	/*
 	 * See table_relation_size().
 	 *
diff --git a/src/include/commands/defrem.h b/src/include/commands/defrem.h
index 6d9348bac80..cd0aaaa0b93 100644
--- a/src/include/commands/defrem.h
+++ b/src/include/commands/defrem.h
@@ -136,6 +136,7 @@ extern ObjectAddress AlterUserMapping(AlterUserMappingStmt *stmt);
 extern Oid	RemoveUserMapping(DropUserMappingStmt *stmt);
 extern void CreateForeignTable(CreateForeignTableStmt *stmt, Oid relid);
 extern void ImportForeignSchema(ImportForeignSchemaStmt *stmt);
+extern Datum optionListToArray(List *options);
 extern Datum transformGenericOptions(Oid catalogId,
 									 Datum oldOptions,
 									 List *options,
-- 
2.39.5

  [text/x-diff] v1-0002-Add-the-dummy_table_am-test-module.patch (33.3K, ../../20250302085641.hmjom5ru3w554t2n@poseidon.home.virt/3-v1-0002-Add-the-dummy_table_am-test-module.patch)
  download | inline diff:
From 993694f7c610c23e8b5ebf99ab501b1aede87bb9 Mon Sep 17 00:00:00 2001
From: Julien Tachoires <julien@tachoires.me>
Date: Sat, 1 Mar 2025 20:50:13 +0100
Subject: [PATCH 2/2] Add the "dummy_table_am" test module

This test module is in charge of testing TAM reloptions. It's very
similar to what we do in dummy_index_am as we have to exercise the
exact same kind of feature.
---
 src/test/modules/Makefile                     |   1 +
 src/test/modules/dummy_table_am/Makefile      |  20 +
 src/test/modules/dummy_table_am/README        |  14 +
 .../dummy_table_am/dummy_table_am--1.0.sql    |  13 +
 .../modules/dummy_table_am/dummy_table_am.c   | 588 ++++++++++++++++++
 .../dummy_table_am/dummy_table_am.control     |   5 +
 .../dummy_table_am/expected/reloptions.out    | 181 ++++++
 src/test/modules/dummy_table_am/meson.build   |  33 +
 .../modules/dummy_table_am/sql/reloptions.sql |  99 +++
 src/test/modules/meson.build                  |   1 +
 10 files changed, 955 insertions(+)
 create mode 100644 src/test/modules/dummy_table_am/Makefile
 create mode 100644 src/test/modules/dummy_table_am/README
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am.c
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am.control
 create mode 100644 src/test/modules/dummy_table_am/expected/reloptions.out
 create mode 100644 src/test/modules/dummy_table_am/meson.build
 create mode 100644 src/test/modules/dummy_table_am/sql/reloptions.sql

diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 4e4be3fa511..8fe2a2904d6 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -9,6 +9,7 @@ SUBDIRS = \
 		  commit_ts \
 		  delay_execution \
 		  dummy_index_am \
+		  dummy_table_am \
 		  dummy_seclabel \
 		  libpq_pipeline \
 		  oauth_validator \
diff --git a/src/test/modules/dummy_table_am/Makefile b/src/test/modules/dummy_table_am/Makefile
new file mode 100644
index 00000000000..94837dff392
--- /dev/null
+++ b/src/test/modules/dummy_table_am/Makefile
@@ -0,0 +1,20 @@
+# src/test/modules/dummy_table_am/Makefile
+
+MODULES = dummy_table_am
+
+EXTENSION = dummy_table_am
+DATA = dummy_table_am--1.0.sql
+PGFILEDESC = "dummy_table_am - table access method template"
+
+REGRESS = reloptions
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/dummy_table_am
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/dummy_table_am/README b/src/test/modules/dummy_table_am/README
new file mode 100644
index 00000000000..50cf08ee3b1
--- /dev/null
+++ b/src/test/modules/dummy_table_am/README
@@ -0,0 +1,14 @@
+Dummy Table AM
+==============
+
+Dummy table AM is a module for testing any facility usable by a table
+access method, whose code is kept a maximum simple.
+
+This includes tests for all relation option types:
+- boolean
+- enum
+- integer
+- real
+- strings (with and without NULL as default)
+
+It also includes tests related to unrecognized options.
diff --git a/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql b/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
new file mode 100644
index 00000000000..12ad3ad174b
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
@@ -0,0 +1,13 @@
+/* src/test/modules/dummy_table_am/dummy_table_am--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION dummy_table_am" to load this file. \quit
+
+CREATE FUNCTION dummy_table_am_handler(internal)
+RETURNS table_am_handler
+AS 'MODULE_PATHNAME'
+LANGUAGE C;
+
+-- Access method
+CREATE ACCESS METHOD dummy_table_am TYPE TABLE HANDLER dummy_table_am_handler;
+COMMENT ON ACCESS METHOD dummy_table_am IS 'Dummy Table Access Method';
diff --git a/src/test/modules/dummy_table_am/dummy_table_am.c b/src/test/modules/dummy_table_am/dummy_table_am.c
new file mode 100644
index 00000000000..a473bc7dd9b
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am.c
@@ -0,0 +1,588 @@
+/*-------------------------------------------------------------------------
+ *
+ * dummy_table_am.c
+ *		Table AM templae main file
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/test/modules/dummy_table_am/dummy_table_am.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "miscadmin.h"
+
+#include "access/hio.h"
+#include "access/relscan.h"
+#include "access/reloptions.h"
+#include "access/tableam.h"
+#include "access/sdir.h"
+#include "access/skey.h"
+#include "executor/tuptable.h"
+#include "utils/relcache.h"
+#include "utils/snapshot.h"
+
+
+PG_MODULE_MAGIC;
+
+/* Base structures for scans */
+typedef struct DummyScanDescData
+{
+	TableScanDescData rs_base;	/* AM independent part of the descriptor */
+
+	/* Add more fields here as needed by the AM. */
+}			DummyScanDescData;
+typedef struct DummyScanDescData *DummyScanDesc;
+
+/* parse table for fillRelOptions */
+static relopt_parse_elt dt_relopt_tab[7];
+
+/* Kind of relation options for dummy index */
+static relopt_kind dt_relopt_kind;
+
+typedef enum DummyAmEnum
+{
+	DUMMY_AM_ENUM_ONE,
+	DUMMY_AM_ENUM_TWO,
+}			DummyAmEnum;
+
+/* Dummy table options */
+typedef struct DummyTableOptions
+{
+	int32		vl_len_;		/* varlena header (do not touch directly!) */
+	int			option_int;
+	double		option_real;
+	bool		option_bool;
+	DummyAmEnum option_enum;
+	int			option_string_val_offset;
+	int			option_string_null_offset;
+	int			fillfactor;
+}			DummyTableOptions;
+
+static relopt_enum_elt_def dummyAmEnumValues[] =
+{
+	{"one", DUMMY_AM_ENUM_ONE},
+	{"two", DUMMY_AM_ENUM_TWO},
+	{(const char *) NULL}		/* list terminator */
+};
+
+/* ------------------------------------------------------------------------
+ *                     Dummy Access Method Interface
+ * ------------------------------------------------------------------------
+ */
+
+static const TupleTableSlotOps *
+dummy_slot_callbacks(Relation relation)
+{
+	return &TTSOpsMinimalTuple;
+}
+
+static TableScanDesc
+dummy_scan_begin(Relation relation, Snapshot snapshot, int nkeys, ScanKey key,
+				 ParallelTableScanDesc parallel_scan, uint32 flags)
+{
+	DummyScanDesc scan;
+
+	scan = (DummyScanDesc) palloc(sizeof(DummyScanDescData));
+
+	scan->rs_base.rs_rd = relation;
+	scan->rs_base.rs_snapshot = snapshot;
+	scan->rs_base.rs_nkeys = nkeys;
+	scan->rs_base.rs_flags = flags;
+	scan->rs_base.rs_parallel = parallel_scan;
+
+	return (TableScanDesc) scan;
+}
+
+static void
+dummy_scan_end(TableScanDesc sscan)
+{
+	DummyScanDesc scan = (DummyScanDesc) sscan;
+
+	pfree(scan);
+
+	return;
+}
+
+static void
+dummy_scan_rescan(TableScanDesc sscan, ScanKey key, bool set_params,
+				  bool allow_strat, bool allow_sync, bool allow_pagemode)
+{
+	return;
+}
+
+static bool
+dummy_scan_getnextslot(TableScanDesc sscan, ScanDirection direction,
+					   TupleTableSlot *slot)
+{
+	return true;
+}
+
+static void
+dummy_scan_set_tidrange(TableScanDesc sscan, ItemPointer mintid,
+						ItemPointer maxtid)
+{
+	return;
+}
+
+static bool
+dummy_scan_getnextslot_tidrange(TableScanDesc sscan, ScanDirection direction,
+								TupleTableSlot *slot)
+{
+	return true;
+}
+
+static Size
+dummy_parallelscan_estimate(Relation rel)
+{
+	return 0;
+}
+
+static Size
+dummy_parallelscan_initialize(Relation rel, ParallelTableScanDesc pscan)
+{
+	return 0;
+}
+
+static void
+dummy_parallelscan_reinitialize(Relation rel, ParallelTableScanDesc pscan)
+{
+	return;
+}
+
+static IndexFetchTableData *
+dummy_index_fetch_begin(Relation rel)
+{
+	return NULL;
+}
+
+static void
+dummy_index_fetch_reset(IndexFetchTableData *scan)
+{
+	return;
+}
+
+static void
+dummy_index_fetch_end(IndexFetchTableData *scan)
+{
+	return;
+}
+
+static bool
+dummy_index_fetch_tuple(struct IndexFetchTableData *scan, ItemPointer tid,
+						Snapshot snapshot, TupleTableSlot *slot,
+						bool *call_again, bool *all_dead)
+{
+	return true;
+}
+
+static void
+dummy_tuple_insert(Relation relation, TupleTableSlot *slot, CommandId cid,
+				   int options, BulkInsertStateData *bistate)
+{
+	DummyTableOptions *relopts;
+
+	relopts = (DummyTableOptions *) relation->rd_options;
+
+	elog(NOTICE, "option_int=%d, option_real=%f, option_bool=%d, option_enum=%d",
+		 relopts->option_int, relopts->option_real, relopts->option_bool, relopts->option_enum);
+
+	return;
+}
+
+static void
+dummy_tuple_insert_speculative(Relation relation, TupleTableSlot *slot,
+							   CommandId cid, int options,
+							   BulkInsertStateData *bistate, uint32 specToken)
+{
+	return;
+}
+
+static void
+dummy_tuple_complete_speculative(Relation relation, TupleTableSlot *slot,
+								 uint32 specToken, bool succeeded)
+{
+	return;
+}
+
+static void
+dummy_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples,
+				   CommandId cid, int options, BulkInsertStateData *bistate)
+{
+	return;
+}
+
+static TM_Result
+dummy_tuple_delete(Relation relation, ItemPointer tid, CommandId cid,
+				   Snapshot snapshot, Snapshot crosscheck, bool wait,
+				   TM_FailureData *tmfd, bool changingPart)
+{
+	return TM_Ok;
+}
+
+static TM_Result
+dummy_tuple_update(Relation relation, ItemPointer otid, TupleTableSlot *slot,
+				   CommandId cid, Snapshot snapshot, Snapshot crosscheck,
+				   bool wait, TM_FailureData *tmfd,
+				   LockTupleMode *lockmode, TU_UpdateIndexes *update_indexes)
+{
+	return TM_Ok;
+}
+
+static TM_Result
+dummy_tuple_lock(Relation relation, ItemPointer tid, Snapshot snapshot,
+				 TupleTableSlot *slot, CommandId cid, LockTupleMode mode,
+				 LockWaitPolicy wait_policy, uint8 flags,
+				 TM_FailureData *tmfd)
+{
+	return TM_Ok;
+}
+
+static bool
+dummy_fetch_row_version(Relation relation, ItemPointer tid,
+						Snapshot snapshot, TupleTableSlot *slot)
+{
+	return false;
+}
+
+static void
+dummy_get_latest_tid(TableScanDesc sscan, ItemPointer tid)
+{
+	return;
+}
+
+static bool
+dummy_tuple_tid_valid(TableScanDesc scan, ItemPointer tid)
+{
+	return false;
+}
+
+static bool
+dummy_tuple_satisfies_snapshot(Relation rel, TupleTableSlot *slot,
+							   Snapshot snapshot)
+{
+	return false;
+}
+
+static TransactionId
+dummy_index_delete_tuples(Relation rel, TM_IndexDeleteOp *delstate)
+{
+	return InvalidTransactionId;
+}
+
+static void
+dummy_relation_set_new_filelocator(Relation rel,
+								   const RelFileLocator *newrlocator,
+								   char persistence,
+								   TransactionId *freezeXid,
+								   MultiXactId *minmulti)
+{
+	return;
+}
+
+static void
+dummy_relation_nontransactional_truncate(Relation rel)
+{
+	return;
+}
+
+static void
+dummy_relation_copy_data(Relation rel, const RelFileLocator *newrlocator)
+{
+	return;
+}
+
+static void
+dummy_relation_copy_for_cluster(Relation OldHeap, Relation NewHeap,
+								Relation OldIndex, bool use_sort,
+								TransactionId OldestXmin,
+								TransactionId *xid_cutoff,
+								MultiXactId *multi_cutoff,
+								double *num_tuples,
+								double *tups_vacuumed,
+								double *tups_recently_dead)
+{
+	return;
+}
+
+static void
+dummy_relation_vacuum(Relation rel, struct VacuumParams *params,
+					  BufferAccessStrategy bstrategy)
+{
+	return;
+}
+
+static bool
+dummy_scan_analyze_next_block(TableScanDesc scan, ReadStream *stream)
+{
+	return false;
+}
+
+static bool
+dummy_scan_analyze_next_tuple(TableScanDesc scan, TransactionId OldestXmin,
+							  double *liverows, double *deadrows,
+							  TupleTableSlot *slot)
+{
+	return false;
+}
+
+static double
+dummy_index_build_range_scan(Relation heapRelation,
+							 Relation indexRelation,
+							 struct IndexInfo *indexInfo,
+							 bool allow_sync,
+							 bool anyvisible,
+							 bool progress,
+							 BlockNumber start_blockno,
+							 BlockNumber numblocks,
+							 IndexBuildCallback callback,
+							 void *callback_state,
+							 TableScanDesc scan)
+{
+	return 0;
+}
+
+static void
+dummy_index_validate_scan(Relation heapRelation,
+						  Relation indexRelation,
+						  struct IndexInfo *indexInfo,
+						  Snapshot snapshot,
+						  struct ValidateIndexState *state)
+{
+	return;
+}
+
+static uint64
+dummy_relation_size(Relation rel, ForkNumber forkNumber)
+{
+	return 0;
+}
+
+static bool
+dummy_relation_needs_toast_table(Relation rel)
+{
+	return false;
+}
+
+static Oid
+dummy_relation_toast_am(Relation rel)
+{
+	return InvalidOid;
+}
+
+static void
+dummy_relation_fetch_toast_slice(Relation toastrel, Oid valueid, int32 attrsize,
+								 int32 sliceoffset, int32 slicelength,
+								 struct varlena *result)
+{
+	return;
+}
+
+static void
+dummy_relation_estimate_size(Relation rel, int32 *attr_widths,
+							 BlockNumber *pages, double *tuples,
+							 double *allvisfrac)
+{
+	return;
+}
+
+static bool
+dummy_scan_bitmap_next_block(TableScanDesc scan, BlockNumber *blockno,
+							 bool *recheck, uint64 *lossy_pages,
+							 uint64 *exact_pages)
+{
+	return false;
+}
+
+static bool
+dummy_scan_bitmap_next_tuple(TableScanDesc scan, TupleTableSlot *slot)
+{
+	return false;
+}
+
+static bool
+dummy_scan_sample_next_block(TableScanDesc scan, struct SampleScanState *scanstate)
+{
+	return false;
+}
+
+static bool
+dummy_scan_sample_next_tuple(TableScanDesc scan, struct SampleScanState *scanstate,
+							 TupleTableSlot *slot)
+{
+	return false;
+}
+
+static bytea *
+dummy_relation_options(char relkind, Datum reloptions, bool validate)
+{
+	return (bytea *) build_reloptions(reloptions, validate,
+									  dt_relopt_kind,
+									  sizeof(DummyTableOptions),
+									  dt_relopt_tab, lengthof(dt_relopt_tab));
+}
+
+/*
+ * Validation function for string relation options.
+ */
+static void
+validate_string_option(const char *value)
+{
+	ereport(NOTICE,
+			(errmsg("new option value for string parameter %s",
+					value ? value : "NULL")));
+}
+
+/*
+ * This function creates a full set of relation option types,
+ * with various patterns.
+ */
+static void
+create_reloptions_table(void)
+{
+	dt_relopt_kind = add_reloption_kind();
+
+	add_int_reloption(dt_relopt_kind, "option_int",
+					  "Integer option for dummy_table_am",
+					  10, -10, 100, AccessExclusiveLock);
+	dt_relopt_tab[0].optname = "option_int";
+	dt_relopt_tab[0].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[0].offset = offsetof(DummyTableOptions, option_int);
+
+	add_real_reloption(dt_relopt_kind, "option_real",
+					   "Real option for dummy_table_am",
+					   3.1415, -10, 100, AccessExclusiveLock);
+	dt_relopt_tab[1].optname = "option_real";
+	dt_relopt_tab[1].opttype = RELOPT_TYPE_REAL;
+	dt_relopt_tab[1].offset = offsetof(DummyTableOptions, option_real);
+
+	add_bool_reloption(dt_relopt_kind, "option_bool",
+					   "Boolean option for dummy_table_am",
+					   true, AccessExclusiveLock);
+	dt_relopt_tab[2].optname = "option_bool";
+	dt_relopt_tab[2].opttype = RELOPT_TYPE_BOOL;
+	dt_relopt_tab[2].offset = offsetof(DummyTableOptions, option_bool);
+
+	add_enum_reloption(dt_relopt_kind, "option_enum",
+					   "Enum option for dummy_table_am",
+					   dummyAmEnumValues,
+					   DUMMY_AM_ENUM_ONE,
+					   "Valid values are \"one\" and \"two\".",
+					   AccessExclusiveLock);
+	dt_relopt_tab[3].optname = "option_enum";
+	dt_relopt_tab[3].opttype = RELOPT_TYPE_ENUM;
+	dt_relopt_tab[3].offset = offsetof(DummyTableOptions, option_enum);
+
+	add_string_reloption(dt_relopt_kind, "option_string_val",
+						 "String option for dummy_table_am with non-NULL default",
+						 "DefaultValue", &validate_string_option,
+						 AccessExclusiveLock);
+	dt_relopt_tab[4].optname = "option_string_val";
+	dt_relopt_tab[4].opttype = RELOPT_TYPE_STRING;
+	dt_relopt_tab[4].offset = offsetof(DummyTableOptions,
+									   option_string_val_offset);
+
+	/*
+	 * String option for dummy_table_am with NULL default, and without
+	 * description.
+	 */
+	add_string_reloption(dt_relopt_kind, "option_string_null",
+						 NULL,	/* description */
+						 NULL, &validate_string_option,
+						 AccessExclusiveLock);
+	dt_relopt_tab[5].optname = "option_string_null";
+	dt_relopt_tab[5].opttype = RELOPT_TYPE_STRING;
+	dt_relopt_tab[5].offset = offsetof(DummyTableOptions,
+									   option_string_null_offset);
+
+	/*
+	 * fillfactor will be used to check reloption conversion when changing
+	 * table access method between heap AM and dummy_table_am.
+	 */
+	add_int_reloption(dt_relopt_kind, "fillfactor",
+					  "Fillfactor option for dummy_table_am",
+					  10, 0, 90, AccessExclusiveLock);
+	dt_relopt_tab[6].optname = "fillfactor";
+	dt_relopt_tab[6].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[6].offset = offsetof(DummyTableOptions, fillfactor);
+}
+
+
+/*
+ * Table Access Method API
+ */
+static const TableAmRoutine dummy_table_am_methods = {
+	.type = T_TableAmRoutine,
+
+	.slot_callbacks = dummy_slot_callbacks,
+	.scan_begin = dummy_scan_begin,
+	.scan_end = dummy_scan_end,
+	.scan_rescan = dummy_scan_rescan,
+	.scan_getnextslot = dummy_scan_getnextslot,
+
+	.scan_set_tidrange = dummy_scan_set_tidrange,
+	.scan_getnextslot_tidrange = dummy_scan_getnextslot_tidrange,
+
+	.parallelscan_estimate = dummy_parallelscan_estimate,
+	.parallelscan_initialize = dummy_parallelscan_initialize,
+	.parallelscan_reinitialize = dummy_parallelscan_reinitialize,
+
+	.index_fetch_begin = dummy_index_fetch_begin,
+	.index_fetch_reset = dummy_index_fetch_reset,
+	.index_fetch_end = dummy_index_fetch_end,
+	.index_fetch_tuple = dummy_index_fetch_tuple,
+
+	.tuple_insert = dummy_tuple_insert,
+	.tuple_insert_speculative = dummy_tuple_insert_speculative,
+	.tuple_complete_speculative = dummy_tuple_complete_speculative,
+	.multi_insert = dummy_multi_insert,
+	.tuple_delete = dummy_tuple_delete,
+	.tuple_update = dummy_tuple_update,
+	.tuple_lock = dummy_tuple_lock,
+
+	.tuple_fetch_row_version = dummy_fetch_row_version,
+	.tuple_get_latest_tid = dummy_get_latest_tid,
+	.tuple_tid_valid = dummy_tuple_tid_valid,
+	.tuple_satisfies_snapshot = dummy_tuple_satisfies_snapshot,
+	.index_delete_tuples = dummy_index_delete_tuples,
+
+	.relation_set_new_filelocator = dummy_relation_set_new_filelocator,
+	.relation_nontransactional_truncate = dummy_relation_nontransactional_truncate,
+	.relation_copy_data = dummy_relation_copy_data,
+	.relation_copy_for_cluster = dummy_relation_copy_for_cluster,
+	.relation_vacuum = dummy_relation_vacuum,
+	.scan_analyze_next_block = dummy_scan_analyze_next_block,
+	.scan_analyze_next_tuple = dummy_scan_analyze_next_tuple,
+	.index_build_range_scan = dummy_index_build_range_scan,
+	.index_validate_scan = dummy_index_validate_scan,
+
+	.relation_size = dummy_relation_size,
+	.relation_needs_toast_table = dummy_relation_needs_toast_table,
+	.relation_toast_am = dummy_relation_toast_am,
+	.relation_fetch_toast_slice = dummy_relation_fetch_toast_slice,
+	.relation_estimate_size = dummy_relation_estimate_size,
+	.relation_options = dummy_relation_options,
+
+	.scan_bitmap_next_block = dummy_scan_bitmap_next_block,
+	.scan_bitmap_next_tuple = dummy_scan_bitmap_next_tuple,
+	.scan_sample_next_block = dummy_scan_sample_next_block,
+	.scan_sample_next_tuple = dummy_scan_sample_next_tuple
+};
+
+PG_FUNCTION_INFO_V1(dummy_table_am_handler);
+
+Datum
+dummy_table_am_handler(PG_FUNCTION_ARGS)
+{
+	PG_RETURN_POINTER(&dummy_table_am_methods);
+}
+
+void
+_PG_init(void)
+{
+	create_reloptions_table();
+}
diff --git a/src/test/modules/dummy_table_am/dummy_table_am.control b/src/test/modules/dummy_table_am/dummy_table_am.control
new file mode 100644
index 00000000000..08f2f868d49
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am.control
@@ -0,0 +1,5 @@
+# dummy_table_am extension
+comment = 'dummy_table_am - table access method template'
+default_version = '1.0'
+module_pathname = '$libdir/dummy_table_am'
+relocatable = true
diff --git a/src/test/modules/dummy_table_am/expected/reloptions.out b/src/test/modules/dummy_table_am/expected/reloptions.out
new file mode 100644
index 00000000000..4c08ac4e3ac
--- /dev/null
+++ b/src/test/modules/dummy_table_am/expected/reloptions.out
@@ -0,0 +1,181 @@
+-- Tests for relation options
+CREATE EXTENSION dummy_table_am;
+CREATE TABLE dummy_test_tab (i int4) USING dummy_table_am;
+-- Silence validation checks for strings
+SET client_min_messages TO 'warning';
+-- Test with default values.
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+ unnest 
+--------
+(0 rows)
+
+DROP TABLE dummy_test_tab;
+-- Test with full set of options.
+-- Allow validation checks for strings
+SET client_min_messages TO 'notice';
+CREATE TABLE dummy_test_tab (i int4)
+  USING dummy_table_am WITH (
+  option_bool = false,
+  option_int = 5,
+  option_real = 3.1,
+  option_enum = 'two',
+  option_string_val = NULL,
+  option_string_null = 'val');
+NOTICE:  new option value for string parameter null
+NOTICE:  new option value for string parameter val
+-- Silence again validation checks for strings until the end of the test.
+SET client_min_messages TO 'warning';
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+         unnest         
+------------------------
+ option_bool=false
+ option_int=5
+ option_real=3.1
+ option_enum=two
+ option_string_val=null
+ option_string_null=val
+(6 rows)
+
+-- ALTER TABLE .. SET
+ALTER TABLE dummy_test_tab SET (option_int = 10);
+ALTER TABLE dummy_test_tab SET (option_bool = true);
+ALTER TABLE dummy_test_tab SET (option_real = 3.2);
+ALTER TABLE dummy_test_tab SET (option_string_val = 'val2');
+ALTER TABLE dummy_test_tab SET (option_string_null = NULL);
+ALTER TABLE dummy_test_tab SET (option_enum = 'one');
+ALTER TABLE dummy_test_tab SET (option_enum = 'three');
+ERROR:  invalid value for enum option "option_enum": three
+DETAIL:  Valid values are "one" and "two".
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+         unnest          
+-------------------------
+ option_int=10
+ option_bool=true
+ option_real=3.2
+ option_string_val=val2
+ option_string_null=null
+ option_enum=one
+(6 rows)
+
+-- ALTER TABLE .. RESET
+ALTER TABLE dummy_test_tab RESET (option_int);
+ALTER TABLE dummy_test_tab RESET (option_bool);
+ALTER TABLE dummy_test_tab RESET (option_real);
+ALTER TABLE dummy_test_tab RESET (option_enum);
+ALTER TABLE dummy_test_tab RESET (option_string_val);
+ALTER TABLE dummy_test_tab RESET (option_string_null);
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+ unnest 
+--------
+(0 rows)
+
+-- Cross-type checks for reloption values
+-- Integer
+ALTER TABLE dummy_test_tab SET (option_int = 3.3); -- ok
+ALTER TABLE dummy_test_tab SET (option_int = true); -- error
+ERROR:  invalid value for integer option "option_int": true
+ALTER TABLE dummy_test_tab SET (option_int = 'val3'); -- error
+ERROR:  invalid value for integer option "option_int": val3
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+     unnest     
+----------------
+ option_int=3.3
+(1 row)
+
+ALTER TABLE dummy_test_tab RESET (option_int);
+-- Boolean
+ALTER TABLE dummy_test_tab SET (option_bool = 4); -- error
+ERROR:  invalid value for boolean option "option_bool": 4
+ALTER TABLE dummy_test_tab SET (option_bool = 1); -- ok, as true
+ALTER TABLE dummy_test_tab SET (option_bool = 3.4); -- error
+ERROR:  invalid value for boolean option "option_bool": 3.4
+ALTER TABLE dummy_test_tab SET (option_bool = 'val4'); -- error
+ERROR:  invalid value for boolean option "option_bool": val4
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+    unnest     
+---------------
+ option_bool=1
+(1 row)
+
+ALTER TABLE dummy_test_tab RESET (option_bool);
+-- Float
+ALTER TABLE dummy_test_tab SET (option_real = 4); -- ok
+ALTER TABLE dummy_test_tab SET (option_real = true); -- error
+ERROR:  invalid value for floating point option "option_real": true
+ALTER TABLE dummy_test_tab SET (option_real = 'val5'); -- error
+ERROR:  invalid value for floating point option "option_real": val5
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+    unnest     
+---------------
+ option_real=4
+(1 row)
+
+ALTER TABLE dummy_test_tab RESET (option_real);
+-- Enum
+ALTER TABLE dummy_test_tab SET (option_enum = 'one'); -- ok
+ALTER TABLE dummy_test_tab SET (option_enum = 0); -- error
+ERROR:  invalid value for enum option "option_enum": 0
+DETAIL:  Valid values are "one" and "two".
+ALTER TABLE dummy_test_tab SET (option_enum = true); -- error
+ERROR:  invalid value for enum option "option_enum": true
+DETAIL:  Valid values are "one" and "two".
+ALTER TABLE dummy_test_tab SET (option_enum = 'three'); -- error
+ERROR:  invalid value for enum option "option_enum": three
+DETAIL:  Valid values are "one" and "two".
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+     unnest      
+-----------------
+ option_enum=one
+(1 row)
+
+ALTER TABLE dummy_test_tab RESET (option_enum);
+-- String
+ALTER TABLE dummy_test_tab SET (option_string_val = 4); -- ok
+ALTER TABLE dummy_test_tab SET (option_string_val = 3.5); -- ok
+ALTER TABLE dummy_test_tab SET (option_string_val = true); -- ok, as "true"
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+         unnest         
+------------------------
+ option_string_val=true
+(1 row)
+
+ALTER TABLE dummy_test_tab RESET (option_string_val);
+DROP TABLE dummy_test_tab;
+-- ALTER TABLE SET ACCESS METHOD OPTIONS
+CREATE TABLE heap_tab (i INT4) WITH (fillfactor=100, toast_tuple_target=1000);
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'heap_tab';
+         unnest          
+-------------------------
+ fillfactor=100
+ toast_tuple_target=1000
+(2 rows)
+
+-- error: fillfactor is out of bounds: maximum value from the new table am is 90
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am;
+ERROR:  value 100 out of bounds for option "fillfactor"
+DETAIL:  Valid values are between "0" and "90".
+-- error: toast_tuple_target does not exist in the new table AM
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (SET fillfactor '50');
+ERROR:  unrecognized parameter "toast_tuple_target"
+-- error: adding is not possible when the parameter is already defined in source reloptions
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (ADD fillfactor '50');
+ERROR:  option "fillfactor" provided more than once
+-- error: the specified option we want to drop does not exist 
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (DROP does_not_exist);
+ERROR:  option "does_not_exist" not found
+-- error: adding unrecognized parameter
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (SET fillfactor '50', DROP toast_tuple_target, ADD unrecognized 'foo');
+ERROR:  unrecognized parameter "unrecognized"
+-- ok
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (DROP fillfactor, DROP toast_tuple_target, option_int '1', option_bool 'true', option_real '0.001', option_enum 'one', option_string_val 'hello');
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'heap_tab';
+         unnest          
+-------------------------
+ option_int=1
+ option_bool=true
+ option_real=0.001
+ option_enum=one
+ option_string_val=hello
+(5 rows)
+
+DROP TABLE heap_tab;
diff --git a/src/test/modules/dummy_table_am/meson.build b/src/test/modules/dummy_table_am/meson.build
new file mode 100644
index 00000000000..6b197b15ffa
--- /dev/null
+++ b/src/test/modules/dummy_table_am/meson.build
@@ -0,0 +1,33 @@
+# Copyright (c) 2022-2025, PostgreSQL Global Development Group
+
+dummy_table_am_sources = files(
+  'dummy_table_am.c',
+)
+
+if host_system == 'windows'
+  dummy_table_am_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'dummy_table_am',
+    '--FILEDESC', 'dummy_table_am - table access method template',])
+endif
+
+dummy_table_am = shared_module('dummy_table_am',
+  dummy_table_am_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += dummy_table_am
+
+test_install_data += files(
+  'dummy_table_am.control',
+  'dummy_table_am--1.0.sql',
+)
+
+tests += {
+  'name': 'dummy_table_am',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'reloptions',
+    ],
+  },
+}
diff --git a/src/test/modules/dummy_table_am/sql/reloptions.sql b/src/test/modules/dummy_table_am/sql/reloptions.sql
new file mode 100644
index 00000000000..ce02533d42e
--- /dev/null
+++ b/src/test/modules/dummy_table_am/sql/reloptions.sql
@@ -0,0 +1,99 @@
+-- Tests for relation options
+CREATE EXTENSION dummy_table_am;
+
+CREATE TABLE dummy_test_tab (i int4) USING dummy_table_am;
+
+-- Silence validation checks for strings
+SET client_min_messages TO 'warning';
+
+-- Test with default values.
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+DROP TABLE dummy_test_tab;
+
+-- Test with full set of options.
+-- Allow validation checks for strings
+SET client_min_messages TO 'notice';
+CREATE TABLE dummy_test_tab (i int4)
+  USING dummy_table_am WITH (
+  option_bool = false,
+  option_int = 5,
+  option_real = 3.1,
+  option_enum = 'two',
+  option_string_val = NULL,
+  option_string_null = 'val');
+-- Silence again validation checks for strings until the end of the test.
+SET client_min_messages TO 'warning';
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+
+-- ALTER TABLE .. SET
+ALTER TABLE dummy_test_tab SET (option_int = 10);
+ALTER TABLE dummy_test_tab SET (option_bool = true);
+ALTER TABLE dummy_test_tab SET (option_real = 3.2);
+ALTER TABLE dummy_test_tab SET (option_string_val = 'val2');
+ALTER TABLE dummy_test_tab SET (option_string_null = NULL);
+ALTER TABLE dummy_test_tab SET (option_enum = 'one');
+ALTER TABLE dummy_test_tab SET (option_enum = 'three');
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+
+-- ALTER TABLE .. RESET
+ALTER TABLE dummy_test_tab RESET (option_int);
+ALTER TABLE dummy_test_tab RESET (option_bool);
+ALTER TABLE dummy_test_tab RESET (option_real);
+ALTER TABLE dummy_test_tab RESET (option_enum);
+ALTER TABLE dummy_test_tab RESET (option_string_val);
+ALTER TABLE dummy_test_tab RESET (option_string_null);
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+
+-- Cross-type checks for reloption values
+-- Integer
+ALTER TABLE dummy_test_tab SET (option_int = 3.3); -- ok
+ALTER TABLE dummy_test_tab SET (option_int = true); -- error
+ALTER TABLE dummy_test_tab SET (option_int = 'val3'); -- error
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+ALTER TABLE dummy_test_tab RESET (option_int);
+-- Boolean
+ALTER TABLE dummy_test_tab SET (option_bool = 4); -- error
+ALTER TABLE dummy_test_tab SET (option_bool = 1); -- ok, as true
+ALTER TABLE dummy_test_tab SET (option_bool = 3.4); -- error
+ALTER TABLE dummy_test_tab SET (option_bool = 'val4'); -- error
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+ALTER TABLE dummy_test_tab RESET (option_bool);
+-- Float
+ALTER TABLE dummy_test_tab SET (option_real = 4); -- ok
+ALTER TABLE dummy_test_tab SET (option_real = true); -- error
+ALTER TABLE dummy_test_tab SET (option_real = 'val5'); -- error
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+ALTER TABLE dummy_test_tab RESET (option_real);
+-- Enum
+ALTER TABLE dummy_test_tab SET (option_enum = 'one'); -- ok
+ALTER TABLE dummy_test_tab SET (option_enum = 0); -- error
+ALTER TABLE dummy_test_tab SET (option_enum = true); -- error
+ALTER TABLE dummy_test_tab SET (option_enum = 'three'); -- error
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+ALTER TABLE dummy_test_tab RESET (option_enum);
+-- String
+ALTER TABLE dummy_test_tab SET (option_string_val = 4); -- ok
+ALTER TABLE dummy_test_tab SET (option_string_val = 3.5); -- ok
+ALTER TABLE dummy_test_tab SET (option_string_val = true); -- ok, as "true"
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+ALTER TABLE dummy_test_tab RESET (option_string_val);
+
+DROP TABLE dummy_test_tab;
+
+-- ALTER TABLE SET ACCESS METHOD OPTIONS
+CREATE TABLE heap_tab (i INT4) WITH (fillfactor=100, toast_tuple_target=1000);
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'heap_tab';
+-- error: fillfactor is out of bounds: maximum value from the new table am is 90
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am;
+-- error: toast_tuple_target does not exist in the new table AM
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (SET fillfactor '50');
+-- error: adding is not possible when the parameter is already defined in source reloptions
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (ADD fillfactor '50');
+-- error: the specified option we want to drop does not exist 
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (DROP does_not_exist);
+-- error: adding unrecognized parameter
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (SET fillfactor '50', DROP toast_tuple_target, ADD unrecognized 'foo');
+-- ok
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (DROP fillfactor, DROP toast_tuple_target, option_int '1', option_bool 'true', option_real '0.001', option_enum 'one', option_string_val 'hello');
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'heap_tab';
+DROP TABLE heap_tab;
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 2b057451473..28398254df7 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -4,6 +4,7 @@ subdir('brin')
 subdir('commit_ts')
 subdir('delay_execution')
 subdir('dummy_index_am')
+subdir('dummy_table_am')
 subdir('dummy_seclabel')
 subdir('gin')
 subdir('injection_points')
-- 
2.39.5

^ permalink  raw  reply  [nested|flat] 25+ messages in thread

* Re: Allow table AMs to define their own reloptions
@ 2025-03-02 13:23  Julien Tachoires <julien@tachoires.me>
  parent: Julien Tachoires <julien@tachoires.me>
  0 siblings, 2 replies; 25+ messages in thread

From: Julien Tachoires @ 2025-03-02 13:23 UTC (permalink / raw)
  To: pgsql-hackers

On Sun, Mar 02, 2025 at 09:56:41AM +0100, Julien Tachoires wrote:
> With the help of the new TAM routine 'relation_options', table access 
> methods can with this patch define their own reloptions 
> parser/validator.
> 
> These reloptions can be set via the following commands:
> 1. CREATE TABLE ... USING table_am
>        WITH (option1='value1', option2='value2');
> 2. ALTER TABLE ...
>        SET (option1 'value1', option2 'value2');
> 3. ALTER TABLE ... SET ACCESS METHOD table_am
>        OPTIONS (option1 'value1', option2 'value2');
> 
> When changing table's access method, the settings inherited from the 
> former TAM can be dropped (if not supported by the new TAM) via: DROP 
> option, or, updated via: SET option 'value'.
> 
> Currently, tables using different TAMs than heap are able to use heap's 
> reloptions (fillfactor, toast_tuple_target, etc...). With this patch 
> applied, this is not the case anymore: if the TAM needs to have access 
> to similar settings to heap ones, they have to explicitly define them.
> 
> The 2nd patch file includes a new test module 'dummy_table_am' which 
> implements a dummy table access method utilized to exercise TAM 
> reloptions. This test module is strongly based on what we already have 
> in 'dummy_index_am'. 'dummy_table_am' provides a complete example of TAM 
> reloptions definition.
> 
> This work is directly derived from SadhuPrasad's patch here [2]. Others 
> attempts were posted here [1] and here [3].
> 
> [1] https://www.postgresql.org/message-id/flat/429fb58fa3218221bb17c7bf9e70e1aa6cfc6b5d.camel%40j-davis....
> [2] https://www.postgresql.org/message-id/flat/CAFF0-CG4KZHdtYHMsonWiXNzj16gWZpduXAn8yF7pDDub+GQMg@mail....
> [3] https://www.postgresql.org/message-id/flat/AMUA1wBBBxfc3tKRLLdU64rb.1.1683276279979.Hmail.wuhao%40ha...

Please find a new version including minor fixes: 'TAM' terms are
replaced by 'table AM'

-- 
Julien Tachoires

Attachments:

  [text/x-diff] v2-0001-Allow-table-AMs-to-define-their-own-reloptions.patch (25.0K, ../../20250302132354.ffbfokeq36dp2kss@poseidon.home.virt/2-v2-0001-Allow-table-AMs-to-define-their-own-reloptions.patch)
  download | inline diff:
From 4b20842ae509f6c330c48e67944442fd4c966e3f Mon Sep 17 00:00:00 2001
From: Julien Tachoires <julien@tachoires.me>
Date: Sat, 1 Mar 2025 17:59:49 +0100
Subject: [PATCH 1/2] Allow table AMs to define their own reloptions

With the help of the new routine 'relation_options', table access
methods can now define their own reloptions.

These options can be set via the following commands:
1. CREATE TABLE ... USING table_am
       WITH (option1='value1', option2='value2');
2. ALTER TABLE ...
       SET (option1 'value1', option2 'value2');
3. ALTER TABLE ... SET ACCESS METHOD table_am
       OPTIONS (option1 'value1', option2 'value2');

When changing table's access method, the settings from the former
table AM can be dropped (if not supported by the new table AM) via:
DROP option, or, updated via: SET option 'value'.

Before this commit, tables using different table AMs than heap were
able to use heap's reloptions (fillfactor, toast_tuple_target,
etc...). Now, this is not the case anymore: if the table AM needs
to have access to settings similar to heap ones, they must
explicitly define them.

This work is directly derived from SadhuPrasad's patch named:
v4-0001-PATCH-V4-Per-table-storage-parameters-for-TableAM.patch
---
 doc/src/sgml/ref/alter_table.sgml        |  13 +-
 doc/src/sgml/ref/create_table.sgml       |   3 +-
 src/backend/access/common/reloptions.c   |  66 ++++++++-
 src/backend/access/heap/heapam_handler.c |   2 +
 src/backend/commands/foreigncmds.c       |   2 +-
 src/backend/commands/tablecmds.c         | 180 ++++++++++++++++++++---
 src/backend/parser/gram.y                |   9 ++
 src/backend/postmaster/autovacuum.c      |  18 ++-
 src/backend/utils/cache/relcache.c       |  11 +-
 src/include/access/reloptions.h          |   6 +-
 src/include/access/tableam.h             |  10 ++
 src/include/commands/defrem.h            |   1 +
 12 files changed, 286 insertions(+), 35 deletions(-)

diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index 8e56b8e59b0..e38200e20d2 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -76,7 +76,7 @@ ALTER TABLE [ IF EXISTS ] <replaceable class="parameter">name</replaceable>
     CLUSTER ON <replaceable class="parameter">index_name</replaceable>
     SET WITHOUT CLUSTER
     SET WITHOUT OIDS
-    SET ACCESS METHOD { <replaceable class="parameter">new_access_method</replaceable> | DEFAULT }
+    SET ACCESS METHOD { <replaceable class="parameter">new_access_method</replaceable> | DEFAULT } [ OPTIONS ( [ ADD | SET | DROP ] <replaceable class="parameter">option</replaceable> ['<replaceable class="parameter">value</replaceable>'] [, ... ] ) ]
     SET TABLESPACE <replaceable class="parameter">new_tablespace</replaceable>
     SET { LOGGED | UNLOGGED }
     SET ( <replaceable class="parameter">storage_parameter</replaceable> [= <replaceable class="parameter">value</replaceable>] [, ... ] )
@@ -734,7 +734,7 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
    </varlistentry>
 
    <varlistentry id="sql-altertable-desc-set-access-method">
-    <term><literal>SET ACCESS METHOD</literal></term>
+    <term><literal>SET ACCESS METHOD { <replaceable class="parameter">new_access_method</replaceable> | DEFAULT } [ OPTIONS ( [ ADD | SET | DROP ] <replaceable class="parameter">option</replaceable> ['<replaceable class="parameter">value</replaceable>'] [, ... ] ) ]</literal></term>
     <listitem>
      <para>
       This form changes the access method of the table by rewriting it
@@ -752,6 +752,15 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
       causing future partitions to default to
       <varname>default_table_access_method</varname>.
      </para>
+     <para>
+      Specifying <literal>OPTIONS</literal> allows to change options for
+      the table when changing the table access method.
+      <literal>ADD</literal>, <literal>SET</literal>, and
+      <literal>DROP</literal> specify the action to be performed.
+      <literal>ADD</literal> is assumed if no operation is explicitly
+      specified.  Option names must be unique; names and values are also
+      validated using the table access method's library.
+     </para>
     </listitem>
    </varlistentry>
 
diff --git a/doc/src/sgml/ref/create_table.sgml b/doc/src/sgml/ref/create_table.sgml
index 0a3e520f215..96ecb2ee060 100644
--- a/doc/src/sgml/ref/create_table.sgml
+++ b/doc/src/sgml/ref/create_table.sgml
@@ -1548,7 +1548,8 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
     Storage parameters for
     indexes are documented in <xref linkend="sql-createindex"/>.
     The storage parameters currently
-    available for tables are listed below.  For many of these parameters, as
+    available for tables are listed below. Each table may have different set of storage
+    parameters through different access methods. For many of these parameters, as
     shown, there is an additional parameter with the same name prefixed with
     <literal>toast.</literal>, which controls the behavior of the
     table's secondary <acronym>TOAST</acronym> table, if any
diff --git a/src/backend/access/common/reloptions.c b/src/backend/access/common/reloptions.c
index 59fb53e7707..eb39f1d3378 100644
--- a/src/backend/access/common/reloptions.c
+++ b/src/backend/access/common/reloptions.c
@@ -25,6 +25,7 @@
 #include "access/reloptions.h"
 #include "access/spgist_private.h"
 #include "catalog/pg_type.h"
+#include "catalog/pg_am.h"
 #include "commands/defrem.h"
 #include "commands/tablespace.h"
 #include "nodes/makefuncs.h"
@@ -34,6 +35,7 @@
 #include "utils/guc.h"
 #include "utils/memutils.h"
 #include "utils/rel.h"
+#include "utils/syscache.h"
 
 /*
  * Contents of pg_class.reloptions
@@ -1396,7 +1398,7 @@ untransformRelOptions(Datum options)
  */
 bytea *
 extractRelOptions(HeapTuple tuple, TupleDesc tupdesc,
-				  amoptions_function amoptions)
+				  amoptions_function amoptions, reloptions_function reloptsfun)
 {
 	bytea	   *options;
 	bool		isnull;
@@ -1418,7 +1420,8 @@ extractRelOptions(HeapTuple tuple, TupleDesc tupdesc,
 		case RELKIND_RELATION:
 		case RELKIND_TOASTVALUE:
 		case RELKIND_MATVIEW:
-			options = heap_reloptions(classForm->relkind, datum, false);
+			options = table_reloptions(reloptsfun, InvalidOid, classForm->relkind,
+									   datum, false);
 			break;
 		case RELKIND_PARTITIONED_TABLE:
 			options = partitioned_table_reloptions(datum, false);
@@ -2036,7 +2039,8 @@ view_reloptions(Datum reloptions, bool validate)
 }
 
 /*
- * Parse options for heaps, views and toast tables.
+ * Parse options for heaps, views and toast tables. This is the implementation
+ * of relOptions for the access method heap.
  */
 bytea *
 heap_reloptions(char relkind, Datum reloptions, bool validate)
@@ -2066,6 +2070,62 @@ heap_reloptions(char relkind, Datum reloptions, bool validate)
 }
 
 
+/*
+ * Parse options for tables.
+ *
+ *	reloptsfun	Table AM's option parser function. Can be NULL if amid is
+ *				valid. In this case we load the new table AM and use its option
+ *				parser function.
+ *	amid		New table AM's Oid if any.
+ *	relkind		relation kind
+ *	reloptions	options as text[] datum
+ *	validate	error flag
+ */
+bytea *
+table_reloptions(reloptions_function reloptsfun, Oid amid, char relkind,
+				 Datum reloptions, bool validate)
+{
+	/* amid and reloptsfun are mutually exclusive */
+	Assert((!OidIsValid(amid) && (reloptsfun != NULL)) || \
+		   (OidIsValid(amid) && (reloptsfun == NULL)));
+
+	/* Parse/validate options using reloptsfun */
+	if (!OidIsValid(amid) && reloptsfun != NULL)
+	{
+		/* Assume function is strict */
+		if (!PointerIsValid(DatumGetPointer(reloptions)))
+			return NULL;
+
+		return reloptsfun(relkind, reloptions, validate);
+	}
+	/* Parse/validate options using the API of the new Table AM */
+	else if (OidIsValid(amid) && (reloptsfun == NULL))
+	{
+		const TableAmRoutine *routine;
+		HeapTuple	atuple;
+		Form_pg_am	aform;
+
+		atuple = SearchSysCache1(AMOID, ObjectIdGetDatum(amid));
+
+		if (!HeapTupleIsValid(atuple))
+			elog(ERROR, "cache lookup failed for access method %u", amid);
+
+		aform = (Form_pg_am) GETSTRUCT(atuple);
+		routine = GetTableAmRoutine(aform->amhandler);
+		ReleaseSysCache(atuple);
+
+		if (routine->relation_options != NULL)
+			return routine->relation_options(relkind, reloptions, validate);
+
+		return NULL;
+	}
+	else
+	{
+		/* Should not happen */
+		return NULL;
+	}
+}
+
 /*
  * Parse options for indexes.
  *
diff --git a/src/backend/access/heap/heapam_handler.c b/src/backend/access/heap/heapam_handler.c
index e78682c3cef..23451c5af92 100644
--- a/src/backend/access/heap/heapam_handler.c
+++ b/src/backend/access/heap/heapam_handler.c
@@ -24,6 +24,7 @@
 #include "access/heaptoast.h"
 #include "access/multixact.h"
 #include "access/rewriteheap.h"
+#include "access/reloptions.h"
 #include "access/syncscan.h"
 #include "access/tableam.h"
 #include "access/tsmapi.h"
@@ -2678,6 +2679,7 @@ static const TableAmRoutine heapam_methods = {
 	.index_build_range_scan = heapam_index_build_range_scan,
 	.index_validate_scan = heapam_index_validate_scan,
 
+	.relation_options = heap_reloptions,
 	.relation_size = table_block_relation_size,
 	.relation_needs_toast_table = heapam_relation_needs_toast_table,
 	.relation_toast_am = heapam_relation_toast_am,
diff --git a/src/backend/commands/foreigncmds.c b/src/backend/commands/foreigncmds.c
index c14e038d54f..9dab5dfb999 100644
--- a/src/backend/commands/foreigncmds.c
+++ b/src/backend/commands/foreigncmds.c
@@ -62,7 +62,7 @@ static void import_error_callback(void *arg);
  * processing, hence any validation should be done before this
  * conversion.
  */
-static Datum
+Datum
 optionListToArray(List *options)
 {
 	ArrayBuildState *astate = NULL;
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index ce7d115667e..660de70fe9f 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -635,6 +635,8 @@ static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
 								const char *tablespacename, LOCKMODE lockmode);
 static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
 static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
+static void ATExecSetAccessMethodOptions(Relation rel, List *defList, AlterTableType operation,
+										 LOCKMODE lockmode, Oid newAccessMethodId);
 static void ATExecSetRelOptions(Relation rel, List *defList,
 								AlterTableType operation,
 								LOCKMODE lockmode);
@@ -884,24 +886,6 @@ DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId,
 	if (!OidIsValid(ownerId))
 		ownerId = GetUserId();
 
-	/*
-	 * Parse and validate reloptions, if any.
-	 */
-	reloptions = transformRelOptions((Datum) 0, stmt->options, NULL, validnsps,
-									 true, false);
-
-	switch (relkind)
-	{
-		case RELKIND_VIEW:
-			(void) view_reloptions(reloptions, true);
-			break;
-		case RELKIND_PARTITIONED_TABLE:
-			(void) partitioned_table_reloptions(reloptions, true);
-			break;
-		default:
-			(void) heap_reloptions(relkind, reloptions, true);
-	}
-
 	if (stmt->ofTypename)
 	{
 		AclResult	aclresult;
@@ -1016,6 +1000,29 @@ DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId,
 			accessMethodId = get_table_am_oid(default_table_access_method, false);
 	}
 
+	/*
+	 * Parse and validate reloptions, if any.
+	 */
+	reloptions = transformRelOptions((Datum) 0, stmt->options, NULL, validnsps,
+									 true, false);
+	switch (relkind)
+	{
+		case RELKIND_VIEW:
+			(void) view_reloptions(reloptions, true);
+			break;
+		case RELKIND_PARTITIONED_TABLE:
+			(void) partitioned_table_reloptions(reloptions, true);
+			break;
+		case RELKIND_RELATION:
+		case RELKIND_TOASTVALUE:
+		case RELKIND_MATVIEW:
+			(void) table_reloptions(NULL, accessMethodId, relkind, reloptions,
+									true);
+			break;
+		default:
+			(void) heap_reloptions(relkind, reloptions, true);
+	}
+
 	/*
 	 * Create the relation.  Inherited defaults and CHECK constraints are
 	 * passed in for immediate handling --- since they don't need parsing,
@@ -5497,6 +5504,9 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
 			if (rel->rd_rel->relkind == RELKIND_PARTITIONED_TABLE &&
 				tab->chgAccessMethod)
 				ATExecSetAccessMethodNoStorage(rel, tab->newAccessMethod);
+
+			ATExecSetAccessMethodOptions(rel, (List *) cmd->def, cmd->subtype,
+										 lockmode, tab->newAccessMethod);
 			break;
 		case AT_SetTableSpace:	/* SET TABLESPACE */
 
@@ -15690,6 +15700,138 @@ ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel, const char *tablespacen
 	tab->newTableSpace = tablespaceId;
 }
 
+/* SET, ADD or DROP options in ALTER TABLE SET ACCESS METHOD */
+static void
+ATExecSetAccessMethodOptions(Relation rel, List *options, AlterTableType operation,
+							 LOCKMODE lockmode, Oid newAccessMethodId)
+{
+	Oid			relid;
+	Relation	pgclass;
+	HeapTuple	tuple;
+	HeapTuple	newtuple;
+	Datum		datum;
+	bool		isnull;
+	Datum		newOptions;
+	Datum		repl_val[Natts_pg_class];
+	bool		repl_null[Natts_pg_class];
+	bool		repl_repl[Natts_pg_class];
+	List	   *resultOptions;
+	ListCell   *optcell;
+
+	pgclass = table_open(RelationRelationId, RowExclusiveLock);
+
+	/* Fetch heap tuple */
+	relid = RelationGetRelid(rel);
+	tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
+	if (!HeapTupleIsValid(tuple))
+		elog(ERROR, "cache lookup failed for relation %u", relid);
+
+	/* Get the old reloptions */
+	datum = SysCacheGetAttr(RELOID, tuple, Anum_pg_class_reloptions, &isnull);
+
+	if (isnull)
+		datum = PointerGetDatum(NULL);
+
+	resultOptions = untransformRelOptions(datum);
+
+	foreach(optcell, options)
+	{
+		DefElem    *od = lfirst(optcell);
+		ListCell   *cell;
+
+		/* Search in existing options */
+		foreach(cell, resultOptions)
+		{
+			DefElem    *def = lfirst(cell);
+
+			if (strcmp(def->defname, od->defname) == 0)
+				break;
+		}
+
+		/*
+		 * It is possible to perform multiple SET/DROP actions on the same
+		 * option.  The standard permits this, as long as the options to be
+		 * added are unique.  Note that an unspecified action is taken to be
+		 * ADD.
+		 */
+		switch (od->defaction)
+		{
+			case DEFELEM_DROP:
+				if (!cell)
+					ereport(ERROR,
+							(errcode(ERRCODE_UNDEFINED_OBJECT),
+							 errmsg("option \"%s\" not found",
+									od->defname)));
+				resultOptions = list_delete_cell(resultOptions, cell);
+				break;
+
+			case DEFELEM_SET:
+				if (!cell)
+					ereport(ERROR,
+							(errcode(ERRCODE_UNDEFINED_OBJECT),
+							 errmsg("option \"%s\" not found",
+									od->defname)));
+				lfirst(cell) = od;
+				break;
+
+			case DEFELEM_ADD:
+			case DEFELEM_UNSPEC:
+				if (cell)
+					ereport(ERROR,
+							(errcode(ERRCODE_DUPLICATE_OBJECT),
+							 errmsg("option \"%s\" provided more than once",
+									od->defname)));
+				resultOptions = lappend(resultOptions, od);
+				break;
+
+			default:
+				elog(ERROR, "unrecognized action %d on option \"%s\"",
+					 (int) od->defaction, od->defname);
+				break;
+		}
+	}
+
+	newOptions = optionListToArray(resultOptions);
+
+	/*
+	 * If the new table access method was not explicitly defined, then use the
+	 * default one.
+	 */
+	if (!OidIsValid(newAccessMethodId))
+		newAccessMethodId = get_table_am_oid(default_table_access_method, false);
+
+	/* Validate new options via the new Table Access Method API */
+	(void) table_reloptions(NULL, newAccessMethodId, rel->rd_rel->relkind,
+							newOptions, true);
+
+	/* Initialize buffers for new tuple values */
+	memset(repl_val, 0, sizeof(repl_val));
+	memset(repl_null, false, sizeof(repl_null));
+	memset(repl_repl, false, sizeof(repl_repl));
+
+	if (newOptions != (Datum) 0)
+		repl_val[Anum_pg_class_reloptions - 1] = newOptions;
+	else
+		repl_null[Anum_pg_class_reloptions - 1] = true;
+
+	repl_repl[Anum_pg_class_reloptions - 1] = true;
+
+	/* Everything looks good - update the tuple */
+	newtuple = heap_modify_tuple(tuple, RelationGetDescr(pgclass),
+								 repl_val, repl_null, repl_repl);
+
+	CatalogTupleUpdate(pgclass, &newtuple->t_self, newtuple);
+
+	InvokeObjectPostAlterHook(RelationRelationId, RelationGetRelid(rel),
+							  InvalidOid);
+
+	ReleaseSysCache(tuple);
+
+	table_close(pgclass, RowExclusiveLock);
+
+	heap_freetuple(newtuple);
+}
+
 /*
  * Set, reset, or replace reloptions.
  */
@@ -15747,7 +15889,7 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
 		case RELKIND_RELATION:
 		case RELKIND_TOASTVALUE:
 		case RELKIND_MATVIEW:
-			(void) heap_reloptions(rel->rd_rel->relkind, newOptions, true);
+			rel->rd_tableam->relation_options(rel->rd_rel->relkind, newOptions, true);
 			break;
 		case RELKIND_PARTITIONED_TABLE:
 			(void) partitioned_table_reloptions(newOptions, true);
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index 7d99c9355c6..9f38463626f 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -2901,6 +2901,15 @@ alter_table_cmd:
 					n->name = $4;
 					$$ = (Node *) n;
 				}
+			/* ALTER TABLE <name> SET ACCESS METHOD <amname> [OPTIONS]*/
+			| SET ACCESS METHOD name alter_generic_options
+				{
+					AlterTableCmd *n = makeNode(AlterTableCmd);
+					n->subtype = AT_SetAccessMethod;
+					n->name = $4;
+					n->def = (Node *) $5;
+					$$ = (Node *)n;
+				}
 			/* ALTER TABLE <name> SET TABLESPACE <tablespacename> */
 			| SET TABLESPACE name
 				{
diff --git a/src/backend/postmaster/autovacuum.c b/src/backend/postmaster/autovacuum.c
index ddb303f5201..20058327297 100644
--- a/src/backend/postmaster/autovacuum.c
+++ b/src/backend/postmaster/autovacuum.c
@@ -331,6 +331,7 @@ static void FreeWorkerInfo(int code, Datum arg);
 
 static autovac_table *table_recheck_autovac(Oid relid, HTAB *table_toast_map,
 											TupleDesc pg_class_desc,
+											reloptions_function reloptions,
 											int effective_multixact_freeze_max_age);
 static void recheck_relation_needs_vacanalyze(Oid relid, AutoVacOpts *avopts,
 											  Form_pg_class classForm,
@@ -345,7 +346,7 @@ static void relation_needs_vacanalyze(Oid relid, AutoVacOpts *relopts,
 static void autovacuum_do_vac_analyze(autovac_table *tab,
 									  BufferAccessStrategy bstrategy);
 static AutoVacOpts *extract_autovac_opts(HeapTuple tup,
-										 TupleDesc pg_class_desc);
+										 TupleDesc pg_class_desc, reloptions_function reloptions);
 static void perform_work_item(AutoVacuumWorkItem *workitem);
 static void autovac_report_activity(autovac_table *tab);
 static void autovac_report_workitem(AutoVacuumWorkItem *workitem,
@@ -2031,7 +2032,8 @@ do_autovacuum(void)
 		}
 
 		/* Fetch reloptions and the pgstat entry for this table */
-		relopts = extract_autovac_opts(tuple, pg_class_desc);
+		relopts = extract_autovac_opts(tuple, pg_class_desc,
+									   classRel->rd_tableam->relation_options);
 		tabentry = pgstat_fetch_stat_tabentry_ext(classForm->relisshared,
 												  relid);
 
@@ -2104,7 +2106,8 @@ do_autovacuum(void)
 		 * fetch reloptions -- if this toast table does not have them, try the
 		 * main rel
 		 */
-		relopts = extract_autovac_opts(tuple, pg_class_desc);
+		relopts = extract_autovac_opts(tuple, pg_class_desc,
+									   classRel->rd_tableam->relation_options);
 		if (relopts == NULL)
 		{
 			av_relation *hentry;
@@ -2362,6 +2365,7 @@ do_autovacuum(void)
 		 */
 		MemoryContextSwitchTo(AutovacMemCxt);
 		tab = table_recheck_autovac(relid, table_toast_map, pg_class_desc,
+									classRel->rd_tableam->relation_options,
 									effective_multixact_freeze_max_age);
 		if (tab == NULL)
 		{
@@ -2687,7 +2691,8 @@ deleted2:
  * be a risk; fortunately, it doesn't.
  */
 static AutoVacOpts *
-extract_autovac_opts(HeapTuple tup, TupleDesc pg_class_desc)
+extract_autovac_opts(HeapTuple tup, TupleDesc pg_class_desc,
+					 reloptions_function reloptions)
 {
 	bytea	   *relopts;
 	AutoVacOpts *av;
@@ -2696,7 +2701,7 @@ extract_autovac_opts(HeapTuple tup, TupleDesc pg_class_desc)
 		   ((Form_pg_class) GETSTRUCT(tup))->relkind == RELKIND_MATVIEW ||
 		   ((Form_pg_class) GETSTRUCT(tup))->relkind == RELKIND_TOASTVALUE);
 
-	relopts = extractRelOptions(tup, pg_class_desc, NULL);
+	relopts = extractRelOptions(tup, pg_class_desc, NULL, reloptions);
 	if (relopts == NULL)
 		return NULL;
 
@@ -2719,6 +2724,7 @@ extract_autovac_opts(HeapTuple tup, TupleDesc pg_class_desc)
 static autovac_table *
 table_recheck_autovac(Oid relid, HTAB *table_toast_map,
 					  TupleDesc pg_class_desc,
+					  reloptions_function reloptions,
 					  int effective_multixact_freeze_max_age)
 {
 	Form_pg_class classForm;
@@ -2739,7 +2745,7 @@ table_recheck_autovac(Oid relid, HTAB *table_toast_map,
 	 * Get the applicable reloptions.  If it is a TOAST table, try to get the
 	 * main table reloptions if the toast table itself doesn't have.
 	 */
-	avopts = extract_autovac_opts(classTup, pg_class_desc);
+	avopts = extract_autovac_opts(classTup, pg_class_desc, reloptions);
 	if (classForm->relkind == RELKIND_TOASTVALUE &&
 		avopts == NULL && table_toast_map != NULL)
 	{
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index 398114373e9..b6c309c8bd2 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -466,6 +466,7 @@ RelationParseRelOptions(Relation relation, HeapTuple tuple)
 {
 	bytea	   *options;
 	amoptions_function amoptsfn;
+	reloptions_function reloptsfn;
 
 	relation->rd_options = NULL;
 
@@ -477,13 +478,18 @@ RelationParseRelOptions(Relation relation, HeapTuple tuple)
 	{
 		case RELKIND_RELATION:
 		case RELKIND_TOASTVALUE:
-		case RELKIND_VIEW:
 		case RELKIND_MATVIEW:
+			reloptsfn = relation->rd_tableam->relation_options;
+			amoptsfn = NULL;
+			break;
+		case RELKIND_VIEW:
 		case RELKIND_PARTITIONED_TABLE:
+			reloptsfn = NULL;
 			amoptsfn = NULL;
 			break;
 		case RELKIND_INDEX:
 		case RELKIND_PARTITIONED_INDEX:
+			reloptsfn = NULL;
 			amoptsfn = relation->rd_indam->amoptions;
 			break;
 		default:
@@ -495,7 +501,8 @@ RelationParseRelOptions(Relation relation, HeapTuple tuple)
 	 * we might not have any other for pg_class yet (consider executing this
 	 * code for pg_class itself)
 	 */
-	options = extractRelOptions(tuple, GetPgClassDescriptor(), amoptsfn);
+	options = extractRelOptions(tuple, GetPgClassDescriptor(),
+								amoptsfn, reloptsfn);
 
 	/*
 	 * Copy parsed data into CacheMemoryContext.  To guard against the
diff --git a/src/include/access/reloptions.h b/src/include/access/reloptions.h
index 43445cdcc6c..d0ef7918856 100644
--- a/src/include/access/reloptions.h
+++ b/src/include/access/reloptions.h
@@ -21,6 +21,7 @@
 
 #include "access/amapi.h"
 #include "access/htup.h"
+#include "access/tableam.h"
 #include "access/tupdesc.h"
 #include "nodes/pg_list.h"
 #include "storage/lock.h"
@@ -224,7 +225,8 @@ extern Datum transformRelOptions(Datum oldOptions, List *defList,
 								 bool acceptOidsOff, bool isReset);
 extern List *untransformRelOptions(Datum options);
 extern bytea *extractRelOptions(HeapTuple tuple, TupleDesc tupdesc,
-								amoptions_function amoptions);
+								amoptions_function amoptions,
+								reloptions_function reloptsfun);
 extern void *build_reloptions(Datum reloptions, bool validate,
 							  relopt_kind kind,
 							  Size relopt_struct_size,
@@ -238,6 +240,8 @@ extern bytea *default_reloptions(Datum reloptions, bool validate,
 extern bytea *heap_reloptions(char relkind, Datum reloptions, bool validate);
 extern bytea *view_reloptions(Datum reloptions, bool validate);
 extern bytea *partitioned_table_reloptions(Datum reloptions, bool validate);
+extern bytea *table_reloptions(reloptions_function reloptsfun, Oid amid, char relkind,
+							   Datum reloptions, bool validate);
 extern bytea *index_reloptions(amoptions_function amoptions, Datum reloptions,
 							   bool validate);
 extern bytea *attribute_reloptions(Datum reloptions, bool validate);
diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h
index 131c050c15f..79ad91d201c 100644
--- a/src/include/access/tableam.h
+++ b/src/include/access/tableam.h
@@ -276,6 +276,14 @@ typedef void (*IndexBuildCallback) (Relation index,
 									bool tupleIsAlive,
 									void *state);
 
+/*
+ * Callback in charge of parsing and validating the table reloptions.
+ * It returns parsed options in bytea format.
+ */
+typedef bytea *(*reloptions_function) (char relkind,
+									   Datum reloptions,
+									   bool validate);
+
 /*
  * API struct for a table AM.  Note this must be allocated in a
  * server-lifetime manner, typically as a static const struct, which then gets
@@ -715,6 +723,8 @@ typedef struct TableAmRoutine
 	 * ------------------------------------------------------------------------
 	 */
 
+	reloptions_function relation_options;
+
 	/*
 	 * See table_relation_size().
 	 *
diff --git a/src/include/commands/defrem.h b/src/include/commands/defrem.h
index 6d9348bac80..cd0aaaa0b93 100644
--- a/src/include/commands/defrem.h
+++ b/src/include/commands/defrem.h
@@ -136,6 +136,7 @@ extern ObjectAddress AlterUserMapping(AlterUserMappingStmt *stmt);
 extern Oid	RemoveUserMapping(DropUserMappingStmt *stmt);
 extern void CreateForeignTable(CreateForeignTableStmt *stmt, Oid relid);
 extern void ImportForeignSchema(ImportForeignSchemaStmt *stmt);
+extern Datum optionListToArray(List *options);
 extern Datum transformGenericOptions(Oid catalogId,
 									 Datum oldOptions,
 									 List *options,
-- 
2.39.5

  [text/x-diff] v2-0002-Add-the-dummy_table_am-test-module.patch (33.3K, ../../20250302132354.ffbfokeq36dp2kss@poseidon.home.virt/3-v2-0002-Add-the-dummy_table_am-test-module.patch)
  download | inline diff:
From 769cdb2d0c4e9630bf5f60dd30cf391f623d7333 Mon Sep 17 00:00:00 2001
From: Julien Tachoires <julien@tachoires.me>
Date: Sat, 1 Mar 2025 20:50:13 +0100
Subject: [PATCH 2/2] Add the "dummy_table_am" test module

This test module is in charge of testing table AM reloptions. It's
very similar to what we do in dummy_index_am as we have to exercise
the exact same kind of feature.
---
 src/test/modules/Makefile                     |   1 +
 src/test/modules/dummy_table_am/Makefile      |  20 +
 src/test/modules/dummy_table_am/README        |  14 +
 .../dummy_table_am/dummy_table_am--1.0.sql    |  13 +
 .../modules/dummy_table_am/dummy_table_am.c   | 588 ++++++++++++++++++
 .../dummy_table_am/dummy_table_am.control     |   5 +
 .../dummy_table_am/expected/reloptions.out    | 181 ++++++
 src/test/modules/dummy_table_am/meson.build   |  33 +
 .../modules/dummy_table_am/sql/reloptions.sql |  99 +++
 src/test/modules/meson.build                  |   1 +
 10 files changed, 955 insertions(+)
 create mode 100644 src/test/modules/dummy_table_am/Makefile
 create mode 100644 src/test/modules/dummy_table_am/README
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am.c
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am.control
 create mode 100644 src/test/modules/dummy_table_am/expected/reloptions.out
 create mode 100644 src/test/modules/dummy_table_am/meson.build
 create mode 100644 src/test/modules/dummy_table_am/sql/reloptions.sql

diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 4e4be3fa511..8fe2a2904d6 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -9,6 +9,7 @@ SUBDIRS = \
 		  commit_ts \
 		  delay_execution \
 		  dummy_index_am \
+		  dummy_table_am \
 		  dummy_seclabel \
 		  libpq_pipeline \
 		  oauth_validator \
diff --git a/src/test/modules/dummy_table_am/Makefile b/src/test/modules/dummy_table_am/Makefile
new file mode 100644
index 00000000000..94837dff392
--- /dev/null
+++ b/src/test/modules/dummy_table_am/Makefile
@@ -0,0 +1,20 @@
+# src/test/modules/dummy_table_am/Makefile
+
+MODULES = dummy_table_am
+
+EXTENSION = dummy_table_am
+DATA = dummy_table_am--1.0.sql
+PGFILEDESC = "dummy_table_am - table access method template"
+
+REGRESS = reloptions
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/dummy_table_am
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/dummy_table_am/README b/src/test/modules/dummy_table_am/README
new file mode 100644
index 00000000000..50cf08ee3b1
--- /dev/null
+++ b/src/test/modules/dummy_table_am/README
@@ -0,0 +1,14 @@
+Dummy Table AM
+==============
+
+Dummy table AM is a module for testing any facility usable by a table
+access method, whose code is kept a maximum simple.
+
+This includes tests for all relation option types:
+- boolean
+- enum
+- integer
+- real
+- strings (with and without NULL as default)
+
+It also includes tests related to unrecognized options.
diff --git a/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql b/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
new file mode 100644
index 00000000000..12ad3ad174b
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
@@ -0,0 +1,13 @@
+/* src/test/modules/dummy_table_am/dummy_table_am--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION dummy_table_am" to load this file. \quit
+
+CREATE FUNCTION dummy_table_am_handler(internal)
+RETURNS table_am_handler
+AS 'MODULE_PATHNAME'
+LANGUAGE C;
+
+-- Access method
+CREATE ACCESS METHOD dummy_table_am TYPE TABLE HANDLER dummy_table_am_handler;
+COMMENT ON ACCESS METHOD dummy_table_am IS 'Dummy Table Access Method';
diff --git a/src/test/modules/dummy_table_am/dummy_table_am.c b/src/test/modules/dummy_table_am/dummy_table_am.c
new file mode 100644
index 00000000000..a473bc7dd9b
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am.c
@@ -0,0 +1,588 @@
+/*-------------------------------------------------------------------------
+ *
+ * dummy_table_am.c
+ *		Table AM templae main file
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/test/modules/dummy_table_am/dummy_table_am.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "miscadmin.h"
+
+#include "access/hio.h"
+#include "access/relscan.h"
+#include "access/reloptions.h"
+#include "access/tableam.h"
+#include "access/sdir.h"
+#include "access/skey.h"
+#include "executor/tuptable.h"
+#include "utils/relcache.h"
+#include "utils/snapshot.h"
+
+
+PG_MODULE_MAGIC;
+
+/* Base structures for scans */
+typedef struct DummyScanDescData
+{
+	TableScanDescData rs_base;	/* AM independent part of the descriptor */
+
+	/* Add more fields here as needed by the AM. */
+}			DummyScanDescData;
+typedef struct DummyScanDescData *DummyScanDesc;
+
+/* parse table for fillRelOptions */
+static relopt_parse_elt dt_relopt_tab[7];
+
+/* Kind of relation options for dummy index */
+static relopt_kind dt_relopt_kind;
+
+typedef enum DummyAmEnum
+{
+	DUMMY_AM_ENUM_ONE,
+	DUMMY_AM_ENUM_TWO,
+}			DummyAmEnum;
+
+/* Dummy table options */
+typedef struct DummyTableOptions
+{
+	int32		vl_len_;		/* varlena header (do not touch directly!) */
+	int			option_int;
+	double		option_real;
+	bool		option_bool;
+	DummyAmEnum option_enum;
+	int			option_string_val_offset;
+	int			option_string_null_offset;
+	int			fillfactor;
+}			DummyTableOptions;
+
+static relopt_enum_elt_def dummyAmEnumValues[] =
+{
+	{"one", DUMMY_AM_ENUM_ONE},
+	{"two", DUMMY_AM_ENUM_TWO},
+	{(const char *) NULL}		/* list terminator */
+};
+
+/* ------------------------------------------------------------------------
+ *                     Dummy Access Method Interface
+ * ------------------------------------------------------------------------
+ */
+
+static const TupleTableSlotOps *
+dummy_slot_callbacks(Relation relation)
+{
+	return &TTSOpsMinimalTuple;
+}
+
+static TableScanDesc
+dummy_scan_begin(Relation relation, Snapshot snapshot, int nkeys, ScanKey key,
+				 ParallelTableScanDesc parallel_scan, uint32 flags)
+{
+	DummyScanDesc scan;
+
+	scan = (DummyScanDesc) palloc(sizeof(DummyScanDescData));
+
+	scan->rs_base.rs_rd = relation;
+	scan->rs_base.rs_snapshot = snapshot;
+	scan->rs_base.rs_nkeys = nkeys;
+	scan->rs_base.rs_flags = flags;
+	scan->rs_base.rs_parallel = parallel_scan;
+
+	return (TableScanDesc) scan;
+}
+
+static void
+dummy_scan_end(TableScanDesc sscan)
+{
+	DummyScanDesc scan = (DummyScanDesc) sscan;
+
+	pfree(scan);
+
+	return;
+}
+
+static void
+dummy_scan_rescan(TableScanDesc sscan, ScanKey key, bool set_params,
+				  bool allow_strat, bool allow_sync, bool allow_pagemode)
+{
+	return;
+}
+
+static bool
+dummy_scan_getnextslot(TableScanDesc sscan, ScanDirection direction,
+					   TupleTableSlot *slot)
+{
+	return true;
+}
+
+static void
+dummy_scan_set_tidrange(TableScanDesc sscan, ItemPointer mintid,
+						ItemPointer maxtid)
+{
+	return;
+}
+
+static bool
+dummy_scan_getnextslot_tidrange(TableScanDesc sscan, ScanDirection direction,
+								TupleTableSlot *slot)
+{
+	return true;
+}
+
+static Size
+dummy_parallelscan_estimate(Relation rel)
+{
+	return 0;
+}
+
+static Size
+dummy_parallelscan_initialize(Relation rel, ParallelTableScanDesc pscan)
+{
+	return 0;
+}
+
+static void
+dummy_parallelscan_reinitialize(Relation rel, ParallelTableScanDesc pscan)
+{
+	return;
+}
+
+static IndexFetchTableData *
+dummy_index_fetch_begin(Relation rel)
+{
+	return NULL;
+}
+
+static void
+dummy_index_fetch_reset(IndexFetchTableData *scan)
+{
+	return;
+}
+
+static void
+dummy_index_fetch_end(IndexFetchTableData *scan)
+{
+	return;
+}
+
+static bool
+dummy_index_fetch_tuple(struct IndexFetchTableData *scan, ItemPointer tid,
+						Snapshot snapshot, TupleTableSlot *slot,
+						bool *call_again, bool *all_dead)
+{
+	return true;
+}
+
+static void
+dummy_tuple_insert(Relation relation, TupleTableSlot *slot, CommandId cid,
+				   int options, BulkInsertStateData *bistate)
+{
+	DummyTableOptions *relopts;
+
+	relopts = (DummyTableOptions *) relation->rd_options;
+
+	elog(NOTICE, "option_int=%d, option_real=%f, option_bool=%d, option_enum=%d",
+		 relopts->option_int, relopts->option_real, relopts->option_bool, relopts->option_enum);
+
+	return;
+}
+
+static void
+dummy_tuple_insert_speculative(Relation relation, TupleTableSlot *slot,
+							   CommandId cid, int options,
+							   BulkInsertStateData *bistate, uint32 specToken)
+{
+	return;
+}
+
+static void
+dummy_tuple_complete_speculative(Relation relation, TupleTableSlot *slot,
+								 uint32 specToken, bool succeeded)
+{
+	return;
+}
+
+static void
+dummy_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples,
+				   CommandId cid, int options, BulkInsertStateData *bistate)
+{
+	return;
+}
+
+static TM_Result
+dummy_tuple_delete(Relation relation, ItemPointer tid, CommandId cid,
+				   Snapshot snapshot, Snapshot crosscheck, bool wait,
+				   TM_FailureData *tmfd, bool changingPart)
+{
+	return TM_Ok;
+}
+
+static TM_Result
+dummy_tuple_update(Relation relation, ItemPointer otid, TupleTableSlot *slot,
+				   CommandId cid, Snapshot snapshot, Snapshot crosscheck,
+				   bool wait, TM_FailureData *tmfd,
+				   LockTupleMode *lockmode, TU_UpdateIndexes *update_indexes)
+{
+	return TM_Ok;
+}
+
+static TM_Result
+dummy_tuple_lock(Relation relation, ItemPointer tid, Snapshot snapshot,
+				 TupleTableSlot *slot, CommandId cid, LockTupleMode mode,
+				 LockWaitPolicy wait_policy, uint8 flags,
+				 TM_FailureData *tmfd)
+{
+	return TM_Ok;
+}
+
+static bool
+dummy_fetch_row_version(Relation relation, ItemPointer tid,
+						Snapshot snapshot, TupleTableSlot *slot)
+{
+	return false;
+}
+
+static void
+dummy_get_latest_tid(TableScanDesc sscan, ItemPointer tid)
+{
+	return;
+}
+
+static bool
+dummy_tuple_tid_valid(TableScanDesc scan, ItemPointer tid)
+{
+	return false;
+}
+
+static bool
+dummy_tuple_satisfies_snapshot(Relation rel, TupleTableSlot *slot,
+							   Snapshot snapshot)
+{
+	return false;
+}
+
+static TransactionId
+dummy_index_delete_tuples(Relation rel, TM_IndexDeleteOp *delstate)
+{
+	return InvalidTransactionId;
+}
+
+static void
+dummy_relation_set_new_filelocator(Relation rel,
+								   const RelFileLocator *newrlocator,
+								   char persistence,
+								   TransactionId *freezeXid,
+								   MultiXactId *minmulti)
+{
+	return;
+}
+
+static void
+dummy_relation_nontransactional_truncate(Relation rel)
+{
+	return;
+}
+
+static void
+dummy_relation_copy_data(Relation rel, const RelFileLocator *newrlocator)
+{
+	return;
+}
+
+static void
+dummy_relation_copy_for_cluster(Relation OldHeap, Relation NewHeap,
+								Relation OldIndex, bool use_sort,
+								TransactionId OldestXmin,
+								TransactionId *xid_cutoff,
+								MultiXactId *multi_cutoff,
+								double *num_tuples,
+								double *tups_vacuumed,
+								double *tups_recently_dead)
+{
+	return;
+}
+
+static void
+dummy_relation_vacuum(Relation rel, struct VacuumParams *params,
+					  BufferAccessStrategy bstrategy)
+{
+	return;
+}
+
+static bool
+dummy_scan_analyze_next_block(TableScanDesc scan, ReadStream *stream)
+{
+	return false;
+}
+
+static bool
+dummy_scan_analyze_next_tuple(TableScanDesc scan, TransactionId OldestXmin,
+							  double *liverows, double *deadrows,
+							  TupleTableSlot *slot)
+{
+	return false;
+}
+
+static double
+dummy_index_build_range_scan(Relation heapRelation,
+							 Relation indexRelation,
+							 struct IndexInfo *indexInfo,
+							 bool allow_sync,
+							 bool anyvisible,
+							 bool progress,
+							 BlockNumber start_blockno,
+							 BlockNumber numblocks,
+							 IndexBuildCallback callback,
+							 void *callback_state,
+							 TableScanDesc scan)
+{
+	return 0;
+}
+
+static void
+dummy_index_validate_scan(Relation heapRelation,
+						  Relation indexRelation,
+						  struct IndexInfo *indexInfo,
+						  Snapshot snapshot,
+						  struct ValidateIndexState *state)
+{
+	return;
+}
+
+static uint64
+dummy_relation_size(Relation rel, ForkNumber forkNumber)
+{
+	return 0;
+}
+
+static bool
+dummy_relation_needs_toast_table(Relation rel)
+{
+	return false;
+}
+
+static Oid
+dummy_relation_toast_am(Relation rel)
+{
+	return InvalidOid;
+}
+
+static void
+dummy_relation_fetch_toast_slice(Relation toastrel, Oid valueid, int32 attrsize,
+								 int32 sliceoffset, int32 slicelength,
+								 struct varlena *result)
+{
+	return;
+}
+
+static void
+dummy_relation_estimate_size(Relation rel, int32 *attr_widths,
+							 BlockNumber *pages, double *tuples,
+							 double *allvisfrac)
+{
+	return;
+}
+
+static bool
+dummy_scan_bitmap_next_block(TableScanDesc scan, BlockNumber *blockno,
+							 bool *recheck, uint64 *lossy_pages,
+							 uint64 *exact_pages)
+{
+	return false;
+}
+
+static bool
+dummy_scan_bitmap_next_tuple(TableScanDesc scan, TupleTableSlot *slot)
+{
+	return false;
+}
+
+static bool
+dummy_scan_sample_next_block(TableScanDesc scan, struct SampleScanState *scanstate)
+{
+	return false;
+}
+
+static bool
+dummy_scan_sample_next_tuple(TableScanDesc scan, struct SampleScanState *scanstate,
+							 TupleTableSlot *slot)
+{
+	return false;
+}
+
+static bytea *
+dummy_relation_options(char relkind, Datum reloptions, bool validate)
+{
+	return (bytea *) build_reloptions(reloptions, validate,
+									  dt_relopt_kind,
+									  sizeof(DummyTableOptions),
+									  dt_relopt_tab, lengthof(dt_relopt_tab));
+}
+
+/*
+ * Validation function for string relation options.
+ */
+static void
+validate_string_option(const char *value)
+{
+	ereport(NOTICE,
+			(errmsg("new option value for string parameter %s",
+					value ? value : "NULL")));
+}
+
+/*
+ * This function creates a full set of relation option types,
+ * with various patterns.
+ */
+static void
+create_reloptions_table(void)
+{
+	dt_relopt_kind = add_reloption_kind();
+
+	add_int_reloption(dt_relopt_kind, "option_int",
+					  "Integer option for dummy_table_am",
+					  10, -10, 100, AccessExclusiveLock);
+	dt_relopt_tab[0].optname = "option_int";
+	dt_relopt_tab[0].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[0].offset = offsetof(DummyTableOptions, option_int);
+
+	add_real_reloption(dt_relopt_kind, "option_real",
+					   "Real option for dummy_table_am",
+					   3.1415, -10, 100, AccessExclusiveLock);
+	dt_relopt_tab[1].optname = "option_real";
+	dt_relopt_tab[1].opttype = RELOPT_TYPE_REAL;
+	dt_relopt_tab[1].offset = offsetof(DummyTableOptions, option_real);
+
+	add_bool_reloption(dt_relopt_kind, "option_bool",
+					   "Boolean option for dummy_table_am",
+					   true, AccessExclusiveLock);
+	dt_relopt_tab[2].optname = "option_bool";
+	dt_relopt_tab[2].opttype = RELOPT_TYPE_BOOL;
+	dt_relopt_tab[2].offset = offsetof(DummyTableOptions, option_bool);
+
+	add_enum_reloption(dt_relopt_kind, "option_enum",
+					   "Enum option for dummy_table_am",
+					   dummyAmEnumValues,
+					   DUMMY_AM_ENUM_ONE,
+					   "Valid values are \"one\" and \"two\".",
+					   AccessExclusiveLock);
+	dt_relopt_tab[3].optname = "option_enum";
+	dt_relopt_tab[3].opttype = RELOPT_TYPE_ENUM;
+	dt_relopt_tab[3].offset = offsetof(DummyTableOptions, option_enum);
+
+	add_string_reloption(dt_relopt_kind, "option_string_val",
+						 "String option for dummy_table_am with non-NULL default",
+						 "DefaultValue", &validate_string_option,
+						 AccessExclusiveLock);
+	dt_relopt_tab[4].optname = "option_string_val";
+	dt_relopt_tab[4].opttype = RELOPT_TYPE_STRING;
+	dt_relopt_tab[4].offset = offsetof(DummyTableOptions,
+									   option_string_val_offset);
+
+	/*
+	 * String option for dummy_table_am with NULL default, and without
+	 * description.
+	 */
+	add_string_reloption(dt_relopt_kind, "option_string_null",
+						 NULL,	/* description */
+						 NULL, &validate_string_option,
+						 AccessExclusiveLock);
+	dt_relopt_tab[5].optname = "option_string_null";
+	dt_relopt_tab[5].opttype = RELOPT_TYPE_STRING;
+	dt_relopt_tab[5].offset = offsetof(DummyTableOptions,
+									   option_string_null_offset);
+
+	/*
+	 * fillfactor will be used to check reloption conversion when changing
+	 * table access method between heap AM and dummy_table_am.
+	 */
+	add_int_reloption(dt_relopt_kind, "fillfactor",
+					  "Fillfactor option for dummy_table_am",
+					  10, 0, 90, AccessExclusiveLock);
+	dt_relopt_tab[6].optname = "fillfactor";
+	dt_relopt_tab[6].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[6].offset = offsetof(DummyTableOptions, fillfactor);
+}
+
+
+/*
+ * Table Access Method API
+ */
+static const TableAmRoutine dummy_table_am_methods = {
+	.type = T_TableAmRoutine,
+
+	.slot_callbacks = dummy_slot_callbacks,
+	.scan_begin = dummy_scan_begin,
+	.scan_end = dummy_scan_end,
+	.scan_rescan = dummy_scan_rescan,
+	.scan_getnextslot = dummy_scan_getnextslot,
+
+	.scan_set_tidrange = dummy_scan_set_tidrange,
+	.scan_getnextslot_tidrange = dummy_scan_getnextslot_tidrange,
+
+	.parallelscan_estimate = dummy_parallelscan_estimate,
+	.parallelscan_initialize = dummy_parallelscan_initialize,
+	.parallelscan_reinitialize = dummy_parallelscan_reinitialize,
+
+	.index_fetch_begin = dummy_index_fetch_begin,
+	.index_fetch_reset = dummy_index_fetch_reset,
+	.index_fetch_end = dummy_index_fetch_end,
+	.index_fetch_tuple = dummy_index_fetch_tuple,
+
+	.tuple_insert = dummy_tuple_insert,
+	.tuple_insert_speculative = dummy_tuple_insert_speculative,
+	.tuple_complete_speculative = dummy_tuple_complete_speculative,
+	.multi_insert = dummy_multi_insert,
+	.tuple_delete = dummy_tuple_delete,
+	.tuple_update = dummy_tuple_update,
+	.tuple_lock = dummy_tuple_lock,
+
+	.tuple_fetch_row_version = dummy_fetch_row_version,
+	.tuple_get_latest_tid = dummy_get_latest_tid,
+	.tuple_tid_valid = dummy_tuple_tid_valid,
+	.tuple_satisfies_snapshot = dummy_tuple_satisfies_snapshot,
+	.index_delete_tuples = dummy_index_delete_tuples,
+
+	.relation_set_new_filelocator = dummy_relation_set_new_filelocator,
+	.relation_nontransactional_truncate = dummy_relation_nontransactional_truncate,
+	.relation_copy_data = dummy_relation_copy_data,
+	.relation_copy_for_cluster = dummy_relation_copy_for_cluster,
+	.relation_vacuum = dummy_relation_vacuum,
+	.scan_analyze_next_block = dummy_scan_analyze_next_block,
+	.scan_analyze_next_tuple = dummy_scan_analyze_next_tuple,
+	.index_build_range_scan = dummy_index_build_range_scan,
+	.index_validate_scan = dummy_index_validate_scan,
+
+	.relation_size = dummy_relation_size,
+	.relation_needs_toast_table = dummy_relation_needs_toast_table,
+	.relation_toast_am = dummy_relation_toast_am,
+	.relation_fetch_toast_slice = dummy_relation_fetch_toast_slice,
+	.relation_estimate_size = dummy_relation_estimate_size,
+	.relation_options = dummy_relation_options,
+
+	.scan_bitmap_next_block = dummy_scan_bitmap_next_block,
+	.scan_bitmap_next_tuple = dummy_scan_bitmap_next_tuple,
+	.scan_sample_next_block = dummy_scan_sample_next_block,
+	.scan_sample_next_tuple = dummy_scan_sample_next_tuple
+};
+
+PG_FUNCTION_INFO_V1(dummy_table_am_handler);
+
+Datum
+dummy_table_am_handler(PG_FUNCTION_ARGS)
+{
+	PG_RETURN_POINTER(&dummy_table_am_methods);
+}
+
+void
+_PG_init(void)
+{
+	create_reloptions_table();
+}
diff --git a/src/test/modules/dummy_table_am/dummy_table_am.control b/src/test/modules/dummy_table_am/dummy_table_am.control
new file mode 100644
index 00000000000..08f2f868d49
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am.control
@@ -0,0 +1,5 @@
+# dummy_table_am extension
+comment = 'dummy_table_am - table access method template'
+default_version = '1.0'
+module_pathname = '$libdir/dummy_table_am'
+relocatable = true
diff --git a/src/test/modules/dummy_table_am/expected/reloptions.out b/src/test/modules/dummy_table_am/expected/reloptions.out
new file mode 100644
index 00000000000..0b947500ead
--- /dev/null
+++ b/src/test/modules/dummy_table_am/expected/reloptions.out
@@ -0,0 +1,181 @@
+-- Tests for relation options
+CREATE EXTENSION dummy_table_am;
+CREATE TABLE dummy_test_tab (i int4) USING dummy_table_am;
+-- Silence validation checks for strings
+SET client_min_messages TO 'warning';
+-- Test with default values.
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+ unnest 
+--------
+(0 rows)
+
+DROP TABLE dummy_test_tab;
+-- Test with full set of options.
+-- Allow validation checks for strings
+SET client_min_messages TO 'notice';
+CREATE TABLE dummy_test_tab (i int4)
+  USING dummy_table_am WITH (
+  option_bool = false,
+  option_int = 5,
+  option_real = 3.1,
+  option_enum = 'two',
+  option_string_val = NULL,
+  option_string_null = 'val');
+NOTICE:  new option value for string parameter null
+NOTICE:  new option value for string parameter val
+-- Silence again validation checks for strings until the end of the test.
+SET client_min_messages TO 'warning';
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+         unnest         
+------------------------
+ option_bool=false
+ option_int=5
+ option_real=3.1
+ option_enum=two
+ option_string_val=null
+ option_string_null=val
+(6 rows)
+
+-- ALTER TABLE .. SET
+ALTER TABLE dummy_test_tab SET (option_int = 10);
+ALTER TABLE dummy_test_tab SET (option_bool = true);
+ALTER TABLE dummy_test_tab SET (option_real = 3.2);
+ALTER TABLE dummy_test_tab SET (option_string_val = 'val2');
+ALTER TABLE dummy_test_tab SET (option_string_null = NULL);
+ALTER TABLE dummy_test_tab SET (option_enum = 'one');
+ALTER TABLE dummy_test_tab SET (option_enum = 'three');
+ERROR:  invalid value for enum option "option_enum": three
+DETAIL:  Valid values are "one" and "two".
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+         unnest          
+-------------------------
+ option_int=10
+ option_bool=true
+ option_real=3.2
+ option_string_val=val2
+ option_string_null=null
+ option_enum=one
+(6 rows)
+
+-- ALTER TABLE .. RESET
+ALTER TABLE dummy_test_tab RESET (option_int);
+ALTER TABLE dummy_test_tab RESET (option_bool);
+ALTER TABLE dummy_test_tab RESET (option_real);
+ALTER TABLE dummy_test_tab RESET (option_enum);
+ALTER TABLE dummy_test_tab RESET (option_string_val);
+ALTER TABLE dummy_test_tab RESET (option_string_null);
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+ unnest 
+--------
+(0 rows)
+
+-- Cross-type checks for reloption values
+-- Integer
+ALTER TABLE dummy_test_tab SET (option_int = 3.3); -- ok
+ALTER TABLE dummy_test_tab SET (option_int = true); -- error
+ERROR:  invalid value for integer option "option_int": true
+ALTER TABLE dummy_test_tab SET (option_int = 'val3'); -- error
+ERROR:  invalid value for integer option "option_int": val3
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+     unnest     
+----------------
+ option_int=3.3
+(1 row)
+
+ALTER TABLE dummy_test_tab RESET (option_int);
+-- Boolean
+ALTER TABLE dummy_test_tab SET (option_bool = 4); -- error
+ERROR:  invalid value for boolean option "option_bool": 4
+ALTER TABLE dummy_test_tab SET (option_bool = 1); -- ok, as true
+ALTER TABLE dummy_test_tab SET (option_bool = 3.4); -- error
+ERROR:  invalid value for boolean option "option_bool": 3.4
+ALTER TABLE dummy_test_tab SET (option_bool = 'val4'); -- error
+ERROR:  invalid value for boolean option "option_bool": val4
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+    unnest     
+---------------
+ option_bool=1
+(1 row)
+
+ALTER TABLE dummy_test_tab RESET (option_bool);
+-- Float
+ALTER TABLE dummy_test_tab SET (option_real = 4); -- ok
+ALTER TABLE dummy_test_tab SET (option_real = true); -- error
+ERROR:  invalid value for floating point option "option_real": true
+ALTER TABLE dummy_test_tab SET (option_real = 'val5'); -- error
+ERROR:  invalid value for floating point option "option_real": val5
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+    unnest     
+---------------
+ option_real=4
+(1 row)
+
+ALTER TABLE dummy_test_tab RESET (option_real);
+-- Enum
+ALTER TABLE dummy_test_tab SET (option_enum = 'one'); -- ok
+ALTER TABLE dummy_test_tab SET (option_enum = 0); -- error
+ERROR:  invalid value for enum option "option_enum": 0
+DETAIL:  Valid values are "one" and "two".
+ALTER TABLE dummy_test_tab SET (option_enum = true); -- error
+ERROR:  invalid value for enum option "option_enum": true
+DETAIL:  Valid values are "one" and "two".
+ALTER TABLE dummy_test_tab SET (option_enum = 'three'); -- error
+ERROR:  invalid value for enum option "option_enum": three
+DETAIL:  Valid values are "one" and "two".
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+     unnest      
+-----------------
+ option_enum=one
+(1 row)
+
+ALTER TABLE dummy_test_tab RESET (option_enum);
+-- String
+ALTER TABLE dummy_test_tab SET (option_string_val = 4); -- ok
+ALTER TABLE dummy_test_tab SET (option_string_val = 3.5); -- ok
+ALTER TABLE dummy_test_tab SET (option_string_val = true); -- ok, as "true"
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+         unnest         
+------------------------
+ option_string_val=true
+(1 row)
+
+ALTER TABLE dummy_test_tab RESET (option_string_val);
+DROP TABLE dummy_test_tab;
+-- ALTER TABLE SET ACCESS METHOD OPTIONS
+CREATE TABLE heap_tab (i INT4) WITH (fillfactor=100, toast_tuple_target=1000);
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'heap_tab';
+         unnest          
+-------------------------
+ fillfactor=100
+ toast_tuple_target=1000
+(2 rows)
+
+-- error: fillfactor is out of bounds: maximum value from the new table am is 90
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am;
+ERROR:  value 100 out of bounds for option "fillfactor"
+DETAIL:  Valid values are between "0" and "90".
+-- error: toast_tuple_target does not exist in the new table AM
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (SET fillfactor '50');
+ERROR:  unrecognized parameter "toast_tuple_target"
+-- error: adding is not possible when the parameter is already defined in source reloptions
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (ADD fillfactor '50');
+ERROR:  option "fillfactor" provided more than once
+-- error: the specified option we want to drop does not exist
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (DROP does_not_exist);
+ERROR:  option "does_not_exist" not found
+-- error: adding unrecognized parameter
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (SET fillfactor '50', DROP toast_tuple_target, ADD unrecognized 'foo');
+ERROR:  unrecognized parameter "unrecognized"
+-- ok
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (DROP fillfactor, DROP toast_tuple_target, option_int '1', option_bool 'true', option_real '0.001', option_enum 'one', option_string_val 'hello');
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'heap_tab';
+         unnest          
+-------------------------
+ option_int=1
+ option_bool=true
+ option_real=0.001
+ option_enum=one
+ option_string_val=hello
+(5 rows)
+
+DROP TABLE heap_tab;
diff --git a/src/test/modules/dummy_table_am/meson.build b/src/test/modules/dummy_table_am/meson.build
new file mode 100644
index 00000000000..6b197b15ffa
--- /dev/null
+++ b/src/test/modules/dummy_table_am/meson.build
@@ -0,0 +1,33 @@
+# Copyright (c) 2022-2025, PostgreSQL Global Development Group
+
+dummy_table_am_sources = files(
+  'dummy_table_am.c',
+)
+
+if host_system == 'windows'
+  dummy_table_am_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'dummy_table_am',
+    '--FILEDESC', 'dummy_table_am - table access method template',])
+endif
+
+dummy_table_am = shared_module('dummy_table_am',
+  dummy_table_am_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += dummy_table_am
+
+test_install_data += files(
+  'dummy_table_am.control',
+  'dummy_table_am--1.0.sql',
+)
+
+tests += {
+  'name': 'dummy_table_am',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'reloptions',
+    ],
+  },
+}
diff --git a/src/test/modules/dummy_table_am/sql/reloptions.sql b/src/test/modules/dummy_table_am/sql/reloptions.sql
new file mode 100644
index 00000000000..47fb4862c6c
--- /dev/null
+++ b/src/test/modules/dummy_table_am/sql/reloptions.sql
@@ -0,0 +1,99 @@
+-- Tests for relation options
+CREATE EXTENSION dummy_table_am;
+
+CREATE TABLE dummy_test_tab (i int4) USING dummy_table_am;
+
+-- Silence validation checks for strings
+SET client_min_messages TO 'warning';
+
+-- Test with default values.
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+DROP TABLE dummy_test_tab;
+
+-- Test with full set of options.
+-- Allow validation checks for strings
+SET client_min_messages TO 'notice';
+CREATE TABLE dummy_test_tab (i int4)
+  USING dummy_table_am WITH (
+  option_bool = false,
+  option_int = 5,
+  option_real = 3.1,
+  option_enum = 'two',
+  option_string_val = NULL,
+  option_string_null = 'val');
+-- Silence again validation checks for strings until the end of the test.
+SET client_min_messages TO 'warning';
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+
+-- ALTER TABLE .. SET
+ALTER TABLE dummy_test_tab SET (option_int = 10);
+ALTER TABLE dummy_test_tab SET (option_bool = true);
+ALTER TABLE dummy_test_tab SET (option_real = 3.2);
+ALTER TABLE dummy_test_tab SET (option_string_val = 'val2');
+ALTER TABLE dummy_test_tab SET (option_string_null = NULL);
+ALTER TABLE dummy_test_tab SET (option_enum = 'one');
+ALTER TABLE dummy_test_tab SET (option_enum = 'three');
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+
+-- ALTER TABLE .. RESET
+ALTER TABLE dummy_test_tab RESET (option_int);
+ALTER TABLE dummy_test_tab RESET (option_bool);
+ALTER TABLE dummy_test_tab RESET (option_real);
+ALTER TABLE dummy_test_tab RESET (option_enum);
+ALTER TABLE dummy_test_tab RESET (option_string_val);
+ALTER TABLE dummy_test_tab RESET (option_string_null);
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+
+-- Cross-type checks for reloption values
+-- Integer
+ALTER TABLE dummy_test_tab SET (option_int = 3.3); -- ok
+ALTER TABLE dummy_test_tab SET (option_int = true); -- error
+ALTER TABLE dummy_test_tab SET (option_int = 'val3'); -- error
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+ALTER TABLE dummy_test_tab RESET (option_int);
+-- Boolean
+ALTER TABLE dummy_test_tab SET (option_bool = 4); -- error
+ALTER TABLE dummy_test_tab SET (option_bool = 1); -- ok, as true
+ALTER TABLE dummy_test_tab SET (option_bool = 3.4); -- error
+ALTER TABLE dummy_test_tab SET (option_bool = 'val4'); -- error
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+ALTER TABLE dummy_test_tab RESET (option_bool);
+-- Float
+ALTER TABLE dummy_test_tab SET (option_real = 4); -- ok
+ALTER TABLE dummy_test_tab SET (option_real = true); -- error
+ALTER TABLE dummy_test_tab SET (option_real = 'val5'); -- error
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+ALTER TABLE dummy_test_tab RESET (option_real);
+-- Enum
+ALTER TABLE dummy_test_tab SET (option_enum = 'one'); -- ok
+ALTER TABLE dummy_test_tab SET (option_enum = 0); -- error
+ALTER TABLE dummy_test_tab SET (option_enum = true); -- error
+ALTER TABLE dummy_test_tab SET (option_enum = 'three'); -- error
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+ALTER TABLE dummy_test_tab RESET (option_enum);
+-- String
+ALTER TABLE dummy_test_tab SET (option_string_val = 4); -- ok
+ALTER TABLE dummy_test_tab SET (option_string_val = 3.5); -- ok
+ALTER TABLE dummy_test_tab SET (option_string_val = true); -- ok, as "true"
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+ALTER TABLE dummy_test_tab RESET (option_string_val);
+
+DROP TABLE dummy_test_tab;
+
+-- ALTER TABLE SET ACCESS METHOD OPTIONS
+CREATE TABLE heap_tab (i INT4) WITH (fillfactor=100, toast_tuple_target=1000);
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'heap_tab';
+-- error: fillfactor is out of bounds: maximum value from the new table am is 90
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am;
+-- error: toast_tuple_target does not exist in the new table AM
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (SET fillfactor '50');
+-- error: adding is not possible when the parameter is already defined in source reloptions
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (ADD fillfactor '50');
+-- error: the specified option we want to drop does not exist
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (DROP does_not_exist);
+-- error: adding unrecognized parameter
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (SET fillfactor '50', DROP toast_tuple_target, ADD unrecognized 'foo');
+-- ok
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (DROP fillfactor, DROP toast_tuple_target, option_int '1', option_bool 'true', option_real '0.001', option_enum 'one', option_string_val 'hello');
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'heap_tab';
+DROP TABLE heap_tab;
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 2b057451473..28398254df7 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -4,6 +4,7 @@ subdir('brin')
 subdir('commit_ts')
 subdir('delay_execution')
 subdir('dummy_index_am')
+subdir('dummy_table_am')
 subdir('dummy_seclabel')
 subdir('gin')
 subdir('injection_points')
-- 
2.39.5

^ permalink  raw  reply  [nested|flat] 25+ messages in thread

* Re: Allow table AMs to define their own reloptions
@ 2025-03-02 15:20  Yura Sokolov <y.sokolov@postgrespro.ru>
  parent: Julien Tachoires <julien@tachoires.me>
  1 sibling, 1 reply; 25+ messages in thread

From: Yura Sokolov @ 2025-03-02 15:20 UTC (permalink / raw)
  To: Julien Tachoires <julien@tachoires.me>; pgsql-hackers

02.03.2025 16:23, Julien Tachoires пишет:
> On Sun, Mar 02, 2025 at 09:56:41AM +0100, Julien Tachoires wrote:
>> With the help of the new TAM routine 'relation_options', table access 
>> methods can with this patch define their own reloptions 
>> parser/validator.
>>
>> These reloptions can be set via the following commands:
>> 1. CREATE TABLE ... USING table_am
>>        WITH (option1='value1', option2='value2');
>> 2. ALTER TABLE ...
>>        SET (option1 'value1', option2 'value2');
>> 3. ALTER TABLE ... SET ACCESS METHOD table_am
>>        OPTIONS (option1 'value1', option2 'value2');
>>
>> When changing table's access method, the settings inherited from the 
>> former TAM can be dropped (if not supported by the new TAM) via: DROP 
>> option, or, updated via: SET option 'value'.
>>
>> Currently, tables using different TAMs than heap are able to use heap's 
>> reloptions (fillfactor, toast_tuple_target, etc...). With this patch 
>> applied, this is not the case anymore: if the TAM needs to have access 
>> to similar settings to heap ones, they have to explicitly define them.
>>
>> The 2nd patch file includes a new test module 'dummy_table_am' which 
>> implements a dummy table access method utilized to exercise TAM 
>> reloptions. This test module is strongly based on what we already have 
>> in 'dummy_index_am'. 'dummy_table_am' provides a complete example of TAM 
>> reloptions definition.
>>
>> This work is directly derived from SadhuPrasad's patch here [2]. Others 
>> attempts were posted here [1] and here [3].
>>
>> [1] https://www.postgresql.org/message-id/flat/429fb58fa3218221bb17c7bf9e70e1aa6cfc6b5d.camel%40j-davis....
>> [2] https://www.postgresql.org/message-id/flat/CAFF0-CG4KZHdtYHMsonWiXNzj16gWZpduXAn8yF7pDDub+GQMg@mail....
>> [3] https://www.postgresql.org/message-id/flat/AMUA1wBBBxfc3tKRLLdU64rb.1.1683276279979.Hmail.wuhao%40ha...
> 
> Please find a new version including minor fixes: 'TAM' terms are
> replaced by 'table AM'

Good day, Julien.

Your forgot another one attempt discussion with patch [1] with alive
commitfest entry [2]

[1] https://postgr.es/m/flat/3766675.7eaCOWfIcx%40thinkpad-pgpro
[2] https://commitfest.postgresql.org/patch/4688/


-------
regards
Yura Sokolov aka funny-falcon





^ permalink  raw  reply  [nested|flat] 25+ messages in thread

* Re: Allow table AMs to define their own reloptions
@ 2025-03-04 06:16  Julien Tachoires <julien@tachoires.me>
  parent: Yura Sokolov <y.sokolov@postgrespro.ru>
  0 siblings, 1 reply; 25+ messages in thread

From: Julien Tachoires @ 2025-03-04 06:16 UTC (permalink / raw)
  To: Yura Sokolov <y.sokolov@postgrespro.ru>; +Cc: pgsql-hackers

Hi Yura,

On Sun, Mar 02, 2025 at 06:20:07PM +0300, Yura Sokolov wrote:
> Your forgot another one attempt discussion with patch [1] with alive
> commitfest entry [2]
> 
> [1] https://postgr.es/m/flat/3766675.7eaCOWfIcx%40thinkpad-pgpro
> [2] https://commitfest.postgresql.org/patch/4688/

Thank you. After taking a look at the patch itself and the email thread,
it seems this patch does not add custom reloptions to table AMs, see
[1].

[1]: https://www.postgresql.org/message-id/1823308.yXV3o4JbTB%40thinkpad-pgpro

-- 
Julien Tachoires





^ permalink  raw  reply  [nested|flat] 25+ messages in thread

* Re: Allow table AMs to define their own reloptions
@ 2025-03-04 09:19  Yura Sokolov <y.sokolov@postgrespro.ru>
  parent: Julien Tachoires <julien@tachoires.me>
  0 siblings, 0 replies; 25+ messages in thread

From: Yura Sokolov @ 2025-03-04 09:19 UTC (permalink / raw)
  To: Julien Tachoires <julien@tachoires.me>; +Cc: pgsql-hackers

04.03.2025 09:16, Julien Tachoires пишет:
> Hi Yura,
> 
> On Sun, Mar 02, 2025 at 06:20:07PM +0300, Yura Sokolov wrote:
>> Your forgot another one attempt discussion with patch [1] with alive
>> commitfest entry [2]
>>
>> [1] https://postgr.es/m/flat/3766675.7eaCOWfIcx%40thinkpad-pgpro
>> [2] https://commitfest.postgresql.org/patch/4688/
> 
> Thank you. After taking a look at the patch itself and the email thread,
> it seems this patch does not add custom reloptions to table AMs, see
> [1].
> 
> [1]: https://www.postgresql.org/message-id/1823308.yXV3o4JbTB%40thinkpad-pgpro

But intention of this patch is to make adding options easier, as author says:

  But new option engine will make adding custom options for table AM more
  easy task, as main goal of this patch is to  simplify adding options
  everywhere they needed. And yes, adding custom table AM options is one of
  my next goals, as soon as this patch is commit.

I believe, adding generic way to options extension is better way than
targeting specific options. Don't you?

I believe Nikolay's patch needs review and support. As you're interesting
to the problem, may you at least look at his suggestion and analyze it from
the point of view of your task?

-------
regards
Yura Sokolov aka funny-falcon





^ permalink  raw  reply  [nested|flat] 25+ messages in thread

* Re: Allow table AMs to define their own reloptions
@ 2025-03-29 07:46  Julien Tachoires <julien@tachoires.me>
  parent: Julien Tachoires <julien@tachoires.me>
  1 sibling, 1 reply; 25+ messages in thread

From: Julien Tachoires @ 2025-03-29 07:46 UTC (permalink / raw)
  To: pgsql-hackers

On Sun, Mar 02, 2025 at 02:23:54PM +0100, Julien Tachoires wrote:
> On Sun, Mar 02, 2025 at 09:56:41AM +0100, Julien Tachoires wrote:
> > With the help of the new TAM routine 'relation_options', table access 
> > methods can with this patch define their own reloptions 
> > parser/validator.
> > 
> > These reloptions can be set via the following commands:
> > 1. CREATE TABLE ... USING table_am
> >        WITH (option1='value1', option2='value2');
> > 2. ALTER TABLE ...
> >        SET (option1 'value1', option2 'value2');
> > 3. ALTER TABLE ... SET ACCESS METHOD table_am
> >        OPTIONS (option1 'value1', option2 'value2');
> > 
> > When changing table's access method, the settings inherited from the 
> > former TAM can be dropped (if not supported by the new TAM) via: DROP 
> > option, or, updated via: SET option 'value'.
> > 
> > Currently, tables using different TAMs than heap are able to use heap's 
> > reloptions (fillfactor, toast_tuple_target, etc...). With this patch 
> > applied, this is not the case anymore: if the TAM needs to have access 
> > to similar settings to heap ones, they have to explicitly define them.
> > 
> > The 2nd patch file includes a new test module 'dummy_table_am' which 
> > implements a dummy table access method utilized to exercise TAM 
> > reloptions. This test module is strongly based on what we already have 
> > in 'dummy_index_am'. 'dummy_table_am' provides a complete example of TAM 
> > reloptions definition.
> > 
> > This work is directly derived from SadhuPrasad's patch here [2]. Others 
> > attempts were posted here [1] and here [3].
> > 
> > [1] https://www.postgresql.org/message-id/flat/429fb58fa3218221bb17c7bf9e70e1aa6cfc6b5d.camel%40j-davis....
> > [2] https://www.postgresql.org/message-id/flat/CAFF0-CG4KZHdtYHMsonWiXNzj16gWZpduXAn8yF7pDDub+GQMg@mail....
> > [3] https://www.postgresql.org/message-id/flat/AMUA1wBBBxfc3tKRLLdU64rb.1.1683276279979.Hmail.wuhao%40ha...
> 
> Please find a new version including minor fixes: 'TAM' terms are
> replaced by 'table AM'

Please find a new rebased version. 

-- 
Julien Tachoires

Attachments:

  [text/x-diff] v3-0001-Allow-table-AMs-to-define-their-own-reloptions.patch (25.0K, ../../20250329074601.3zgrsozvycthucir@poseidon.home.virt/2-v3-0001-Allow-table-AMs-to-define-their-own-reloptions.patch)
  download | inline diff:
From ceb99fc9cb49eb5bca7ef35dd2afc767b5d2abf1 Mon Sep 17 00:00:00 2001
From: Julien Tachoires <julien@tachoires.me>
Date: Sat, 1 Mar 2025 17:59:49 +0100
Subject: [PATCH 1/2] Allow table AMs to define their own reloptions

With the help of the new routine 'relation_options', table access
methods can now define their own reloptions.

These options can be set via the following commands:
1. CREATE TABLE ... USING table_am
       WITH (option1='value1', option2='value2');
2. ALTER TABLE ...
       SET (option1 'value1', option2 'value2');
3. ALTER TABLE ... SET ACCESS METHOD table_am
       OPTIONS (option1 'value1', option2 'value2');

When changing table's access method, the settings from the former
table AM can be dropped (if not supported by the new table AM) via:
DROP option, or, updated via: SET option 'value'.

Before this commit, tables using different table AMs than heap were
able to use heap's reloptions (fillfactor, toast_tuple_target,
etc...). Now, this is not the case anymore: if the table AM needs
to have access to settings similar to heap ones, they must
explicitly define them.

This work is directly derived from SadhuPrasad's patch named:
v4-0001-PATCH-V4-Per-table-storage-parameters-for-TableAM.patch
---
 doc/src/sgml/ref/alter_table.sgml        |  13 +-
 doc/src/sgml/ref/create_table.sgml       |   3 +-
 src/backend/access/common/reloptions.c   |  66 ++++++++-
 src/backend/access/heap/heapam_handler.c |   2 +
 src/backend/commands/foreigncmds.c       |   2 +-
 src/backend/commands/tablecmds.c         | 180 ++++++++++++++++++++---
 src/backend/parser/gram.y                |   9 ++
 src/backend/postmaster/autovacuum.c      |  18 ++-
 src/backend/utils/cache/relcache.c       |  11 +-
 src/include/access/reloptions.h          |   6 +-
 src/include/access/tableam.h             |  10 ++
 src/include/commands/defrem.h            |   1 +
 12 files changed, 286 insertions(+), 35 deletions(-)

diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index 11d1bc7dbe1..1b4dd023877 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -77,7 +77,7 @@ ALTER TABLE [ IF EXISTS ] <replaceable class="parameter">name</replaceable>
     CLUSTER ON <replaceable class="parameter">index_name</replaceable>
     SET WITHOUT CLUSTER
     SET WITHOUT OIDS
-    SET ACCESS METHOD { <replaceable class="parameter">new_access_method</replaceable> | DEFAULT }
+    SET ACCESS METHOD { <replaceable class="parameter">new_access_method</replaceable> | DEFAULT } [ OPTIONS ( [ ADD | SET | DROP ] <replaceable class="parameter">option</replaceable> ['<replaceable class="parameter">value</replaceable>'] [, ... ] ) ]
     SET TABLESPACE <replaceable class="parameter">new_tablespace</replaceable>
     SET { LOGGED | UNLOGGED }
     SET ( <replaceable class="parameter">storage_parameter</replaceable> [= <replaceable class="parameter">value</replaceable>] [, ... ] )
@@ -755,7 +755,7 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
    </varlistentry>
 
    <varlistentry id="sql-altertable-desc-set-access-method">
-    <term><literal>SET ACCESS METHOD</literal></term>
+    <term><literal>SET ACCESS METHOD { <replaceable class="parameter">new_access_method</replaceable> | DEFAULT } [ OPTIONS ( [ ADD | SET | DROP ] <replaceable class="parameter">option</replaceable> ['<replaceable class="parameter">value</replaceable>'] [, ... ] ) ]</literal></term>
     <listitem>
      <para>
       This form changes the access method of the table by rewriting it
@@ -773,6 +773,15 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
       causing future partitions to default to
       <varname>default_table_access_method</varname>.
      </para>
+     <para>
+      Specifying <literal>OPTIONS</literal> allows to change options for
+      the table when changing the table access method.
+      <literal>ADD</literal>, <literal>SET</literal>, and
+      <literal>DROP</literal> specify the action to be performed.
+      <literal>ADD</literal> is assumed if no operation is explicitly
+      specified.  Option names must be unique; names and values are also
+      validated using the table access method's library.
+     </para>
     </listitem>
    </varlistentry>
 
diff --git a/doc/src/sgml/ref/create_table.sgml b/doc/src/sgml/ref/create_table.sgml
index e5c034d724e..4420d4c83cd 100644
--- a/doc/src/sgml/ref/create_table.sgml
+++ b/doc/src/sgml/ref/create_table.sgml
@@ -1552,7 +1552,8 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
     Storage parameters for
     indexes are documented in <xref linkend="sql-createindex"/>.
     The storage parameters currently
-    available for tables are listed below.  For many of these parameters, as
+    available for tables are listed below. Each table may have different set of storage
+    parameters through different access methods. For many of these parameters, as
     shown, there is an additional parameter with the same name prefixed with
     <literal>toast.</literal>, which controls the behavior of the
     table's secondary <acronym>TOAST</acronym> table, if any
diff --git a/src/backend/access/common/reloptions.c b/src/backend/access/common/reloptions.c
index 645b5c00467..8de07d3d266 100644
--- a/src/backend/access/common/reloptions.c
+++ b/src/backend/access/common/reloptions.c
@@ -25,6 +25,7 @@
 #include "access/reloptions.h"
 #include "access/spgist_private.h"
 #include "catalog/pg_type.h"
+#include "catalog/pg_am.h"
 #include "commands/defrem.h"
 #include "commands/tablespace.h"
 #include "nodes/makefuncs.h"
@@ -34,6 +35,7 @@
 #include "utils/guc.h"
 #include "utils/memutils.h"
 #include "utils/rel.h"
+#include "utils/syscache.h"
 
 /*
  * Contents of pg_class.reloptions
@@ -1396,7 +1398,7 @@ untransformRelOptions(Datum options)
  */
 bytea *
 extractRelOptions(HeapTuple tuple, TupleDesc tupdesc,
-				  amoptions_function amoptions)
+				  amoptions_function amoptions, reloptions_function reloptsfun)
 {
 	bytea	   *options;
 	bool		isnull;
@@ -1418,7 +1420,8 @@ extractRelOptions(HeapTuple tuple, TupleDesc tupdesc,
 		case RELKIND_RELATION:
 		case RELKIND_TOASTVALUE:
 		case RELKIND_MATVIEW:
-			options = heap_reloptions(classForm->relkind, datum, false);
+			options = table_reloptions(reloptsfun, InvalidOid, classForm->relkind,
+									   datum, false);
 			break;
 		case RELKIND_PARTITIONED_TABLE:
 			options = partitioned_table_reloptions(datum, false);
@@ -2048,7 +2051,8 @@ view_reloptions(Datum reloptions, bool validate)
 }
 
 /*
- * Parse options for heaps, views and toast tables.
+ * Parse options for heaps, views and toast tables. This is the implementation
+ * of relOptions for the access method heap.
  */
 bytea *
 heap_reloptions(char relkind, Datum reloptions, bool validate)
@@ -2078,6 +2082,62 @@ heap_reloptions(char relkind, Datum reloptions, bool validate)
 }
 
 
+/*
+ * Parse options for tables.
+ *
+ *	reloptsfun	Table AM's option parser function. Can be NULL if amid is
+ *				valid. In this case we load the new table AM and use its option
+ *				parser function.
+ *	amid		New table AM's Oid if any.
+ *	relkind		relation kind
+ *	reloptions	options as text[] datum
+ *	validate	error flag
+ */
+bytea *
+table_reloptions(reloptions_function reloptsfun, Oid amid, char relkind,
+				 Datum reloptions, bool validate)
+{
+	/* amid and reloptsfun are mutually exclusive */
+	Assert((!OidIsValid(amid) && (reloptsfun != NULL)) || \
+		   (OidIsValid(amid) && (reloptsfun == NULL)));
+
+	/* Parse/validate options using reloptsfun */
+	if (!OidIsValid(amid) && reloptsfun != NULL)
+	{
+		/* Assume function is strict */
+		if (!PointerIsValid(DatumGetPointer(reloptions)))
+			return NULL;
+
+		return reloptsfun(relkind, reloptions, validate);
+	}
+	/* Parse/validate options using the API of the new Table AM */
+	else if (OidIsValid(amid) && (reloptsfun == NULL))
+	{
+		const TableAmRoutine *routine;
+		HeapTuple	atuple;
+		Form_pg_am	aform;
+
+		atuple = SearchSysCache1(AMOID, ObjectIdGetDatum(amid));
+
+		if (!HeapTupleIsValid(atuple))
+			elog(ERROR, "cache lookup failed for access method %u", amid);
+
+		aform = (Form_pg_am) GETSTRUCT(atuple);
+		routine = GetTableAmRoutine(aform->amhandler);
+		ReleaseSysCache(atuple);
+
+		if (routine->relation_options != NULL)
+			return routine->relation_options(relkind, reloptions, validate);
+
+		return NULL;
+	}
+	else
+	{
+		/* Should not happen */
+		return NULL;
+	}
+}
+
 /*
  * Parse options for indexes.
  *
diff --git a/src/backend/access/heap/heapam_handler.c b/src/backend/access/heap/heapam_handler.c
index 24d3765aa20..e9a1cb4ba1e 100644
--- a/src/backend/access/heap/heapam_handler.c
+++ b/src/backend/access/heap/heapam_handler.c
@@ -24,6 +24,7 @@
 #include "access/heaptoast.h"
 #include "access/multixact.h"
 #include "access/rewriteheap.h"
+#include "access/reloptions.h"
 #include "access/syncscan.h"
 #include "access/tableam.h"
 #include "access/tsmapi.h"
@@ -2701,6 +2702,7 @@ static const TableAmRoutine heapam_methods = {
 	.index_build_range_scan = heapam_index_build_range_scan,
 	.index_validate_scan = heapam_index_validate_scan,
 
+	.relation_options = heap_reloptions,
 	.relation_size = table_block_relation_size,
 	.relation_needs_toast_table = heapam_relation_needs_toast_table,
 	.relation_toast_am = heapam_relation_toast_am,
diff --git a/src/backend/commands/foreigncmds.c b/src/backend/commands/foreigncmds.c
index c14e038d54f..9dab5dfb999 100644
--- a/src/backend/commands/foreigncmds.c
+++ b/src/backend/commands/foreigncmds.c
@@ -62,7 +62,7 @@ static void import_error_callback(void *arg);
  * processing, hence any validation should be done before this
  * conversion.
  */
-static Datum
+Datum
 optionListToArray(List *options)
 {
 	ArrayBuildState *astate = NULL;
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 10624353b0a..d067b9a0be9 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -657,6 +657,8 @@ static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
 								const char *tablespacename, LOCKMODE lockmode);
 static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
 static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
+static void ATExecSetAccessMethodOptions(Relation rel, List *defList, AlterTableType operation,
+										 LOCKMODE lockmode, Oid newAccessMethodId);
 static void ATExecSetRelOptions(Relation rel, List *defList,
 								AlterTableType operation,
 								LOCKMODE lockmode);
@@ -906,24 +908,6 @@ DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId,
 	if (!OidIsValid(ownerId))
 		ownerId = GetUserId();
 
-	/*
-	 * Parse and validate reloptions, if any.
-	 */
-	reloptions = transformRelOptions((Datum) 0, stmt->options, NULL, validnsps,
-									 true, false);
-
-	switch (relkind)
-	{
-		case RELKIND_VIEW:
-			(void) view_reloptions(reloptions, true);
-			break;
-		case RELKIND_PARTITIONED_TABLE:
-			(void) partitioned_table_reloptions(reloptions, true);
-			break;
-		default:
-			(void) heap_reloptions(relkind, reloptions, true);
-	}
-
 	if (stmt->ofTypename)
 	{
 		AclResult	aclresult;
@@ -1026,6 +1010,29 @@ DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId,
 			accessMethodId = get_table_am_oid(default_table_access_method, false);
 	}
 
+	/*
+	 * Parse and validate reloptions, if any.
+	 */
+	reloptions = transformRelOptions((Datum) 0, stmt->options, NULL, validnsps,
+									 true, false);
+	switch (relkind)
+	{
+		case RELKIND_VIEW:
+			(void) view_reloptions(reloptions, true);
+			break;
+		case RELKIND_PARTITIONED_TABLE:
+			(void) partitioned_table_reloptions(reloptions, true);
+			break;
+		case RELKIND_RELATION:
+		case RELKIND_TOASTVALUE:
+		case RELKIND_MATVIEW:
+			(void) table_reloptions(NULL, accessMethodId, relkind, reloptions,
+									true);
+			break;
+		default:
+			(void) heap_reloptions(relkind, reloptions, true);
+	}
+
 	/*
 	 * Create the relation.  Inherited defaults and CHECK constraints are
 	 * passed in for immediate handling --- since they don't need parsing,
@@ -5507,6 +5514,9 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
 			if (rel->rd_rel->relkind == RELKIND_PARTITIONED_TABLE &&
 				tab->chgAccessMethod)
 				ATExecSetAccessMethodNoStorage(rel, tab->newAccessMethod);
+
+			ATExecSetAccessMethodOptions(rel, (List *) cmd->def, cmd->subtype,
+										 lockmode, tab->newAccessMethod);
 			break;
 		case AT_SetTableSpace:	/* SET TABLESPACE */
 
@@ -16024,6 +16034,138 @@ ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel, const char *tablespacen
 	tab->newTableSpace = tablespaceId;
 }
 
+/* SET, ADD or DROP options in ALTER TABLE SET ACCESS METHOD */
+static void
+ATExecSetAccessMethodOptions(Relation rel, List *options, AlterTableType operation,
+							 LOCKMODE lockmode, Oid newAccessMethodId)
+{
+	Oid			relid;
+	Relation	pgclass;
+	HeapTuple	tuple;
+	HeapTuple	newtuple;
+	Datum		datum;
+	bool		isnull;
+	Datum		newOptions;
+	Datum		repl_val[Natts_pg_class];
+	bool		repl_null[Natts_pg_class];
+	bool		repl_repl[Natts_pg_class];
+	List	   *resultOptions;
+	ListCell   *optcell;
+
+	pgclass = table_open(RelationRelationId, RowExclusiveLock);
+
+	/* Fetch heap tuple */
+	relid = RelationGetRelid(rel);
+	tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
+	if (!HeapTupleIsValid(tuple))
+		elog(ERROR, "cache lookup failed for relation %u", relid);
+
+	/* Get the old reloptions */
+	datum = SysCacheGetAttr(RELOID, tuple, Anum_pg_class_reloptions, &isnull);
+
+	if (isnull)
+		datum = PointerGetDatum(NULL);
+
+	resultOptions = untransformRelOptions(datum);
+
+	foreach(optcell, options)
+	{
+		DefElem    *od = lfirst(optcell);
+		ListCell   *cell;
+
+		/* Search in existing options */
+		foreach(cell, resultOptions)
+		{
+			DefElem    *def = lfirst(cell);
+
+			if (strcmp(def->defname, od->defname) == 0)
+				break;
+		}
+
+		/*
+		 * It is possible to perform multiple SET/DROP actions on the same
+		 * option.  The standard permits this, as long as the options to be
+		 * added are unique.  Note that an unspecified action is taken to be
+		 * ADD.
+		 */
+		switch (od->defaction)
+		{
+			case DEFELEM_DROP:
+				if (!cell)
+					ereport(ERROR,
+							(errcode(ERRCODE_UNDEFINED_OBJECT),
+							 errmsg("option \"%s\" not found",
+									od->defname)));
+				resultOptions = list_delete_cell(resultOptions, cell);
+				break;
+
+			case DEFELEM_SET:
+				if (!cell)
+					ereport(ERROR,
+							(errcode(ERRCODE_UNDEFINED_OBJECT),
+							 errmsg("option \"%s\" not found",
+									od->defname)));
+				lfirst(cell) = od;
+				break;
+
+			case DEFELEM_ADD:
+			case DEFELEM_UNSPEC:
+				if (cell)
+					ereport(ERROR,
+							(errcode(ERRCODE_DUPLICATE_OBJECT),
+							 errmsg("option \"%s\" provided more than once",
+									od->defname)));
+				resultOptions = lappend(resultOptions, od);
+				break;
+
+			default:
+				elog(ERROR, "unrecognized action %d on option \"%s\"",
+					 (int) od->defaction, od->defname);
+				break;
+		}
+	}
+
+	newOptions = optionListToArray(resultOptions);
+
+	/*
+	 * If the new table access method was not explicitly defined, then use the
+	 * default one.
+	 */
+	if (!OidIsValid(newAccessMethodId))
+		newAccessMethodId = get_table_am_oid(default_table_access_method, false);
+
+	/* Validate new options via the new Table Access Method API */
+	(void) table_reloptions(NULL, newAccessMethodId, rel->rd_rel->relkind,
+							newOptions, true);
+
+	/* Initialize buffers for new tuple values */
+	memset(repl_val, 0, sizeof(repl_val));
+	memset(repl_null, false, sizeof(repl_null));
+	memset(repl_repl, false, sizeof(repl_repl));
+
+	if (newOptions != (Datum) 0)
+		repl_val[Anum_pg_class_reloptions - 1] = newOptions;
+	else
+		repl_null[Anum_pg_class_reloptions - 1] = true;
+
+	repl_repl[Anum_pg_class_reloptions - 1] = true;
+
+	/* Everything looks good - update the tuple */
+	newtuple = heap_modify_tuple(tuple, RelationGetDescr(pgclass),
+								 repl_val, repl_null, repl_repl);
+
+	CatalogTupleUpdate(pgclass, &newtuple->t_self, newtuple);
+
+	InvokeObjectPostAlterHook(RelationRelationId, RelationGetRelid(rel),
+							  InvalidOid);
+
+	ReleaseSysCache(tuple);
+
+	table_close(pgclass, RowExclusiveLock);
+
+	heap_freetuple(newtuple);
+}
+
 /*
  * Set, reset, or replace reloptions.
  */
@@ -16081,7 +16223,7 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
 	{
 		case RELKIND_RELATION:
 		case RELKIND_MATVIEW:
-			(void) heap_reloptions(rel->rd_rel->relkind, newOptions, true);
+			rel->rd_tableam->relation_options(rel->rd_rel->relkind, newOptions, true);
 			break;
 		case RELKIND_PARTITIONED_TABLE:
 			(void) partitioned_table_reloptions(newOptions, true);
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index 0fc502a3a40..16ac2ea8260 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -2919,6 +2919,15 @@ alter_table_cmd:
 					n->name = $4;
 					$$ = (Node *) n;
 				}
+			/* ALTER TABLE <name> SET ACCESS METHOD <amname> [OPTIONS]*/
+			| SET ACCESS METHOD name alter_generic_options
+				{
+					AlterTableCmd *n = makeNode(AlterTableCmd);
+					n->subtype = AT_SetAccessMethod;
+					n->name = $4;
+					n->def = (Node *) $5;
+					$$ = (Node *)n;
+				}
 			/* ALTER TABLE <name> SET TABLESPACE <tablespacename> */
 			| SET TABLESPACE name
 				{
diff --git a/src/backend/postmaster/autovacuum.c b/src/backend/postmaster/autovacuum.c
index 2513a8ef8a6..aff14a71585 100644
--- a/src/backend/postmaster/autovacuum.c
+++ b/src/backend/postmaster/autovacuum.c
@@ -332,6 +332,7 @@ static void FreeWorkerInfo(int code, Datum arg);
 
 static autovac_table *table_recheck_autovac(Oid relid, HTAB *table_toast_map,
 											TupleDesc pg_class_desc,
+											reloptions_function reloptions,
 											int effective_multixact_freeze_max_age);
 static void recheck_relation_needs_vacanalyze(Oid relid, AutoVacOpts *avopts,
 											  Form_pg_class classForm,
@@ -346,7 +347,7 @@ static void relation_needs_vacanalyze(Oid relid, AutoVacOpts *relopts,
 static void autovacuum_do_vac_analyze(autovac_table *tab,
 									  BufferAccessStrategy bstrategy);
 static AutoVacOpts *extract_autovac_opts(HeapTuple tup,
-										 TupleDesc pg_class_desc);
+										 TupleDesc pg_class_desc, reloptions_function reloptions);
 static void perform_work_item(AutoVacuumWorkItem *workitem);
 static void autovac_report_activity(autovac_table *tab);
 static void autovac_report_workitem(AutoVacuumWorkItem *workitem,
@@ -2033,7 +2034,8 @@ do_autovacuum(void)
 		}
 
 		/* Fetch reloptions and the pgstat entry for this table */
-		relopts = extract_autovac_opts(tuple, pg_class_desc);
+		relopts = extract_autovac_opts(tuple, pg_class_desc,
+									   classRel->rd_tableam->relation_options);
 		tabentry = pgstat_fetch_stat_tabentry_ext(classForm->relisshared,
 												  relid);
 
@@ -2106,7 +2108,8 @@ do_autovacuum(void)
 		 * fetch reloptions -- if this toast table does not have them, try the
 		 * main rel
 		 */
-		relopts = extract_autovac_opts(tuple, pg_class_desc);
+		relopts = extract_autovac_opts(tuple, pg_class_desc,
+									   classRel->rd_tableam->relation_options);
 		if (relopts == NULL)
 		{
 			av_relation *hentry;
@@ -2364,6 +2367,7 @@ do_autovacuum(void)
 		 */
 		MemoryContextSwitchTo(AutovacMemCxt);
 		tab = table_recheck_autovac(relid, table_toast_map, pg_class_desc,
+									classRel->rd_tableam->relation_options,
 									effective_multixact_freeze_max_age);
 		if (tab == NULL)
 		{
@@ -2689,7 +2693,8 @@ deleted2:
  * be a risk; fortunately, it doesn't.
  */
 static AutoVacOpts *
-extract_autovac_opts(HeapTuple tup, TupleDesc pg_class_desc)
+extract_autovac_opts(HeapTuple tup, TupleDesc pg_class_desc,
+					 reloptions_function reloptions)
 {
 	bytea	   *relopts;
 	AutoVacOpts *av;
@@ -2698,7 +2703,7 @@ extract_autovac_opts(HeapTuple tup, TupleDesc pg_class_desc)
 		   ((Form_pg_class) GETSTRUCT(tup))->relkind == RELKIND_MATVIEW ||
 		   ((Form_pg_class) GETSTRUCT(tup))->relkind == RELKIND_TOASTVALUE);
 
-	relopts = extractRelOptions(tup, pg_class_desc, NULL);
+	relopts = extractRelOptions(tup, pg_class_desc, NULL, reloptions);
 	if (relopts == NULL)
 		return NULL;
 
@@ -2721,6 +2726,7 @@ extract_autovac_opts(HeapTuple tup, TupleDesc pg_class_desc)
 static autovac_table *
 table_recheck_autovac(Oid relid, HTAB *table_toast_map,
 					  TupleDesc pg_class_desc,
+					  reloptions_function reloptions,
 					  int effective_multixact_freeze_max_age)
 {
 	Form_pg_class classForm;
@@ -2741,7 +2747,7 @@ table_recheck_autovac(Oid relid, HTAB *table_toast_map,
 	 * Get the applicable reloptions.  If it is a TOAST table, try to get the
 	 * main table reloptions if the toast table itself doesn't have.
 	 */
-	avopts = extract_autovac_opts(classTup, pg_class_desc);
+	avopts = extract_autovac_opts(classTup, pg_class_desc, reloptions);
 	if (classForm->relkind == RELKIND_TOASTVALUE &&
 		avopts == NULL && table_toast_map != NULL)
 	{
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index 9f54a9e72b7..8771e8d9846 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -469,6 +469,7 @@ RelationParseRelOptions(Relation relation, HeapTuple tuple)
 {
 	bytea	   *options;
 	amoptions_function amoptsfn;
+	reloptions_function reloptsfn;
 
 	relation->rd_options = NULL;
 
@@ -480,13 +481,18 @@ RelationParseRelOptions(Relation relation, HeapTuple tuple)
 	{
 		case RELKIND_RELATION:
 		case RELKIND_TOASTVALUE:
-		case RELKIND_VIEW:
 		case RELKIND_MATVIEW:
+			reloptsfn = relation->rd_tableam->relation_options;
+			amoptsfn = NULL;
+			break;
+		case RELKIND_VIEW:
 		case RELKIND_PARTITIONED_TABLE:
+			reloptsfn = NULL;
 			amoptsfn = NULL;
 			break;
 		case RELKIND_INDEX:
 		case RELKIND_PARTITIONED_INDEX:
+			reloptsfn = NULL;
 			amoptsfn = relation->rd_indam->amoptions;
 			break;
 		default:
@@ -498,7 +504,8 @@ RelationParseRelOptions(Relation relation, HeapTuple tuple)
 	 * we might not have any other for pg_class yet (consider executing this
 	 * code for pg_class itself)
 	 */
-	options = extractRelOptions(tuple, GetPgClassDescriptor(), amoptsfn);
+	options = extractRelOptions(tuple, GetPgClassDescriptor(),
+								amoptsfn, reloptsfn);
 
 	/*
 	 * Copy parsed data into CacheMemoryContext.  To guard against the
diff --git a/src/include/access/reloptions.h b/src/include/access/reloptions.h
index dfbb4c85460..37f51d0f1c2 100644
--- a/src/include/access/reloptions.h
+++ b/src/include/access/reloptions.h
@@ -21,6 +21,7 @@
 
 #include "access/amapi.h"
 #include "access/htup.h"
+#include "access/tableam.h"
 #include "access/tupdesc.h"
 #include "nodes/pg_list.h"
 #include "storage/lock.h"
@@ -237,7 +238,8 @@ extern Datum transformRelOptions(Datum oldOptions, List *defList,
 								 bool acceptOidsOff, bool isReset);
 extern List *untransformRelOptions(Datum options);
 extern bytea *extractRelOptions(HeapTuple tuple, TupleDesc tupdesc,
-								amoptions_function amoptions);
+								amoptions_function amoptions,
+								reloptions_function reloptsfun);
 extern void *build_reloptions(Datum reloptions, bool validate,
 							  relopt_kind kind,
 							  Size relopt_struct_size,
@@ -251,6 +253,8 @@ extern bytea *default_reloptions(Datum reloptions, bool validate,
 extern bytea *heap_reloptions(char relkind, Datum reloptions, bool validate);
 extern bytea *view_reloptions(Datum reloptions, bool validate);
 extern bytea *partitioned_table_reloptions(Datum reloptions, bool validate);
+extern bytea *table_reloptions(reloptions_function reloptsfun, Oid amid, char relkind,
+							   Datum reloptions, bool validate);
 extern bytea *index_reloptions(amoptions_function amoptions, Datum reloptions,
 							   bool validate);
 extern bytea *attribute_reloptions(Datum reloptions, bool validate);
diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h
index b8cb1e744ad..f7ec0ed57bc 100644
--- a/src/include/access/tableam.h
+++ b/src/include/access/tableam.h
@@ -276,6 +276,14 @@ typedef void (*IndexBuildCallback) (Relation index,
 									bool tupleIsAlive,
 									void *state);
 
+/*
+ * Callback in charge of parsing and validating the table reloptions.
+ * It returns parsed options in bytea format.
+ */
+typedef bytea *(*reloptions_function) (char relkind,
+									   Datum reloptions,
+									   bool validate);
+
 /*
  * API struct for a table AM.  Note this must be allocated in a
  * server-lifetime manner, typically as a static const struct, which then gets
@@ -715,6 +723,8 @@ typedef struct TableAmRoutine
 	 * ------------------------------------------------------------------------
 	 */
 
+	reloptions_function relation_options;
+
 	/*
 	 * See table_relation_size().
 	 *
diff --git a/src/include/commands/defrem.h b/src/include/commands/defrem.h
index dd22b5efdfd..8e42f394107 100644
--- a/src/include/commands/defrem.h
+++ b/src/include/commands/defrem.h
@@ -136,6 +136,7 @@ extern ObjectAddress AlterUserMapping(AlterUserMappingStmt *stmt);
 extern Oid	RemoveUserMapping(DropUserMappingStmt *stmt);
 extern void CreateForeignTable(CreateForeignTableStmt *stmt, Oid relid);
 extern void ImportForeignSchema(ImportForeignSchemaStmt *stmt);
+extern Datum optionListToArray(List *options);
 extern Datum transformGenericOptions(Oid catalogId,
 									 Datum oldOptions,
 									 List *options,
-- 
2.39.5

  [text/x-diff] v3-0002-Add-the-dummy_table_am-test-module.patch (33.1K, ../../20250329074601.3zgrsozvycthucir@poseidon.home.virt/3-v3-0002-Add-the-dummy_table_am-test-module.patch)
  download | inline diff:
From 8d3ec3528f30a5fcacc2930249d2e20c0ad325bd Mon Sep 17 00:00:00 2001
From: Julien Tachoires <julien@tachoires.me>
Date: Sat, 1 Mar 2025 20:50:13 +0100
Subject: [PATCH 2/2] Add the "dummy_table_am" test module

This test module is in charge of testing table AM reloptions. It's
very similar to what we do in dummy_index_am as we have to exercise
the exact same kind of feature.
---
 src/test/modules/Makefile                     |   1 +
 src/test/modules/dummy_table_am/Makefile      |  20 +
 src/test/modules/dummy_table_am/README        |  14 +
 .../dummy_table_am/dummy_table_am--1.0.sql    |  13 +
 .../modules/dummy_table_am/dummy_table_am.c   | 581 ++++++++++++++++++
 .../dummy_table_am/dummy_table_am.control     |   5 +
 .../dummy_table_am/expected/reloptions.out    | 181 ++++++
 src/test/modules/dummy_table_am/meson.build   |  33 +
 .../modules/dummy_table_am/sql/reloptions.sql |  99 +++
 src/test/modules/meson.build                  |   1 +
 10 files changed, 948 insertions(+)
 create mode 100644 src/test/modules/dummy_table_am/Makefile
 create mode 100644 src/test/modules/dummy_table_am/README
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am.c
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am.control
 create mode 100644 src/test/modules/dummy_table_am/expected/reloptions.out
 create mode 100644 src/test/modules/dummy_table_am/meson.build
 create mode 100644 src/test/modules/dummy_table_am/sql/reloptions.sql

diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 4e4be3fa511..8fe2a2904d6 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -9,6 +9,7 @@ SUBDIRS = \
 		  commit_ts \
 		  delay_execution \
 		  dummy_index_am \
+		  dummy_table_am \
 		  dummy_seclabel \
 		  libpq_pipeline \
 		  oauth_validator \
diff --git a/src/test/modules/dummy_table_am/Makefile b/src/test/modules/dummy_table_am/Makefile
new file mode 100644
index 00000000000..94837dff392
--- /dev/null
+++ b/src/test/modules/dummy_table_am/Makefile
@@ -0,0 +1,20 @@
+# src/test/modules/dummy_table_am/Makefile
+
+MODULES = dummy_table_am
+
+EXTENSION = dummy_table_am
+DATA = dummy_table_am--1.0.sql
+PGFILEDESC = "dummy_table_am - table access method template"
+
+REGRESS = reloptions
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/dummy_table_am
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/dummy_table_am/README b/src/test/modules/dummy_table_am/README
new file mode 100644
index 00000000000..50cf08ee3b1
--- /dev/null
+++ b/src/test/modules/dummy_table_am/README
@@ -0,0 +1,14 @@
+Dummy Table AM
+==============
+
+Dummy table AM is a module for testing any facility usable by a table
+access method, whose code is kept a maximum simple.
+
+This includes tests for all relation option types:
+- boolean
+- enum
+- integer
+- real
+- strings (with and without NULL as default)
+
+It also includes tests related to unrecognized options.
diff --git a/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql b/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
new file mode 100644
index 00000000000..12ad3ad174b
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
@@ -0,0 +1,13 @@
+/* src/test/modules/dummy_table_am/dummy_table_am--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION dummy_table_am" to load this file. \quit
+
+CREATE FUNCTION dummy_table_am_handler(internal)
+RETURNS table_am_handler
+AS 'MODULE_PATHNAME'
+LANGUAGE C;
+
+-- Access method
+CREATE ACCESS METHOD dummy_table_am TYPE TABLE HANDLER dummy_table_am_handler;
+COMMENT ON ACCESS METHOD dummy_table_am IS 'Dummy Table Access Method';
diff --git a/src/test/modules/dummy_table_am/dummy_table_am.c b/src/test/modules/dummy_table_am/dummy_table_am.c
new file mode 100644
index 00000000000..bc9beba195a
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am.c
@@ -0,0 +1,581 @@
+/*-------------------------------------------------------------------------
+ *
+ * dummy_table_am.c
+ *		Table AM templae main file
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/test/modules/dummy_table_am/dummy_table_am.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "miscadmin.h"
+
+#include "access/hio.h"
+#include "access/relscan.h"
+#include "access/reloptions.h"
+#include "access/tableam.h"
+#include "access/sdir.h"
+#include "access/skey.h"
+#include "executor/tuptable.h"
+#include "utils/relcache.h"
+#include "utils/snapshot.h"
+
+
+PG_MODULE_MAGIC;
+
+/* Base structures for scans */
+typedef struct DummyScanDescData
+{
+	TableScanDescData rs_base;	/* AM independent part of the descriptor */
+
+	/* Add more fields here as needed by the AM. */
+}			DummyScanDescData;
+typedef struct DummyScanDescData *DummyScanDesc;
+
+/* parse table for fillRelOptions */
+static relopt_parse_elt dt_relopt_tab[7];
+
+/* Kind of relation options for dummy index */
+static relopt_kind dt_relopt_kind;
+
+typedef enum DummyAmEnum
+{
+	DUMMY_AM_ENUM_ONE,
+	DUMMY_AM_ENUM_TWO,
+}			DummyAmEnum;
+
+/* Dummy table options */
+typedef struct DummyTableOptions
+{
+	int32		vl_len_;		/* varlena header (do not touch directly!) */
+	int			option_int;
+	double		option_real;
+	bool		option_bool;
+	DummyAmEnum option_enum;
+	int			option_string_val_offset;
+	int			option_string_null_offset;
+	int			fillfactor;
+}			DummyTableOptions;
+
+static relopt_enum_elt_def dummyAmEnumValues[] =
+{
+	{"one", DUMMY_AM_ENUM_ONE},
+	{"two", DUMMY_AM_ENUM_TWO},
+	{(const char *) NULL}		/* list terminator */
+};
+
+/* ------------------------------------------------------------------------
+ *                     Dummy Access Method Interface
+ * ------------------------------------------------------------------------
+ */
+
+static const TupleTableSlotOps *
+dummy_slot_callbacks(Relation relation)
+{
+	return &TTSOpsMinimalTuple;
+}
+
+static TableScanDesc
+dummy_scan_begin(Relation relation, Snapshot snapshot, int nkeys, ScanKey key,
+				 ParallelTableScanDesc parallel_scan, uint32 flags)
+{
+	DummyScanDesc scan;
+
+	scan = (DummyScanDesc) palloc(sizeof(DummyScanDescData));
+
+	scan->rs_base.rs_rd = relation;
+	scan->rs_base.rs_snapshot = snapshot;
+	scan->rs_base.rs_nkeys = nkeys;
+	scan->rs_base.rs_flags = flags;
+	scan->rs_base.rs_parallel = parallel_scan;
+
+	return (TableScanDesc) scan;
+}
+
+static void
+dummy_scan_end(TableScanDesc sscan)
+{
+	DummyScanDesc scan = (DummyScanDesc) sscan;
+
+	pfree(scan);
+
+	return;
+}
+
+static void
+dummy_scan_rescan(TableScanDesc sscan, ScanKey key, bool set_params,
+				  bool allow_strat, bool allow_sync, bool allow_pagemode)
+{
+	return;
+}
+
+static bool
+dummy_scan_getnextslot(TableScanDesc sscan, ScanDirection direction,
+					   TupleTableSlot *slot)
+{
+	return true;
+}
+
+static void
+dummy_scan_set_tidrange(TableScanDesc sscan, ItemPointer mintid,
+						ItemPointer maxtid)
+{
+	return;
+}
+
+static bool
+dummy_scan_getnextslot_tidrange(TableScanDesc sscan, ScanDirection direction,
+								TupleTableSlot *slot)
+{
+	return true;
+}
+
+static Size
+dummy_parallelscan_estimate(Relation rel)
+{
+	return 0;
+}
+
+static Size
+dummy_parallelscan_initialize(Relation rel, ParallelTableScanDesc pscan)
+{
+	return 0;
+}
+
+static void
+dummy_parallelscan_reinitialize(Relation rel, ParallelTableScanDesc pscan)
+{
+	return;
+}
+
+static IndexFetchTableData *
+dummy_index_fetch_begin(Relation rel)
+{
+	return NULL;
+}
+
+static void
+dummy_index_fetch_reset(IndexFetchTableData *scan)
+{
+	return;
+}
+
+static void
+dummy_index_fetch_end(IndexFetchTableData *scan)
+{
+	return;
+}
+
+static bool
+dummy_index_fetch_tuple(struct IndexFetchTableData *scan, ItemPointer tid,
+						Snapshot snapshot, TupleTableSlot *slot,
+						bool *call_again, bool *all_dead)
+{
+	return true;
+}
+
+static void
+dummy_tuple_insert(Relation relation, TupleTableSlot *slot, CommandId cid,
+				   int options, BulkInsertStateData *bistate)
+{
+	DummyTableOptions *relopts;
+
+	relopts = (DummyTableOptions *) relation->rd_options;
+
+	elog(NOTICE, "option_int=%d, option_real=%f, option_bool=%d, option_enum=%d",
+		 relopts->option_int, relopts->option_real, relopts->option_bool, relopts->option_enum);
+
+	return;
+}
+
+static void
+dummy_tuple_insert_speculative(Relation relation, TupleTableSlot *slot,
+							   CommandId cid, int options,
+							   BulkInsertStateData *bistate, uint32 specToken)
+{
+	return;
+}
+
+static void
+dummy_tuple_complete_speculative(Relation relation, TupleTableSlot *slot,
+								 uint32 specToken, bool succeeded)
+{
+	return;
+}
+
+static void
+dummy_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples,
+				   CommandId cid, int options, BulkInsertStateData *bistate)
+{
+	return;
+}
+
+static TM_Result
+dummy_tuple_delete(Relation relation, ItemPointer tid, CommandId cid,
+				   Snapshot snapshot, Snapshot crosscheck, bool wait,
+				   TM_FailureData *tmfd, bool changingPart)
+{
+	return TM_Ok;
+}
+
+static TM_Result
+dummy_tuple_update(Relation relation, ItemPointer otid, TupleTableSlot *slot,
+				   CommandId cid, Snapshot snapshot, Snapshot crosscheck,
+				   bool wait, TM_FailureData *tmfd,
+				   LockTupleMode *lockmode, TU_UpdateIndexes *update_indexes)
+{
+	return TM_Ok;
+}
+
+static TM_Result
+dummy_tuple_lock(Relation relation, ItemPointer tid, Snapshot snapshot,
+				 TupleTableSlot *slot, CommandId cid, LockTupleMode mode,
+				 LockWaitPolicy wait_policy, uint8 flags,
+				 TM_FailureData *tmfd)
+{
+	return TM_Ok;
+}
+
+static bool
+dummy_fetch_row_version(Relation relation, ItemPointer tid,
+						Snapshot snapshot, TupleTableSlot *slot)
+{
+	return false;
+}
+
+static void
+dummy_get_latest_tid(TableScanDesc sscan, ItemPointer tid)
+{
+	return;
+}
+
+static bool
+dummy_tuple_tid_valid(TableScanDesc scan, ItemPointer tid)
+{
+	return false;
+}
+
+static bool
+dummy_tuple_satisfies_snapshot(Relation rel, TupleTableSlot *slot,
+							   Snapshot snapshot)
+{
+	return false;
+}
+
+static TransactionId
+dummy_index_delete_tuples(Relation rel, TM_IndexDeleteOp *delstate)
+{
+	return InvalidTransactionId;
+}
+
+static void
+dummy_relation_set_new_filelocator(Relation rel,
+								   const RelFileLocator *newrlocator,
+								   char persistence,
+								   TransactionId *freezeXid,
+								   MultiXactId *minmulti)
+{
+	return;
+}
+
+static void
+dummy_relation_nontransactional_truncate(Relation rel)
+{
+	return;
+}
+
+static void
+dummy_relation_copy_data(Relation rel, const RelFileLocator *newrlocator)
+{
+	return;
+}
+
+static void
+dummy_relation_copy_for_cluster(Relation OldHeap, Relation NewHeap,
+								Relation OldIndex, bool use_sort,
+								TransactionId OldestXmin,
+								TransactionId *xid_cutoff,
+								MultiXactId *multi_cutoff,
+								double *num_tuples,
+								double *tups_vacuumed,
+								double *tups_recently_dead)
+{
+	return;
+}
+
+static void
+dummy_relation_vacuum(Relation rel, struct VacuumParams *params,
+					  BufferAccessStrategy bstrategy)
+{
+	return;
+}
+
+static bool
+dummy_scan_analyze_next_block(TableScanDesc scan, ReadStream *stream)
+{
+	return false;
+}
+
+static bool
+dummy_scan_analyze_next_tuple(TableScanDesc scan, TransactionId OldestXmin,
+							  double *liverows, double *deadrows,
+							  TupleTableSlot *slot)
+{
+	return false;
+}
+
+static double
+dummy_index_build_range_scan(Relation heapRelation,
+							 Relation indexRelation,
+							 struct IndexInfo *indexInfo,
+							 bool allow_sync,
+							 bool anyvisible,
+							 bool progress,
+							 BlockNumber start_blockno,
+							 BlockNumber numblocks,
+							 IndexBuildCallback callback,
+							 void *callback_state,
+							 TableScanDesc scan)
+{
+	return 0;
+}
+
+static void
+dummy_index_validate_scan(Relation heapRelation,
+						  Relation indexRelation,
+						  struct IndexInfo *indexInfo,
+						  Snapshot snapshot,
+						  struct ValidateIndexState *state)
+{
+	return;
+}
+
+static uint64
+dummy_relation_size(Relation rel, ForkNumber forkNumber)
+{
+	return 0;
+}
+
+static bool
+dummy_relation_needs_toast_table(Relation rel)
+{
+	return false;
+}
+
+static Oid
+dummy_relation_toast_am(Relation rel)
+{
+	return InvalidOid;
+}
+
+static void
+dummy_relation_fetch_toast_slice(Relation toastrel, Oid valueid, int32 attrsize,
+								 int32 sliceoffset, int32 slicelength,
+								 struct varlena *result)
+{
+	return;
+}
+
+static void
+dummy_relation_estimate_size(Relation rel, int32 *attr_widths,
+							 BlockNumber *pages, double *tuples,
+							 double *allvisfrac)
+{
+	return;
+}
+
+static bool
+dummy_scan_bitmap_next_tuple(TableScanDesc scan, TupleTableSlot *slot,
+							 bool *recheck, uint64 *lossy_pages,
+							 uint64 *exact_pages)
+{
+	return false;
+}
+
+static bool
+dummy_scan_sample_next_block(TableScanDesc scan, struct SampleScanState *scanstate)
+{
+	return false;
+}
+
+static bool
+dummy_scan_sample_next_tuple(TableScanDesc scan, struct SampleScanState *scanstate,
+							 TupleTableSlot *slot)
+{
+	return false;
+}
+
+static bytea *
+dummy_relation_options(char relkind, Datum reloptions, bool validate)
+{
+	return (bytea *) build_reloptions(reloptions, validate,
+									  dt_relopt_kind,
+									  sizeof(DummyTableOptions),
+									  dt_relopt_tab, lengthof(dt_relopt_tab));
+}
+
+/*
+ * Validation function for string relation options.
+ */
+static void
+validate_string_option(const char *value)
+{
+	ereport(NOTICE,
+			(errmsg("new option value for string parameter %s",
+					value ? value : "NULL")));
+}
+
+/*
+ * This function creates a full set of relation option types,
+ * with various patterns.
+ */
+static void
+create_reloptions_table(void)
+{
+	dt_relopt_kind = add_reloption_kind();
+
+	add_int_reloption(dt_relopt_kind, "option_int",
+					  "Integer option for dummy_table_am",
+					  10, -10, 100, AccessExclusiveLock);
+	dt_relopt_tab[0].optname = "option_int";
+	dt_relopt_tab[0].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[0].offset = offsetof(DummyTableOptions, option_int);
+
+	add_real_reloption(dt_relopt_kind, "option_real",
+					   "Real option for dummy_table_am",
+					   3.1415, -10, 100, AccessExclusiveLock);
+	dt_relopt_tab[1].optname = "option_real";
+	dt_relopt_tab[1].opttype = RELOPT_TYPE_REAL;
+	dt_relopt_tab[1].offset = offsetof(DummyTableOptions, option_real);
+
+	add_bool_reloption(dt_relopt_kind, "option_bool",
+					   "Boolean option for dummy_table_am",
+					   true, AccessExclusiveLock);
+	dt_relopt_tab[2].optname = "option_bool";
+	dt_relopt_tab[2].opttype = RELOPT_TYPE_BOOL;
+	dt_relopt_tab[2].offset = offsetof(DummyTableOptions, option_bool);
+
+	add_enum_reloption(dt_relopt_kind, "option_enum",
+					   "Enum option for dummy_table_am",
+					   dummyAmEnumValues,
+					   DUMMY_AM_ENUM_ONE,
+					   "Valid values are \"one\" and \"two\".",
+					   AccessExclusiveLock);
+	dt_relopt_tab[3].optname = "option_enum";
+	dt_relopt_tab[3].opttype = RELOPT_TYPE_ENUM;
+	dt_relopt_tab[3].offset = offsetof(DummyTableOptions, option_enum);
+
+	add_string_reloption(dt_relopt_kind, "option_string_val",
+						 "String option for dummy_table_am with non-NULL default",
+						 "DefaultValue", &validate_string_option,
+						 AccessExclusiveLock);
+	dt_relopt_tab[4].optname = "option_string_val";
+	dt_relopt_tab[4].opttype = RELOPT_TYPE_STRING;
+	dt_relopt_tab[4].offset = offsetof(DummyTableOptions,
+									   option_string_val_offset);
+
+	/*
+	 * String option for dummy_table_am with NULL default, and without
+	 * description.
+	 */
+	add_string_reloption(dt_relopt_kind, "option_string_null",
+						 NULL,	/* description */
+						 NULL, &validate_string_option,
+						 AccessExclusiveLock);
+	dt_relopt_tab[5].optname = "option_string_null";
+	dt_relopt_tab[5].opttype = RELOPT_TYPE_STRING;
+	dt_relopt_tab[5].offset = offsetof(DummyTableOptions,
+									   option_string_null_offset);
+
+	/*
+	 * fillfactor will be used to check reloption conversion when changing
+	 * table access method between heap AM and dummy_table_am.
+	 */
+	add_int_reloption(dt_relopt_kind, "fillfactor",
+					  "Fillfactor option for dummy_table_am",
+					  10, 0, 90, AccessExclusiveLock);
+	dt_relopt_tab[6].optname = "fillfactor";
+	dt_relopt_tab[6].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[6].offset = offsetof(DummyTableOptions, fillfactor);
+}
+
+
+/*
+ * Table Access Method API
+ */
+static const TableAmRoutine dummy_table_am_methods = {
+	.type = T_TableAmRoutine,
+
+	.slot_callbacks = dummy_slot_callbacks,
+	.scan_begin = dummy_scan_begin,
+	.scan_end = dummy_scan_end,
+	.scan_rescan = dummy_scan_rescan,
+	.scan_getnextslot = dummy_scan_getnextslot,
+
+	.scan_set_tidrange = dummy_scan_set_tidrange,
+	.scan_getnextslot_tidrange = dummy_scan_getnextslot_tidrange,
+
+	.parallelscan_estimate = dummy_parallelscan_estimate,
+	.parallelscan_initialize = dummy_parallelscan_initialize,
+	.parallelscan_reinitialize = dummy_parallelscan_reinitialize,
+
+	.index_fetch_begin = dummy_index_fetch_begin,
+	.index_fetch_reset = dummy_index_fetch_reset,
+	.index_fetch_end = dummy_index_fetch_end,
+	.index_fetch_tuple = dummy_index_fetch_tuple,
+
+	.tuple_insert = dummy_tuple_insert,
+	.tuple_insert_speculative = dummy_tuple_insert_speculative,
+	.tuple_complete_speculative = dummy_tuple_complete_speculative,
+	.multi_insert = dummy_multi_insert,
+	.tuple_delete = dummy_tuple_delete,
+	.tuple_update = dummy_tuple_update,
+	.tuple_lock = dummy_tuple_lock,
+
+	.tuple_fetch_row_version = dummy_fetch_row_version,
+	.tuple_get_latest_tid = dummy_get_latest_tid,
+	.tuple_tid_valid = dummy_tuple_tid_valid,
+	.tuple_satisfies_snapshot = dummy_tuple_satisfies_snapshot,
+	.index_delete_tuples = dummy_index_delete_tuples,
+
+	.relation_set_new_filelocator = dummy_relation_set_new_filelocator,
+	.relation_nontransactional_truncate = dummy_relation_nontransactional_truncate,
+	.relation_copy_data = dummy_relation_copy_data,
+	.relation_copy_for_cluster = dummy_relation_copy_for_cluster,
+	.relation_vacuum = dummy_relation_vacuum,
+	.scan_analyze_next_block = dummy_scan_analyze_next_block,
+	.scan_analyze_next_tuple = dummy_scan_analyze_next_tuple,
+	.index_build_range_scan = dummy_index_build_range_scan,
+	.index_validate_scan = dummy_index_validate_scan,
+
+	.relation_size = dummy_relation_size,
+	.relation_needs_toast_table = dummy_relation_needs_toast_table,
+	.relation_toast_am = dummy_relation_toast_am,
+	.relation_fetch_toast_slice = dummy_relation_fetch_toast_slice,
+	.relation_estimate_size = dummy_relation_estimate_size,
+	.relation_options = dummy_relation_options,
+
+	.scan_bitmap_next_tuple = dummy_scan_bitmap_next_tuple,
+	.scan_sample_next_block = dummy_scan_sample_next_block,
+	.scan_sample_next_tuple = dummy_scan_sample_next_tuple
+};
+
+PG_FUNCTION_INFO_V1(dummy_table_am_handler);
+
+Datum
+dummy_table_am_handler(PG_FUNCTION_ARGS)
+{
+	PG_RETURN_POINTER(&dummy_table_am_methods);
+}
+
+void
+_PG_init(void)
+{
+	create_reloptions_table();
+}
diff --git a/src/test/modules/dummy_table_am/dummy_table_am.control b/src/test/modules/dummy_table_am/dummy_table_am.control
new file mode 100644
index 00000000000..08f2f868d49
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am.control
@@ -0,0 +1,5 @@
+# dummy_table_am extension
+comment = 'dummy_table_am - table access method template'
+default_version = '1.0'
+module_pathname = '$libdir/dummy_table_am'
+relocatable = true
diff --git a/src/test/modules/dummy_table_am/expected/reloptions.out b/src/test/modules/dummy_table_am/expected/reloptions.out
new file mode 100644
index 00000000000..0b947500ead
--- /dev/null
+++ b/src/test/modules/dummy_table_am/expected/reloptions.out
@@ -0,0 +1,181 @@
+-- Tests for relation options
+CREATE EXTENSION dummy_table_am;
+CREATE TABLE dummy_test_tab (i int4) USING dummy_table_am;
+-- Silence validation checks for strings
+SET client_min_messages TO 'warning';
+-- Test with default values.
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+ unnest 
+--------
+(0 rows)
+
+DROP TABLE dummy_test_tab;
+-- Test with full set of options.
+-- Allow validation checks for strings
+SET client_min_messages TO 'notice';
+CREATE TABLE dummy_test_tab (i int4)
+  USING dummy_table_am WITH (
+  option_bool = false,
+  option_int = 5,
+  option_real = 3.1,
+  option_enum = 'two',
+  option_string_val = NULL,
+  option_string_null = 'val');
+NOTICE:  new option value for string parameter null
+NOTICE:  new option value for string parameter val
+-- Silence again validation checks for strings until the end of the test.
+SET client_min_messages TO 'warning';
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+         unnest         
+------------------------
+ option_bool=false
+ option_int=5
+ option_real=3.1
+ option_enum=two
+ option_string_val=null
+ option_string_null=val
+(6 rows)
+
+-- ALTER TABLE .. SET
+ALTER TABLE dummy_test_tab SET (option_int = 10);
+ALTER TABLE dummy_test_tab SET (option_bool = true);
+ALTER TABLE dummy_test_tab SET (option_real = 3.2);
+ALTER TABLE dummy_test_tab SET (option_string_val = 'val2');
+ALTER TABLE dummy_test_tab SET (option_string_null = NULL);
+ALTER TABLE dummy_test_tab SET (option_enum = 'one');
+ALTER TABLE dummy_test_tab SET (option_enum = 'three');
+ERROR:  invalid value for enum option "option_enum": three
+DETAIL:  Valid values are "one" and "two".
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+         unnest          
+-------------------------
+ option_int=10
+ option_bool=true
+ option_real=3.2
+ option_string_val=val2
+ option_string_null=null
+ option_enum=one
+(6 rows)
+
+-- ALTER TABLE .. RESET
+ALTER TABLE dummy_test_tab RESET (option_int);
+ALTER TABLE dummy_test_tab RESET (option_bool);
+ALTER TABLE dummy_test_tab RESET (option_real);
+ALTER TABLE dummy_test_tab RESET (option_enum);
+ALTER TABLE dummy_test_tab RESET (option_string_val);
+ALTER TABLE dummy_test_tab RESET (option_string_null);
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+ unnest 
+--------
+(0 rows)
+
+-- Cross-type checks for reloption values
+-- Integer
+ALTER TABLE dummy_test_tab SET (option_int = 3.3); -- ok
+ALTER TABLE dummy_test_tab SET (option_int = true); -- error
+ERROR:  invalid value for integer option "option_int": true
+ALTER TABLE dummy_test_tab SET (option_int = 'val3'); -- error
+ERROR:  invalid value for integer option "option_int": val3
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+     unnest     
+----------------
+ option_int=3.3
+(1 row)
+
+ALTER TABLE dummy_test_tab RESET (option_int);
+-- Boolean
+ALTER TABLE dummy_test_tab SET (option_bool = 4); -- error
+ERROR:  invalid value for boolean option "option_bool": 4
+ALTER TABLE dummy_test_tab SET (option_bool = 1); -- ok, as true
+ALTER TABLE dummy_test_tab SET (option_bool = 3.4); -- error
+ERROR:  invalid value for boolean option "option_bool": 3.4
+ALTER TABLE dummy_test_tab SET (option_bool = 'val4'); -- error
+ERROR:  invalid value for boolean option "option_bool": val4
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+    unnest     
+---------------
+ option_bool=1
+(1 row)
+
+ALTER TABLE dummy_test_tab RESET (option_bool);
+-- Float
+ALTER TABLE dummy_test_tab SET (option_real = 4); -- ok
+ALTER TABLE dummy_test_tab SET (option_real = true); -- error
+ERROR:  invalid value for floating point option "option_real": true
+ALTER TABLE dummy_test_tab SET (option_real = 'val5'); -- error
+ERROR:  invalid value for floating point option "option_real": val5
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+    unnest     
+---------------
+ option_real=4
+(1 row)
+
+ALTER TABLE dummy_test_tab RESET (option_real);
+-- Enum
+ALTER TABLE dummy_test_tab SET (option_enum = 'one'); -- ok
+ALTER TABLE dummy_test_tab SET (option_enum = 0); -- error
+ERROR:  invalid value for enum option "option_enum": 0
+DETAIL:  Valid values are "one" and "two".
+ALTER TABLE dummy_test_tab SET (option_enum = true); -- error
+ERROR:  invalid value for enum option "option_enum": true
+DETAIL:  Valid values are "one" and "two".
+ALTER TABLE dummy_test_tab SET (option_enum = 'three'); -- error
+ERROR:  invalid value for enum option "option_enum": three
+DETAIL:  Valid values are "one" and "two".
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+     unnest      
+-----------------
+ option_enum=one
+(1 row)
+
+ALTER TABLE dummy_test_tab RESET (option_enum);
+-- String
+ALTER TABLE dummy_test_tab SET (option_string_val = 4); -- ok
+ALTER TABLE dummy_test_tab SET (option_string_val = 3.5); -- ok
+ALTER TABLE dummy_test_tab SET (option_string_val = true); -- ok, as "true"
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+         unnest         
+------------------------
+ option_string_val=true
+(1 row)
+
+ALTER TABLE dummy_test_tab RESET (option_string_val);
+DROP TABLE dummy_test_tab;
+-- ALTER TABLE SET ACCESS METHOD OPTIONS
+CREATE TABLE heap_tab (i INT4) WITH (fillfactor=100, toast_tuple_target=1000);
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'heap_tab';
+         unnest          
+-------------------------
+ fillfactor=100
+ toast_tuple_target=1000
+(2 rows)
+
+-- error: fillfactor is out of bounds: maximum value from the new table am is 90
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am;
+ERROR:  value 100 out of bounds for option "fillfactor"
+DETAIL:  Valid values are between "0" and "90".
+-- error: toast_tuple_target does not exist in the new table AM
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (SET fillfactor '50');
+ERROR:  unrecognized parameter "toast_tuple_target"
+-- error: adding is not possible when the parameter is already defined in source reloptions
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (ADD fillfactor '50');
+ERROR:  option "fillfactor" provided more than once
+-- error: the specified option we want to drop does not exist
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (DROP does_not_exist);
+ERROR:  option "does_not_exist" not found
+-- error: adding unrecognized parameter
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (SET fillfactor '50', DROP toast_tuple_target, ADD unrecognized 'foo');
+ERROR:  unrecognized parameter "unrecognized"
+-- ok
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (DROP fillfactor, DROP toast_tuple_target, option_int '1', option_bool 'true', option_real '0.001', option_enum 'one', option_string_val 'hello');
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'heap_tab';
+         unnest          
+-------------------------
+ option_int=1
+ option_bool=true
+ option_real=0.001
+ option_enum=one
+ option_string_val=hello
+(5 rows)
+
+DROP TABLE heap_tab;
diff --git a/src/test/modules/dummy_table_am/meson.build b/src/test/modules/dummy_table_am/meson.build
new file mode 100644
index 00000000000..6b197b15ffa
--- /dev/null
+++ b/src/test/modules/dummy_table_am/meson.build
@@ -0,0 +1,33 @@
+# Copyright (c) 2022-2025, PostgreSQL Global Development Group
+
+dummy_table_am_sources = files(
+  'dummy_table_am.c',
+)
+
+if host_system == 'windows'
+  dummy_table_am_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'dummy_table_am',
+    '--FILEDESC', 'dummy_table_am - table access method template',])
+endif
+
+dummy_table_am = shared_module('dummy_table_am',
+  dummy_table_am_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += dummy_table_am
+
+test_install_data += files(
+  'dummy_table_am.control',
+  'dummy_table_am--1.0.sql',
+)
+
+tests += {
+  'name': 'dummy_table_am',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'reloptions',
+    ],
+  },
+}
diff --git a/src/test/modules/dummy_table_am/sql/reloptions.sql b/src/test/modules/dummy_table_am/sql/reloptions.sql
new file mode 100644
index 00000000000..47fb4862c6c
--- /dev/null
+++ b/src/test/modules/dummy_table_am/sql/reloptions.sql
@@ -0,0 +1,99 @@
+-- Tests for relation options
+CREATE EXTENSION dummy_table_am;
+
+CREATE TABLE dummy_test_tab (i int4) USING dummy_table_am;
+
+-- Silence validation checks for strings
+SET client_min_messages TO 'warning';
+
+-- Test with default values.
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+DROP TABLE dummy_test_tab;
+
+-- Test with full set of options.
+-- Allow validation checks for strings
+SET client_min_messages TO 'notice';
+CREATE TABLE dummy_test_tab (i int4)
+  USING dummy_table_am WITH (
+  option_bool = false,
+  option_int = 5,
+  option_real = 3.1,
+  option_enum = 'two',
+  option_string_val = NULL,
+  option_string_null = 'val');
+-- Silence again validation checks for strings until the end of the test.
+SET client_min_messages TO 'warning';
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+
+-- ALTER TABLE .. SET
+ALTER TABLE dummy_test_tab SET (option_int = 10);
+ALTER TABLE dummy_test_tab SET (option_bool = true);
+ALTER TABLE dummy_test_tab SET (option_real = 3.2);
+ALTER TABLE dummy_test_tab SET (option_string_val = 'val2');
+ALTER TABLE dummy_test_tab SET (option_string_null = NULL);
+ALTER TABLE dummy_test_tab SET (option_enum = 'one');
+ALTER TABLE dummy_test_tab SET (option_enum = 'three');
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+
+-- ALTER TABLE .. RESET
+ALTER TABLE dummy_test_tab RESET (option_int);
+ALTER TABLE dummy_test_tab RESET (option_bool);
+ALTER TABLE dummy_test_tab RESET (option_real);
+ALTER TABLE dummy_test_tab RESET (option_enum);
+ALTER TABLE dummy_test_tab RESET (option_string_val);
+ALTER TABLE dummy_test_tab RESET (option_string_null);
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+
+-- Cross-type checks for reloption values
+-- Integer
+ALTER TABLE dummy_test_tab SET (option_int = 3.3); -- ok
+ALTER TABLE dummy_test_tab SET (option_int = true); -- error
+ALTER TABLE dummy_test_tab SET (option_int = 'val3'); -- error
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+ALTER TABLE dummy_test_tab RESET (option_int);
+-- Boolean
+ALTER TABLE dummy_test_tab SET (option_bool = 4); -- error
+ALTER TABLE dummy_test_tab SET (option_bool = 1); -- ok, as true
+ALTER TABLE dummy_test_tab SET (option_bool = 3.4); -- error
+ALTER TABLE dummy_test_tab SET (option_bool = 'val4'); -- error
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+ALTER TABLE dummy_test_tab RESET (option_bool);
+-- Float
+ALTER TABLE dummy_test_tab SET (option_real = 4); -- ok
+ALTER TABLE dummy_test_tab SET (option_real = true); -- error
+ALTER TABLE dummy_test_tab SET (option_real = 'val5'); -- error
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+ALTER TABLE dummy_test_tab RESET (option_real);
+-- Enum
+ALTER TABLE dummy_test_tab SET (option_enum = 'one'); -- ok
+ALTER TABLE dummy_test_tab SET (option_enum = 0); -- error
+ALTER TABLE dummy_test_tab SET (option_enum = true); -- error
+ALTER TABLE dummy_test_tab SET (option_enum = 'three'); -- error
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+ALTER TABLE dummy_test_tab RESET (option_enum);
+-- String
+ALTER TABLE dummy_test_tab SET (option_string_val = 4); -- ok
+ALTER TABLE dummy_test_tab SET (option_string_val = 3.5); -- ok
+ALTER TABLE dummy_test_tab SET (option_string_val = true); -- ok, as "true"
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+ALTER TABLE dummy_test_tab RESET (option_string_val);
+
+DROP TABLE dummy_test_tab;
+
+-- ALTER TABLE SET ACCESS METHOD OPTIONS
+CREATE TABLE heap_tab (i INT4) WITH (fillfactor=100, toast_tuple_target=1000);
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'heap_tab';
+-- error: fillfactor is out of bounds: maximum value from the new table am is 90
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am;
+-- error: toast_tuple_target does not exist in the new table AM
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (SET fillfactor '50');
+-- error: adding is not possible when the parameter is already defined in source reloptions
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (ADD fillfactor '50');
+-- error: the specified option we want to drop does not exist
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (DROP does_not_exist);
+-- error: adding unrecognized parameter
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (SET fillfactor '50', DROP toast_tuple_target, ADD unrecognized 'foo');
+-- ok
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (DROP fillfactor, DROP toast_tuple_target, option_int '1', option_bool 'true', option_real '0.001', option_enum 'one', option_string_val 'hello');
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'heap_tab';
+DROP TABLE heap_tab;
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 2b057451473..28398254df7 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -4,6 +4,7 @@ subdir('brin')
 subdir('commit_ts')
 subdir('delay_execution')
 subdir('dummy_index_am')
+subdir('dummy_table_am')
 subdir('dummy_seclabel')
 subdir('gin')
 subdir('injection_points')
-- 
2.39.5

^ permalink  raw  reply  [nested|flat] 25+ messages in thread

* Re: Allow table AMs to define their own reloptions
@ 2025-05-26 11:06  Julien Tachoires <julien@tachoires.me>
  parent: Julien Tachoires <julien@tachoires.me>
  0 siblings, 1 reply; 25+ messages in thread

From: Julien Tachoires @ 2025-05-26 11:06 UTC (permalink / raw)
  To: pgsql-hackers

On Sat, Mar 29, 2025 at 08:46:01AM +0100, Julien Tachoires wrote:
> On Sun, Mar 02, 2025 at 02:23:54PM +0100, Julien Tachoires wrote:
> > On Sun, Mar 02, 2025 at 09:56:41AM +0100, Julien Tachoires wrote:
> > > With the help of the new TAM routine 'relation_options', table access 
> > > methods can with this patch define their own reloptions 
> > > parser/validator.
> > > 
> > > These reloptions can be set via the following commands:
> > > 1. CREATE TABLE ... USING table_am
> > >        WITH (option1='value1', option2='value2');
> > > 2. ALTER TABLE ...
> > >        SET (option1 'value1', option2 'value2');
> > > 3. ALTER TABLE ... SET ACCESS METHOD table_am
> > >        OPTIONS (option1 'value1', option2 'value2');
> > > 
> > > When changing table's access method, the settings inherited from the 
> > > former TAM can be dropped (if not supported by the new TAM) via: DROP 
> > > option, or, updated via: SET option 'value'.
> > > 
> > > Currently, tables using different TAMs than heap are able to use heap's 
> > > reloptions (fillfactor, toast_tuple_target, etc...). With this patch 
> > > applied, this is not the case anymore: if the TAM needs to have access 
> > > to similar settings to heap ones, they have to explicitly define them.
> > > 
> > > The 2nd patch file includes a new test module 'dummy_table_am' which 
> > > implements a dummy table access method utilized to exercise TAM 
> > > reloptions. This test module is strongly based on what we already have 
> > > in 'dummy_index_am'. 'dummy_table_am' provides a complete example of TAM 
> > > reloptions definition.
> > > 
> > > This work is directly derived from SadhuPrasad's patch here [2]. Others 
> > > attempts were posted here [1] and here [3].
> > > 
> > > [1] https://www.postgresql.org/message-id/flat/429fb58fa3218221bb17c7bf9e70e1aa6cfc6b5d.camel%40j-davis....
> > > [2] https://www.postgresql.org/message-id/flat/CAFF0-CG4KZHdtYHMsonWiXNzj16gWZpduXAn8yF7pDDub+GQMg@mail....
> > > [3] https://www.postgresql.org/message-id/flat/AMUA1wBBBxfc3tKRLLdU64rb.1.1683276279979.Hmail.wuhao%40ha...
> > 
> > Please find a new version including minor fixes: 'TAM' terms are
> > replaced by 'table AM'
> 
> Please find a new rebased version. 

New rebased version.

-- 
Julien Tachoires

Attachments:

  [text/x-diff] v4-0001-Allow-table-AMs-to-define-their-own-reloptions.patch (25.0K, ../../20250526110619.oepaf5dbzq3hjuij@poseidon.home.virt/2-v4-0001-Allow-table-AMs-to-define-their-own-reloptions.patch)
  download | inline diff:
From e61a0ef687b19edc676e0cc70f9057ad844c192f Mon Sep 17 00:00:00 2001
From: Julien Tachoires <julien@tachoires.me>
Date: Sat, 1 Mar 2025 17:59:49 +0100
Subject: [PATCH 1/2] Allow table AMs to define their own reloptions

With the help of the new routine 'relation_options', table access
methods can now define their own reloptions.

These options can be set via the following commands:
1. CREATE TABLE ... USING table_am
       WITH (option1='value1', option2='value2');
2. ALTER TABLE ...
       SET (option1 'value1', option2 'value2');
3. ALTER TABLE ... SET ACCESS METHOD table_am
       OPTIONS (option1 'value1', option2 'value2');

When changing table's access method, the settings from the former
table AM can be dropped (if not supported by the new table AM) via:
DROP option, or, updated via: SET option 'value'.

Before this commit, tables using different table AMs than heap were
able to use heap's reloptions (fillfactor, toast_tuple_target,
etc...). Now, this is not the case anymore: if the table AM needs
to have access to settings similar to heap ones, they must
explicitly define them.

This work is directly derived from SadhuPrasad's patch named:
v4-0001-PATCH-V4-Per-table-storage-parameters-for-TableAM.patch
---
 doc/src/sgml/ref/alter_table.sgml        |  13 +-
 doc/src/sgml/ref/create_table.sgml       |   3 +-
 src/backend/access/common/reloptions.c   |  66 ++++++++-
 src/backend/access/heap/heapam_handler.c |   2 +
 src/backend/commands/foreigncmds.c       |   2 +-
 src/backend/commands/tablecmds.c         | 180 ++++++++++++++++++++---
 src/backend/parser/gram.y                |   9 ++
 src/backend/postmaster/autovacuum.c      |  18 ++-
 src/backend/utils/cache/relcache.c       |  11 +-
 src/include/access/reloptions.h          |   6 +-
 src/include/access/tableam.h             |  10 ++
 src/include/commands/defrem.h            |   1 +
 12 files changed, 286 insertions(+), 35 deletions(-)

diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index d63f3a621ac..e567aad4774 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -77,7 +77,7 @@ ALTER TABLE [ IF EXISTS ] <replaceable class="parameter">name</replaceable>
     CLUSTER ON <replaceable class="parameter">index_name</replaceable>
     SET WITHOUT CLUSTER
     SET WITHOUT OIDS
-    SET ACCESS METHOD { <replaceable class="parameter">new_access_method</replaceable> | DEFAULT }
+    SET ACCESS METHOD { <replaceable class="parameter">new_access_method</replaceable> | DEFAULT } [ OPTIONS ( [ ADD | SET | DROP ] <replaceable class="parameter">option</replaceable> ['<replaceable class="parameter">value</replaceable>'] [, ... ] ) ]
     SET TABLESPACE <replaceable class="parameter">new_tablespace</replaceable>
     SET { LOGGED | UNLOGGED }
     SET ( <replaceable class="parameter">storage_parameter</replaceable> [= <replaceable class="parameter">value</replaceable>] [, ... ] )
@@ -758,7 +758,7 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
    </varlistentry>
 
    <varlistentry id="sql-altertable-desc-set-access-method">
-    <term><literal>SET ACCESS METHOD</literal></term>
+    <term><literal>SET ACCESS METHOD { <replaceable class="parameter">new_access_method</replaceable> | DEFAULT } [ OPTIONS ( [ ADD | SET | DROP ] <replaceable class="parameter">option</replaceable> ['<replaceable class="parameter">value</replaceable>'] [, ... ] ) ]</literal></term>
     <listitem>
      <para>
       This form changes the access method of the table by rewriting it
@@ -776,6 +776,15 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
       causing future partitions to default to
       <varname>default_table_access_method</varname>.
      </para>
+     <para>
+      Specifying <literal>OPTIONS</literal> allows to change options for
+      the table when changing the table access method.
+      <literal>ADD</literal>, <literal>SET</literal>, and
+      <literal>DROP</literal> specify the action to be performed.
+      <literal>ADD</literal> is assumed if no operation is explicitly
+      specified.  Option names must be unique; names and values are also
+      validated using the table access method's library.
+     </para>
     </listitem>
    </varlistentry>
 
diff --git a/doc/src/sgml/ref/create_table.sgml b/doc/src/sgml/ref/create_table.sgml
index 4a41b2f5530..ad642e55d22 100644
--- a/doc/src/sgml/ref/create_table.sgml
+++ b/doc/src/sgml/ref/create_table.sgml
@@ -1552,7 +1552,8 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
     Storage parameters for
     indexes are documented in <xref linkend="sql-createindex"/>.
     The storage parameters currently
-    available for tables are listed below.  For many of these parameters, as
+    available for tables are listed below. Each table may have different set of storage
+    parameters through different access methods. For many of these parameters, as
     shown, there is an additional parameter with the same name prefixed with
     <literal>toast.</literal>, which controls the behavior of the
     table's secondary <acronym>TOAST</acronym> table, if any
diff --git a/src/backend/access/common/reloptions.c b/src/backend/access/common/reloptions.c
index 46c1dce222d..0d8ead7ce5d 100644
--- a/src/backend/access/common/reloptions.c
+++ b/src/backend/access/common/reloptions.c
@@ -25,6 +25,7 @@
 #include "access/reloptions.h"
 #include "access/spgist_private.h"
 #include "catalog/pg_type.h"
+#include "catalog/pg_am.h"
 #include "commands/defrem.h"
 #include "commands/tablespace.h"
 #include "nodes/makefuncs.h"
@@ -34,6 +35,7 @@
 #include "utils/guc.h"
 #include "utils/memutils.h"
 #include "utils/rel.h"
+#include "utils/syscache.h"
 
 /*
  * Contents of pg_class.reloptions
@@ -1388,7 +1390,7 @@ untransformRelOptions(Datum options)
  */
 bytea *
 extractRelOptions(HeapTuple tuple, TupleDesc tupdesc,
-				  amoptions_function amoptions)
+				  amoptions_function amoptions, reloptions_function reloptsfun)
 {
 	bytea	   *options;
 	bool		isnull;
@@ -1410,7 +1412,8 @@ extractRelOptions(HeapTuple tuple, TupleDesc tupdesc,
 		case RELKIND_RELATION:
 		case RELKIND_TOASTVALUE:
 		case RELKIND_MATVIEW:
-			options = heap_reloptions(classForm->relkind, datum, false);
+			options = table_reloptions(reloptsfun, InvalidOid, classForm->relkind,
+									   datum, false);
 			break;
 		case RELKIND_PARTITIONED_TABLE:
 			options = partitioned_table_reloptions(datum, false);
@@ -2040,7 +2043,8 @@ view_reloptions(Datum reloptions, bool validate)
 }
 
 /*
- * Parse options for heaps, views and toast tables.
+ * Parse options for heaps, views and toast tables. This is the implementation
+ * of relOptions for the access method heap.
  */
 bytea *
 heap_reloptions(char relkind, Datum reloptions, bool validate)
@@ -2070,6 +2074,62 @@ heap_reloptions(char relkind, Datum reloptions, bool validate)
 }
 
 
+/*
+ * Parse options for tables.
+ *
+ *	reloptsfun	Table AM's option parser function. Can be NULL if amid is
+ *				valid. In this case we load the new table AM and use its option
+ *				parser function.
+ *	amid		New table AM's Oid if any.
+ *	relkind		relation kind
+ *	reloptions	options as text[] datum
+ *	validate	error flag
+ */
+bytea *
+table_reloptions(reloptions_function reloptsfun, Oid amid, char relkind,
+				 Datum reloptions, bool validate)
+{
+	/* amid and reloptsfun are mutually exclusive */
+	Assert((!OidIsValid(amid) && (reloptsfun != NULL)) || \
+		   (OidIsValid(amid) && (reloptsfun == NULL)));
+
+	/* Parse/validate options using reloptsfun */
+	if (!OidIsValid(amid) && reloptsfun != NULL)
+	{
+		/* Assume function is strict */
+		if (!PointerIsValid(DatumGetPointer(reloptions)))
+			return NULL;
+
+		return reloptsfun(relkind, reloptions, validate);
+	}
+	/* Parse/validate options using the API of the new Table AM */
+	else if (OidIsValid(amid) && (reloptsfun == NULL))
+	{
+		const TableAmRoutine *routine;
+		HeapTuple	atuple;
+		Form_pg_am	aform;
+
+		atuple = SearchSysCache1(AMOID, ObjectIdGetDatum(amid));
+
+		if (!HeapTupleIsValid(atuple))
+			elog(ERROR, "cache lookup failed for access method %u", amid);
+
+		aform = (Form_pg_am) GETSTRUCT(atuple);
+		routine = GetTableAmRoutine(aform->amhandler);
+		ReleaseSysCache(atuple);
+
+		if (routine->relation_options != NULL)
+			return routine->relation_options(relkind, reloptions, validate);
+
+		return NULL;
+	}
+	else
+	{
+		/* Should not happen */
+		return NULL;
+	}
+}
+
 /*
  * Parse options for indexes.
  *
diff --git a/src/backend/access/heap/heapam_handler.c b/src/backend/access/heap/heapam_handler.c
index ac082fefa77..79f176bb7b8 100644
--- a/src/backend/access/heap/heapam_handler.c
+++ b/src/backend/access/heap/heapam_handler.c
@@ -24,6 +24,7 @@
 #include "access/heaptoast.h"
 #include "access/multixact.h"
 #include "access/rewriteheap.h"
+#include "access/reloptions.h"
 #include "access/syncscan.h"
 #include "access/tableam.h"
 #include "access/tsmapi.h"
@@ -2659,6 +2660,7 @@ static const TableAmRoutine heapam_methods = {
 	.index_build_range_scan = heapam_index_build_range_scan,
 	.index_validate_scan = heapam_index_validate_scan,
 
+	.relation_options = heap_reloptions,
 	.relation_size = table_block_relation_size,
 	.relation_needs_toast_table = heapam_relation_needs_toast_table,
 	.relation_toast_am = heapam_relation_toast_am,
diff --git a/src/backend/commands/foreigncmds.c b/src/backend/commands/foreigncmds.c
index c14e038d54f..9dab5dfb999 100644
--- a/src/backend/commands/foreigncmds.c
+++ b/src/backend/commands/foreigncmds.c
@@ -62,7 +62,7 @@ static void import_error_callback(void *arg);
  * processing, hence any validation should be done before this
  * conversion.
  */
-static Datum
+Datum
 optionListToArray(List *options)
 {
 	ArrayBuildState *astate = NULL;
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 54ad38247aa..64b10a58d1c 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -677,6 +677,8 @@ static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
 								const char *tablespacename, LOCKMODE lockmode);
 static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
 static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
+static void ATExecSetAccessMethodOptions(Relation rel, List *defList, AlterTableType operation,
+										 LOCKMODE lockmode, Oid newAccessMethodId);
 static void ATExecSetRelOptions(Relation rel, List *defList,
 								AlterTableType operation,
 								LOCKMODE lockmode);
@@ -926,24 +928,6 @@ DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId,
 	if (!OidIsValid(ownerId))
 		ownerId = GetUserId();
 
-	/*
-	 * Parse and validate reloptions, if any.
-	 */
-	reloptions = transformRelOptions((Datum) 0, stmt->options, NULL, validnsps,
-									 true, false);
-
-	switch (relkind)
-	{
-		case RELKIND_VIEW:
-			(void) view_reloptions(reloptions, true);
-			break;
-		case RELKIND_PARTITIONED_TABLE:
-			(void) partitioned_table_reloptions(reloptions, true);
-			break;
-		default:
-			(void) heap_reloptions(relkind, reloptions, true);
-	}
-
 	if (stmt->ofTypename)
 	{
 		AclResult	aclresult;
@@ -1046,6 +1030,29 @@ DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId,
 			accessMethodId = get_table_am_oid(default_table_access_method, false);
 	}
 
+	/*
+	 * Parse and validate reloptions, if any.
+	 */
+	reloptions = transformRelOptions((Datum) 0, stmt->options, NULL, validnsps,
+									 true, false);
+	switch (relkind)
+	{
+		case RELKIND_VIEW:
+			(void) view_reloptions(reloptions, true);
+			break;
+		case RELKIND_PARTITIONED_TABLE:
+			(void) partitioned_table_reloptions(reloptions, true);
+			break;
+		case RELKIND_RELATION:
+		case RELKIND_TOASTVALUE:
+		case RELKIND_MATVIEW:
+			(void) table_reloptions(NULL, accessMethodId, relkind, reloptions,
+									true);
+			break;
+		default:
+			(void) heap_reloptions(relkind, reloptions, true);
+	}
+
 	/*
 	 * Create the relation.  Inherited defaults and CHECK constraints are
 	 * passed in for immediate handling --- since they don't need parsing,
@@ -5527,6 +5534,9 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
 			if (rel->rd_rel->relkind == RELKIND_PARTITIONED_TABLE &&
 				tab->chgAccessMethod)
 				ATExecSetAccessMethodNoStorage(rel, tab->newAccessMethod);
+
+			ATExecSetAccessMethodOptions(rel, (List *) cmd->def, cmd->subtype,
+										 lockmode, tab->newAccessMethod);
 			break;
 		case AT_SetTableSpace:	/* SET TABLESPACE */
 
@@ -16550,6 +16560,138 @@ ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel, const char *tablespacen
 	tab->newTableSpace = tablespaceId;
 }
 
+/* SET, ADD or DROP options in ALTER TABLE SET ACCESS METHOD */
+static void
+ATExecSetAccessMethodOptions(Relation rel, List *options, AlterTableType operation,
+							 LOCKMODE lockmode, Oid newAccessMethodId)
+{
+	Oid			relid;
+	Relation	pgclass;
+	HeapTuple	tuple;
+	HeapTuple	newtuple;
+	Datum		datum;
+	bool		isnull;
+	Datum		newOptions;
+	Datum		repl_val[Natts_pg_class];
+	bool		repl_null[Natts_pg_class];
+	bool		repl_repl[Natts_pg_class];
+	List	   *resultOptions;
+	ListCell   *optcell;
+
+	pgclass = table_open(RelationRelationId, RowExclusiveLock);
+
+	/* Fetch heap tuple */
+	relid = RelationGetRelid(rel);
+	tuple = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
+	if (!HeapTupleIsValid(tuple))
+		elog(ERROR, "cache lookup failed for relation %u", relid);
+
+	/* Get the old reloptions */
+	datum = SysCacheGetAttr(RELOID, tuple, Anum_pg_class_reloptions, &isnull);
+
+	if (isnull)
+		datum = PointerGetDatum(NULL);
+
+	resultOptions = untransformRelOptions(datum);
+
+	foreach(optcell, options)
+	{
+		DefElem    *od = lfirst(optcell);
+		ListCell   *cell;
+
+		/* Search in existing options */
+		foreach(cell, resultOptions)
+		{
+			DefElem    *def = lfirst(cell);
+
+			if (strcmp(def->defname, od->defname) == 0)
+				break;
+		}
+
+		/*
+		 * It is possible to perform multiple SET/DROP actions on the same
+		 * option.  The standard permits this, as long as the options to be
+		 * added are unique.  Note that an unspecified action is taken to be
+		 * ADD.
+		 */
+		switch (od->defaction)
+		{
+			case DEFELEM_DROP:
+				if (!cell)
+					ereport(ERROR,
+							(errcode(ERRCODE_UNDEFINED_OBJECT),
+							 errmsg("option \"%s\" not found",
+									od->defname)));
+				resultOptions = list_delete_cell(resultOptions, cell);
+				break;
+
+			case DEFELEM_SET:
+				if (!cell)
+					ereport(ERROR,
+							(errcode(ERRCODE_UNDEFINED_OBJECT),
+							 errmsg("option \"%s\" not found",
+									od->defname)));
+				lfirst(cell) = od;
+				break;
+
+			case DEFELEM_ADD:
+			case DEFELEM_UNSPEC:
+				if (cell)
+					ereport(ERROR,
+							(errcode(ERRCODE_DUPLICATE_OBJECT),
+							 errmsg("option \"%s\" provided more than once",
+									od->defname)));
+				resultOptions = lappend(resultOptions, od);
+				break;
+
+			default:
+				elog(ERROR, "unrecognized action %d on option \"%s\"",
+					 (int) od->defaction, od->defname);
+				break;
+		}
+	}
+
+	newOptions = optionListToArray(resultOptions);
+
+	/*
+	 * If the new table access method was not explicitly defined, then use the
+	 * default one.
+	 */
+	if (!OidIsValid(newAccessMethodId))
+		newAccessMethodId = get_table_am_oid(default_table_access_method, false);
+
+	/* Validate new options via the new Table Access Method API */
+	(void) table_reloptions(NULL, newAccessMethodId, rel->rd_rel->relkind,
+							newOptions, true);
+
+	/* Initialize buffers for new tuple values */
+	memset(repl_val, 0, sizeof(repl_val));
+	memset(repl_null, false, sizeof(repl_null));
+	memset(repl_repl, false, sizeof(repl_repl));
+
+	if (newOptions != (Datum) 0)
+		repl_val[Anum_pg_class_reloptions - 1] = newOptions;
+	else
+		repl_null[Anum_pg_class_reloptions - 1] = true;
+
+	repl_repl[Anum_pg_class_reloptions - 1] = true;
+
+	/* Everything looks good - update the tuple */
+	newtuple = heap_modify_tuple(tuple, RelationGetDescr(pgclass),
+								 repl_val, repl_null, repl_repl);
+
+	CatalogTupleUpdate(pgclass, &newtuple->t_self, newtuple);
+
+	InvokeObjectPostAlterHook(RelationRelationId, RelationGetRelid(rel),
+							  InvalidOid);
+
+	ReleaseSysCache(tuple);
+
+	table_close(pgclass, RowExclusiveLock);
+
+	heap_freetuple(newtuple);
+}
+
 /*
  * Set, reset, or replace reloptions.
  */
@@ -16607,7 +16749,7 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
 	{
 		case RELKIND_RELATION:
 		case RELKIND_MATVIEW:
-			(void) heap_reloptions(rel->rd_rel->relkind, newOptions, true);
+			rel->rd_tableam->relation_options(rel->rd_rel->relkind, newOptions, true);
 			break;
 		case RELKIND_PARTITIONED_TABLE:
 			(void) partitioned_table_reloptions(newOptions, true);
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index 0b5652071d1..a96c9eb15f2 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -2924,6 +2924,15 @@ alter_table_cmd:
 					n->name = $4;
 					$$ = (Node *) n;
 				}
+			/* ALTER TABLE <name> SET ACCESS METHOD <amname> [OPTIONS]*/
+			| SET ACCESS METHOD name alter_generic_options
+				{
+					AlterTableCmd *n = makeNode(AlterTableCmd);
+					n->subtype = AT_SetAccessMethod;
+					n->name = $4;
+					n->def = (Node *) $5;
+					$$ = (Node *)n;
+				}
 			/* ALTER TABLE <name> SET TABLESPACE <tablespacename> */
 			| SET TABLESPACE name
 				{
diff --git a/src/backend/postmaster/autovacuum.c b/src/backend/postmaster/autovacuum.c
index 981be42e3af..202fddf2de9 100644
--- a/src/backend/postmaster/autovacuum.c
+++ b/src/backend/postmaster/autovacuum.c
@@ -332,6 +332,7 @@ static void FreeWorkerInfo(int code, Datum arg);
 
 static autovac_table *table_recheck_autovac(Oid relid, HTAB *table_toast_map,
 											TupleDesc pg_class_desc,
+											reloptions_function reloptions,
 											int effective_multixact_freeze_max_age);
 static void recheck_relation_needs_vacanalyze(Oid relid, AutoVacOpts *avopts,
 											  Form_pg_class classForm,
@@ -346,7 +347,7 @@ static void relation_needs_vacanalyze(Oid relid, AutoVacOpts *relopts,
 static void autovacuum_do_vac_analyze(autovac_table *tab,
 									  BufferAccessStrategy bstrategy);
 static AutoVacOpts *extract_autovac_opts(HeapTuple tup,
-										 TupleDesc pg_class_desc);
+										 TupleDesc pg_class_desc, reloptions_function reloptions);
 static void perform_work_item(AutoVacuumWorkItem *workitem);
 static void autovac_report_activity(autovac_table *tab);
 static void autovac_report_workitem(AutoVacuumWorkItem *workitem,
@@ -2033,7 +2034,8 @@ do_autovacuum(void)
 		}
 
 		/* Fetch reloptions and the pgstat entry for this table */
-		relopts = extract_autovac_opts(tuple, pg_class_desc);
+		relopts = extract_autovac_opts(tuple, pg_class_desc,
+									   classRel->rd_tableam->relation_options);
 		tabentry = pgstat_fetch_stat_tabentry_ext(classForm->relisshared,
 												  relid);
 
@@ -2113,7 +2115,8 @@ do_autovacuum(void)
 		 * fetch reloptions -- if this toast table does not have them, try the
 		 * main rel
 		 */
-		relopts = extract_autovac_opts(tuple, pg_class_desc);
+		relopts = extract_autovac_opts(tuple, pg_class_desc,
+									   classRel->rd_tableam->relation_options);
 		if (relopts)
 			free_relopts = true;
 		else
@@ -2379,6 +2382,7 @@ do_autovacuum(void)
 		 */
 		MemoryContextSwitchTo(AutovacMemCxt);
 		tab = table_recheck_autovac(relid, table_toast_map, pg_class_desc,
+									classRel->rd_tableam->relation_options,
 									effective_multixact_freeze_max_age);
 		if (tab == NULL)
 		{
@@ -2706,7 +2710,8 @@ deleted2:
  * be a risk; fortunately, it doesn't.
  */
 static AutoVacOpts *
-extract_autovac_opts(HeapTuple tup, TupleDesc pg_class_desc)
+extract_autovac_opts(HeapTuple tup, TupleDesc pg_class_desc,
+					 reloptions_function reloptions)
 {
 	bytea	   *relopts;
 	AutoVacOpts *av;
@@ -2715,7 +2720,7 @@ extract_autovac_opts(HeapTuple tup, TupleDesc pg_class_desc)
 		   ((Form_pg_class) GETSTRUCT(tup))->relkind == RELKIND_MATVIEW ||
 		   ((Form_pg_class) GETSTRUCT(tup))->relkind == RELKIND_TOASTVALUE);
 
-	relopts = extractRelOptions(tup, pg_class_desc, NULL);
+	relopts = extractRelOptions(tup, pg_class_desc, NULL, reloptions);
 	if (relopts == NULL)
 		return NULL;
 
@@ -2738,6 +2743,7 @@ extract_autovac_opts(HeapTuple tup, TupleDesc pg_class_desc)
 static autovac_table *
 table_recheck_autovac(Oid relid, HTAB *table_toast_map,
 					  TupleDesc pg_class_desc,
+					  reloptions_function reloptions,
 					  int effective_multixact_freeze_max_age)
 {
 	Form_pg_class classForm;
@@ -2759,7 +2765,7 @@ table_recheck_autovac(Oid relid, HTAB *table_toast_map,
 	 * Get the applicable reloptions.  If it is a TOAST table, try to get the
 	 * main table reloptions if the toast table itself doesn't have.
 	 */
-	avopts = extract_autovac_opts(classTup, pg_class_desc);
+	avopts = extract_autovac_opts(classTup, pg_class_desc, reloptions);
 	if (avopts)
 		free_avopts = true;
 	else if (classForm->relkind == RELKIND_TOASTVALUE &&
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index 559ba9cdb2c..fb3c8ee2f64 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -469,6 +469,7 @@ RelationParseRelOptions(Relation relation, HeapTuple tuple)
 {
 	bytea	   *options;
 	amoptions_function amoptsfn;
+	reloptions_function reloptsfn;
 
 	relation->rd_options = NULL;
 
@@ -480,13 +481,18 @@ RelationParseRelOptions(Relation relation, HeapTuple tuple)
 	{
 		case RELKIND_RELATION:
 		case RELKIND_TOASTVALUE:
-		case RELKIND_VIEW:
 		case RELKIND_MATVIEW:
+			reloptsfn = relation->rd_tableam->relation_options;
+			amoptsfn = NULL;
+			break;
+		case RELKIND_VIEW:
 		case RELKIND_PARTITIONED_TABLE:
+			reloptsfn = NULL;
 			amoptsfn = NULL;
 			break;
 		case RELKIND_INDEX:
 		case RELKIND_PARTITIONED_INDEX:
+			reloptsfn = NULL;
 			amoptsfn = relation->rd_indam->amoptions;
 			break;
 		default:
@@ -498,7 +504,8 @@ RelationParseRelOptions(Relation relation, HeapTuple tuple)
 	 * we might not have any other for pg_class yet (consider executing this
 	 * code for pg_class itself)
 	 */
-	options = extractRelOptions(tuple, GetPgClassDescriptor(), amoptsfn);
+	options = extractRelOptions(tuple, GetPgClassDescriptor(),
+								amoptsfn, reloptsfn);
 
 	/*
 	 * Copy parsed data into CacheMemoryContext.  To guard against the
diff --git a/src/include/access/reloptions.h b/src/include/access/reloptions.h
index dfbb4c85460..37f51d0f1c2 100644
--- a/src/include/access/reloptions.h
+++ b/src/include/access/reloptions.h
@@ -21,6 +21,7 @@
 
 #include "access/amapi.h"
 #include "access/htup.h"
+#include "access/tableam.h"
 #include "access/tupdesc.h"
 #include "nodes/pg_list.h"
 #include "storage/lock.h"
@@ -237,7 +238,8 @@ extern Datum transformRelOptions(Datum oldOptions, List *defList,
 								 bool acceptOidsOff, bool isReset);
 extern List *untransformRelOptions(Datum options);
 extern bytea *extractRelOptions(HeapTuple tuple, TupleDesc tupdesc,
-								amoptions_function amoptions);
+								amoptions_function amoptions,
+								reloptions_function reloptsfun);
 extern void *build_reloptions(Datum reloptions, bool validate,
 							  relopt_kind kind,
 							  Size relopt_struct_size,
@@ -251,6 +253,8 @@ extern bytea *default_reloptions(Datum reloptions, bool validate,
 extern bytea *heap_reloptions(char relkind, Datum reloptions, bool validate);
 extern bytea *view_reloptions(Datum reloptions, bool validate);
 extern bytea *partitioned_table_reloptions(Datum reloptions, bool validate);
+extern bytea *table_reloptions(reloptions_function reloptsfun, Oid amid, char relkind,
+							   Datum reloptions, bool validate);
 extern bytea *index_reloptions(amoptions_function amoptions, Datum reloptions,
 							   bool validate);
 extern bytea *attribute_reloptions(Datum reloptions, bool validate);
diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h
index 8713e12cbfb..b9544173f96 100644
--- a/src/include/access/tableam.h
+++ b/src/include/access/tableam.h
@@ -269,6 +269,14 @@ typedef void (*IndexBuildCallback) (Relation index,
 									bool tupleIsAlive,
 									void *state);
 
+/*
+ * Callback in charge of parsing and validating the table reloptions.
+ * It returns parsed options in bytea format.
+ */
+typedef bytea *(*reloptions_function) (char relkind,
+									   Datum reloptions,
+									   bool validate);
+
 /*
  * API struct for a table AM.  Note this must be allocated in a
  * server-lifetime manner, typically as a static const struct, which then gets
@@ -708,6 +716,8 @@ typedef struct TableAmRoutine
 	 * ------------------------------------------------------------------------
 	 */
 
+	reloptions_function relation_options;
+
 	/*
 	 * See table_relation_size().
 	 *
diff --git a/src/include/commands/defrem.h b/src/include/commands/defrem.h
index dd22b5efdfd..8e42f394107 100644
--- a/src/include/commands/defrem.h
+++ b/src/include/commands/defrem.h
@@ -136,6 +136,7 @@ extern ObjectAddress AlterUserMapping(AlterUserMappingStmt *stmt);
 extern Oid	RemoveUserMapping(DropUserMappingStmt *stmt);
 extern void CreateForeignTable(CreateForeignTableStmt *stmt, Oid relid);
 extern void ImportForeignSchema(ImportForeignSchemaStmt *stmt);
+extern Datum optionListToArray(List *options);
 extern Datum transformGenericOptions(Oid catalogId,
 									 Datum oldOptions,
 									 List *options,
-- 
2.39.5

  [text/x-diff] v4-0002-Add-the-dummy_table_am-test-module.patch (33.1K, ../../20250526110619.oepaf5dbzq3hjuij@poseidon.home.virt/3-v4-0002-Add-the-dummy_table_am-test-module.patch)
  download | inline diff:
From dcc5c3019124f460564076e79a5e7e4bdbafe671 Mon Sep 17 00:00:00 2001
From: Julien Tachoires <julien@tachoires.me>
Date: Sat, 1 Mar 2025 20:50:13 +0100
Subject: [PATCH 2/2] Add the "dummy_table_am" test module

This test module is in charge of testing table AM reloptions. It's
very similar to what we do in dummy_index_am as we have to exercise
the exact same kind of feature.
---
 src/test/modules/Makefile                     |   1 +
 src/test/modules/dummy_table_am/Makefile      |  20 +
 src/test/modules/dummy_table_am/README        |  14 +
 .../dummy_table_am/dummy_table_am--1.0.sql    |  13 +
 .../modules/dummy_table_am/dummy_table_am.c   | 581 ++++++++++++++++++
 .../dummy_table_am/dummy_table_am.control     |   5 +
 .../dummy_table_am/expected/reloptions.out    | 181 ++++++
 src/test/modules/dummy_table_am/meson.build   |  33 +
 .../modules/dummy_table_am/sql/reloptions.sql |  99 +++
 src/test/modules/meson.build                  |   1 +
 10 files changed, 948 insertions(+)
 create mode 100644 src/test/modules/dummy_table_am/Makefile
 create mode 100644 src/test/modules/dummy_table_am/README
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am.c
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am.control
 create mode 100644 src/test/modules/dummy_table_am/expected/reloptions.out
 create mode 100644 src/test/modules/dummy_table_am/meson.build
 create mode 100644 src/test/modules/dummy_table_am/sql/reloptions.sql

diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index aa1d27bbed3..8afc771a00c 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -9,6 +9,7 @@ SUBDIRS = \
 		  commit_ts \
 		  delay_execution \
 		  dummy_index_am \
+		  dummy_table_am \
 		  dummy_seclabel \
 		  libpq_pipeline \
 		  oauth_validator \
diff --git a/src/test/modules/dummy_table_am/Makefile b/src/test/modules/dummy_table_am/Makefile
new file mode 100644
index 00000000000..94837dff392
--- /dev/null
+++ b/src/test/modules/dummy_table_am/Makefile
@@ -0,0 +1,20 @@
+# src/test/modules/dummy_table_am/Makefile
+
+MODULES = dummy_table_am
+
+EXTENSION = dummy_table_am
+DATA = dummy_table_am--1.0.sql
+PGFILEDESC = "dummy_table_am - table access method template"
+
+REGRESS = reloptions
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/dummy_table_am
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/dummy_table_am/README b/src/test/modules/dummy_table_am/README
new file mode 100644
index 00000000000..50cf08ee3b1
--- /dev/null
+++ b/src/test/modules/dummy_table_am/README
@@ -0,0 +1,14 @@
+Dummy Table AM
+==============
+
+Dummy table AM is a module for testing any facility usable by a table
+access method, whose code is kept a maximum simple.
+
+This includes tests for all relation option types:
+- boolean
+- enum
+- integer
+- real
+- strings (with and without NULL as default)
+
+It also includes tests related to unrecognized options.
diff --git a/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql b/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
new file mode 100644
index 00000000000..12ad3ad174b
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
@@ -0,0 +1,13 @@
+/* src/test/modules/dummy_table_am/dummy_table_am--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION dummy_table_am" to load this file. \quit
+
+CREATE FUNCTION dummy_table_am_handler(internal)
+RETURNS table_am_handler
+AS 'MODULE_PATHNAME'
+LANGUAGE C;
+
+-- Access method
+CREATE ACCESS METHOD dummy_table_am TYPE TABLE HANDLER dummy_table_am_handler;
+COMMENT ON ACCESS METHOD dummy_table_am IS 'Dummy Table Access Method';
diff --git a/src/test/modules/dummy_table_am/dummy_table_am.c b/src/test/modules/dummy_table_am/dummy_table_am.c
new file mode 100644
index 00000000000..bc9beba195a
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am.c
@@ -0,0 +1,581 @@
+/*-------------------------------------------------------------------------
+ *
+ * dummy_table_am.c
+ *		Table AM templae main file
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/test/modules/dummy_table_am/dummy_table_am.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "miscadmin.h"
+
+#include "access/hio.h"
+#include "access/relscan.h"
+#include "access/reloptions.h"
+#include "access/tableam.h"
+#include "access/sdir.h"
+#include "access/skey.h"
+#include "executor/tuptable.h"
+#include "utils/relcache.h"
+#include "utils/snapshot.h"
+
+
+PG_MODULE_MAGIC;
+
+/* Base structures for scans */
+typedef struct DummyScanDescData
+{
+	TableScanDescData rs_base;	/* AM independent part of the descriptor */
+
+	/* Add more fields here as needed by the AM. */
+}			DummyScanDescData;
+typedef struct DummyScanDescData *DummyScanDesc;
+
+/* parse table for fillRelOptions */
+static relopt_parse_elt dt_relopt_tab[7];
+
+/* Kind of relation options for dummy index */
+static relopt_kind dt_relopt_kind;
+
+typedef enum DummyAmEnum
+{
+	DUMMY_AM_ENUM_ONE,
+	DUMMY_AM_ENUM_TWO,
+}			DummyAmEnum;
+
+/* Dummy table options */
+typedef struct DummyTableOptions
+{
+	int32		vl_len_;		/* varlena header (do not touch directly!) */
+	int			option_int;
+	double		option_real;
+	bool		option_bool;
+	DummyAmEnum option_enum;
+	int			option_string_val_offset;
+	int			option_string_null_offset;
+	int			fillfactor;
+}			DummyTableOptions;
+
+static relopt_enum_elt_def dummyAmEnumValues[] =
+{
+	{"one", DUMMY_AM_ENUM_ONE},
+	{"two", DUMMY_AM_ENUM_TWO},
+	{(const char *) NULL}		/* list terminator */
+};
+
+/* ------------------------------------------------------------------------
+ *                     Dummy Access Method Interface
+ * ------------------------------------------------------------------------
+ */
+
+static const TupleTableSlotOps *
+dummy_slot_callbacks(Relation relation)
+{
+	return &TTSOpsMinimalTuple;
+}
+
+static TableScanDesc
+dummy_scan_begin(Relation relation, Snapshot snapshot, int nkeys, ScanKey key,
+				 ParallelTableScanDesc parallel_scan, uint32 flags)
+{
+	DummyScanDesc scan;
+
+	scan = (DummyScanDesc) palloc(sizeof(DummyScanDescData));
+
+	scan->rs_base.rs_rd = relation;
+	scan->rs_base.rs_snapshot = snapshot;
+	scan->rs_base.rs_nkeys = nkeys;
+	scan->rs_base.rs_flags = flags;
+	scan->rs_base.rs_parallel = parallel_scan;
+
+	return (TableScanDesc) scan;
+}
+
+static void
+dummy_scan_end(TableScanDesc sscan)
+{
+	DummyScanDesc scan = (DummyScanDesc) sscan;
+
+	pfree(scan);
+
+	return;
+}
+
+static void
+dummy_scan_rescan(TableScanDesc sscan, ScanKey key, bool set_params,
+				  bool allow_strat, bool allow_sync, bool allow_pagemode)
+{
+	return;
+}
+
+static bool
+dummy_scan_getnextslot(TableScanDesc sscan, ScanDirection direction,
+					   TupleTableSlot *slot)
+{
+	return true;
+}
+
+static void
+dummy_scan_set_tidrange(TableScanDesc sscan, ItemPointer mintid,
+						ItemPointer maxtid)
+{
+	return;
+}
+
+static bool
+dummy_scan_getnextslot_tidrange(TableScanDesc sscan, ScanDirection direction,
+								TupleTableSlot *slot)
+{
+	return true;
+}
+
+static Size
+dummy_parallelscan_estimate(Relation rel)
+{
+	return 0;
+}
+
+static Size
+dummy_parallelscan_initialize(Relation rel, ParallelTableScanDesc pscan)
+{
+	return 0;
+}
+
+static void
+dummy_parallelscan_reinitialize(Relation rel, ParallelTableScanDesc pscan)
+{
+	return;
+}
+
+static IndexFetchTableData *
+dummy_index_fetch_begin(Relation rel)
+{
+	return NULL;
+}
+
+static void
+dummy_index_fetch_reset(IndexFetchTableData *scan)
+{
+	return;
+}
+
+static void
+dummy_index_fetch_end(IndexFetchTableData *scan)
+{
+	return;
+}
+
+static bool
+dummy_index_fetch_tuple(struct IndexFetchTableData *scan, ItemPointer tid,
+						Snapshot snapshot, TupleTableSlot *slot,
+						bool *call_again, bool *all_dead)
+{
+	return true;
+}
+
+static void
+dummy_tuple_insert(Relation relation, TupleTableSlot *slot, CommandId cid,
+				   int options, BulkInsertStateData *bistate)
+{
+	DummyTableOptions *relopts;
+
+	relopts = (DummyTableOptions *) relation->rd_options;
+
+	elog(NOTICE, "option_int=%d, option_real=%f, option_bool=%d, option_enum=%d",
+		 relopts->option_int, relopts->option_real, relopts->option_bool, relopts->option_enum);
+
+	return;
+}
+
+static void
+dummy_tuple_insert_speculative(Relation relation, TupleTableSlot *slot,
+							   CommandId cid, int options,
+							   BulkInsertStateData *bistate, uint32 specToken)
+{
+	return;
+}
+
+static void
+dummy_tuple_complete_speculative(Relation relation, TupleTableSlot *slot,
+								 uint32 specToken, bool succeeded)
+{
+	return;
+}
+
+static void
+dummy_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples,
+				   CommandId cid, int options, BulkInsertStateData *bistate)
+{
+	return;
+}
+
+static TM_Result
+dummy_tuple_delete(Relation relation, ItemPointer tid, CommandId cid,
+				   Snapshot snapshot, Snapshot crosscheck, bool wait,
+				   TM_FailureData *tmfd, bool changingPart)
+{
+	return TM_Ok;
+}
+
+static TM_Result
+dummy_tuple_update(Relation relation, ItemPointer otid, TupleTableSlot *slot,
+				   CommandId cid, Snapshot snapshot, Snapshot crosscheck,
+				   bool wait, TM_FailureData *tmfd,
+				   LockTupleMode *lockmode, TU_UpdateIndexes *update_indexes)
+{
+	return TM_Ok;
+}
+
+static TM_Result
+dummy_tuple_lock(Relation relation, ItemPointer tid, Snapshot snapshot,
+				 TupleTableSlot *slot, CommandId cid, LockTupleMode mode,
+				 LockWaitPolicy wait_policy, uint8 flags,
+				 TM_FailureData *tmfd)
+{
+	return TM_Ok;
+}
+
+static bool
+dummy_fetch_row_version(Relation relation, ItemPointer tid,
+						Snapshot snapshot, TupleTableSlot *slot)
+{
+	return false;
+}
+
+static void
+dummy_get_latest_tid(TableScanDesc sscan, ItemPointer tid)
+{
+	return;
+}
+
+static bool
+dummy_tuple_tid_valid(TableScanDesc scan, ItemPointer tid)
+{
+	return false;
+}
+
+static bool
+dummy_tuple_satisfies_snapshot(Relation rel, TupleTableSlot *slot,
+							   Snapshot snapshot)
+{
+	return false;
+}
+
+static TransactionId
+dummy_index_delete_tuples(Relation rel, TM_IndexDeleteOp *delstate)
+{
+	return InvalidTransactionId;
+}
+
+static void
+dummy_relation_set_new_filelocator(Relation rel,
+								   const RelFileLocator *newrlocator,
+								   char persistence,
+								   TransactionId *freezeXid,
+								   MultiXactId *minmulti)
+{
+	return;
+}
+
+static void
+dummy_relation_nontransactional_truncate(Relation rel)
+{
+	return;
+}
+
+static void
+dummy_relation_copy_data(Relation rel, const RelFileLocator *newrlocator)
+{
+	return;
+}
+
+static void
+dummy_relation_copy_for_cluster(Relation OldHeap, Relation NewHeap,
+								Relation OldIndex, bool use_sort,
+								TransactionId OldestXmin,
+								TransactionId *xid_cutoff,
+								MultiXactId *multi_cutoff,
+								double *num_tuples,
+								double *tups_vacuumed,
+								double *tups_recently_dead)
+{
+	return;
+}
+
+static void
+dummy_relation_vacuum(Relation rel, struct VacuumParams *params,
+					  BufferAccessStrategy bstrategy)
+{
+	return;
+}
+
+static bool
+dummy_scan_analyze_next_block(TableScanDesc scan, ReadStream *stream)
+{
+	return false;
+}
+
+static bool
+dummy_scan_analyze_next_tuple(TableScanDesc scan, TransactionId OldestXmin,
+							  double *liverows, double *deadrows,
+							  TupleTableSlot *slot)
+{
+	return false;
+}
+
+static double
+dummy_index_build_range_scan(Relation heapRelation,
+							 Relation indexRelation,
+							 struct IndexInfo *indexInfo,
+							 bool allow_sync,
+							 bool anyvisible,
+							 bool progress,
+							 BlockNumber start_blockno,
+							 BlockNumber numblocks,
+							 IndexBuildCallback callback,
+							 void *callback_state,
+							 TableScanDesc scan)
+{
+	return 0;
+}
+
+static void
+dummy_index_validate_scan(Relation heapRelation,
+						  Relation indexRelation,
+						  struct IndexInfo *indexInfo,
+						  Snapshot snapshot,
+						  struct ValidateIndexState *state)
+{
+	return;
+}
+
+static uint64
+dummy_relation_size(Relation rel, ForkNumber forkNumber)
+{
+	return 0;
+}
+
+static bool
+dummy_relation_needs_toast_table(Relation rel)
+{
+	return false;
+}
+
+static Oid
+dummy_relation_toast_am(Relation rel)
+{
+	return InvalidOid;
+}
+
+static void
+dummy_relation_fetch_toast_slice(Relation toastrel, Oid valueid, int32 attrsize,
+								 int32 sliceoffset, int32 slicelength,
+								 struct varlena *result)
+{
+	return;
+}
+
+static void
+dummy_relation_estimate_size(Relation rel, int32 *attr_widths,
+							 BlockNumber *pages, double *tuples,
+							 double *allvisfrac)
+{
+	return;
+}
+
+static bool
+dummy_scan_bitmap_next_tuple(TableScanDesc scan, TupleTableSlot *slot,
+							 bool *recheck, uint64 *lossy_pages,
+							 uint64 *exact_pages)
+{
+	return false;
+}
+
+static bool
+dummy_scan_sample_next_block(TableScanDesc scan, struct SampleScanState *scanstate)
+{
+	return false;
+}
+
+static bool
+dummy_scan_sample_next_tuple(TableScanDesc scan, struct SampleScanState *scanstate,
+							 TupleTableSlot *slot)
+{
+	return false;
+}
+
+static bytea *
+dummy_relation_options(char relkind, Datum reloptions, bool validate)
+{
+	return (bytea *) build_reloptions(reloptions, validate,
+									  dt_relopt_kind,
+									  sizeof(DummyTableOptions),
+									  dt_relopt_tab, lengthof(dt_relopt_tab));
+}
+
+/*
+ * Validation function for string relation options.
+ */
+static void
+validate_string_option(const char *value)
+{
+	ereport(NOTICE,
+			(errmsg("new option value for string parameter %s",
+					value ? value : "NULL")));
+}
+
+/*
+ * This function creates a full set of relation option types,
+ * with various patterns.
+ */
+static void
+create_reloptions_table(void)
+{
+	dt_relopt_kind = add_reloption_kind();
+
+	add_int_reloption(dt_relopt_kind, "option_int",
+					  "Integer option for dummy_table_am",
+					  10, -10, 100, AccessExclusiveLock);
+	dt_relopt_tab[0].optname = "option_int";
+	dt_relopt_tab[0].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[0].offset = offsetof(DummyTableOptions, option_int);
+
+	add_real_reloption(dt_relopt_kind, "option_real",
+					   "Real option for dummy_table_am",
+					   3.1415, -10, 100, AccessExclusiveLock);
+	dt_relopt_tab[1].optname = "option_real";
+	dt_relopt_tab[1].opttype = RELOPT_TYPE_REAL;
+	dt_relopt_tab[1].offset = offsetof(DummyTableOptions, option_real);
+
+	add_bool_reloption(dt_relopt_kind, "option_bool",
+					   "Boolean option for dummy_table_am",
+					   true, AccessExclusiveLock);
+	dt_relopt_tab[2].optname = "option_bool";
+	dt_relopt_tab[2].opttype = RELOPT_TYPE_BOOL;
+	dt_relopt_tab[2].offset = offsetof(DummyTableOptions, option_bool);
+
+	add_enum_reloption(dt_relopt_kind, "option_enum",
+					   "Enum option for dummy_table_am",
+					   dummyAmEnumValues,
+					   DUMMY_AM_ENUM_ONE,
+					   "Valid values are \"one\" and \"two\".",
+					   AccessExclusiveLock);
+	dt_relopt_tab[3].optname = "option_enum";
+	dt_relopt_tab[3].opttype = RELOPT_TYPE_ENUM;
+	dt_relopt_tab[3].offset = offsetof(DummyTableOptions, option_enum);
+
+	add_string_reloption(dt_relopt_kind, "option_string_val",
+						 "String option for dummy_table_am with non-NULL default",
+						 "DefaultValue", &validate_string_option,
+						 AccessExclusiveLock);
+	dt_relopt_tab[4].optname = "option_string_val";
+	dt_relopt_tab[4].opttype = RELOPT_TYPE_STRING;
+	dt_relopt_tab[4].offset = offsetof(DummyTableOptions,
+									   option_string_val_offset);
+
+	/*
+	 * String option for dummy_table_am with NULL default, and without
+	 * description.
+	 */
+	add_string_reloption(dt_relopt_kind, "option_string_null",
+						 NULL,	/* description */
+						 NULL, &validate_string_option,
+						 AccessExclusiveLock);
+	dt_relopt_tab[5].optname = "option_string_null";
+	dt_relopt_tab[5].opttype = RELOPT_TYPE_STRING;
+	dt_relopt_tab[5].offset = offsetof(DummyTableOptions,
+									   option_string_null_offset);
+
+	/*
+	 * fillfactor will be used to check reloption conversion when changing
+	 * table access method between heap AM and dummy_table_am.
+	 */
+	add_int_reloption(dt_relopt_kind, "fillfactor",
+					  "Fillfactor option for dummy_table_am",
+					  10, 0, 90, AccessExclusiveLock);
+	dt_relopt_tab[6].optname = "fillfactor";
+	dt_relopt_tab[6].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[6].offset = offsetof(DummyTableOptions, fillfactor);
+}
+
+
+/*
+ * Table Access Method API
+ */
+static const TableAmRoutine dummy_table_am_methods = {
+	.type = T_TableAmRoutine,
+
+	.slot_callbacks = dummy_slot_callbacks,
+	.scan_begin = dummy_scan_begin,
+	.scan_end = dummy_scan_end,
+	.scan_rescan = dummy_scan_rescan,
+	.scan_getnextslot = dummy_scan_getnextslot,
+
+	.scan_set_tidrange = dummy_scan_set_tidrange,
+	.scan_getnextslot_tidrange = dummy_scan_getnextslot_tidrange,
+
+	.parallelscan_estimate = dummy_parallelscan_estimate,
+	.parallelscan_initialize = dummy_parallelscan_initialize,
+	.parallelscan_reinitialize = dummy_parallelscan_reinitialize,
+
+	.index_fetch_begin = dummy_index_fetch_begin,
+	.index_fetch_reset = dummy_index_fetch_reset,
+	.index_fetch_end = dummy_index_fetch_end,
+	.index_fetch_tuple = dummy_index_fetch_tuple,
+
+	.tuple_insert = dummy_tuple_insert,
+	.tuple_insert_speculative = dummy_tuple_insert_speculative,
+	.tuple_complete_speculative = dummy_tuple_complete_speculative,
+	.multi_insert = dummy_multi_insert,
+	.tuple_delete = dummy_tuple_delete,
+	.tuple_update = dummy_tuple_update,
+	.tuple_lock = dummy_tuple_lock,
+
+	.tuple_fetch_row_version = dummy_fetch_row_version,
+	.tuple_get_latest_tid = dummy_get_latest_tid,
+	.tuple_tid_valid = dummy_tuple_tid_valid,
+	.tuple_satisfies_snapshot = dummy_tuple_satisfies_snapshot,
+	.index_delete_tuples = dummy_index_delete_tuples,
+
+	.relation_set_new_filelocator = dummy_relation_set_new_filelocator,
+	.relation_nontransactional_truncate = dummy_relation_nontransactional_truncate,
+	.relation_copy_data = dummy_relation_copy_data,
+	.relation_copy_for_cluster = dummy_relation_copy_for_cluster,
+	.relation_vacuum = dummy_relation_vacuum,
+	.scan_analyze_next_block = dummy_scan_analyze_next_block,
+	.scan_analyze_next_tuple = dummy_scan_analyze_next_tuple,
+	.index_build_range_scan = dummy_index_build_range_scan,
+	.index_validate_scan = dummy_index_validate_scan,
+
+	.relation_size = dummy_relation_size,
+	.relation_needs_toast_table = dummy_relation_needs_toast_table,
+	.relation_toast_am = dummy_relation_toast_am,
+	.relation_fetch_toast_slice = dummy_relation_fetch_toast_slice,
+	.relation_estimate_size = dummy_relation_estimate_size,
+	.relation_options = dummy_relation_options,
+
+	.scan_bitmap_next_tuple = dummy_scan_bitmap_next_tuple,
+	.scan_sample_next_block = dummy_scan_sample_next_block,
+	.scan_sample_next_tuple = dummy_scan_sample_next_tuple
+};
+
+PG_FUNCTION_INFO_V1(dummy_table_am_handler);
+
+Datum
+dummy_table_am_handler(PG_FUNCTION_ARGS)
+{
+	PG_RETURN_POINTER(&dummy_table_am_methods);
+}
+
+void
+_PG_init(void)
+{
+	create_reloptions_table();
+}
diff --git a/src/test/modules/dummy_table_am/dummy_table_am.control b/src/test/modules/dummy_table_am/dummy_table_am.control
new file mode 100644
index 00000000000..08f2f868d49
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am.control
@@ -0,0 +1,5 @@
+# dummy_table_am extension
+comment = 'dummy_table_am - table access method template'
+default_version = '1.0'
+module_pathname = '$libdir/dummy_table_am'
+relocatable = true
diff --git a/src/test/modules/dummy_table_am/expected/reloptions.out b/src/test/modules/dummy_table_am/expected/reloptions.out
new file mode 100644
index 00000000000..0b947500ead
--- /dev/null
+++ b/src/test/modules/dummy_table_am/expected/reloptions.out
@@ -0,0 +1,181 @@
+-- Tests for relation options
+CREATE EXTENSION dummy_table_am;
+CREATE TABLE dummy_test_tab (i int4) USING dummy_table_am;
+-- Silence validation checks for strings
+SET client_min_messages TO 'warning';
+-- Test with default values.
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+ unnest 
+--------
+(0 rows)
+
+DROP TABLE dummy_test_tab;
+-- Test with full set of options.
+-- Allow validation checks for strings
+SET client_min_messages TO 'notice';
+CREATE TABLE dummy_test_tab (i int4)
+  USING dummy_table_am WITH (
+  option_bool = false,
+  option_int = 5,
+  option_real = 3.1,
+  option_enum = 'two',
+  option_string_val = NULL,
+  option_string_null = 'val');
+NOTICE:  new option value for string parameter null
+NOTICE:  new option value for string parameter val
+-- Silence again validation checks for strings until the end of the test.
+SET client_min_messages TO 'warning';
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+         unnest         
+------------------------
+ option_bool=false
+ option_int=5
+ option_real=3.1
+ option_enum=two
+ option_string_val=null
+ option_string_null=val
+(6 rows)
+
+-- ALTER TABLE .. SET
+ALTER TABLE dummy_test_tab SET (option_int = 10);
+ALTER TABLE dummy_test_tab SET (option_bool = true);
+ALTER TABLE dummy_test_tab SET (option_real = 3.2);
+ALTER TABLE dummy_test_tab SET (option_string_val = 'val2');
+ALTER TABLE dummy_test_tab SET (option_string_null = NULL);
+ALTER TABLE dummy_test_tab SET (option_enum = 'one');
+ALTER TABLE dummy_test_tab SET (option_enum = 'three');
+ERROR:  invalid value for enum option "option_enum": three
+DETAIL:  Valid values are "one" and "two".
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+         unnest          
+-------------------------
+ option_int=10
+ option_bool=true
+ option_real=3.2
+ option_string_val=val2
+ option_string_null=null
+ option_enum=one
+(6 rows)
+
+-- ALTER TABLE .. RESET
+ALTER TABLE dummy_test_tab RESET (option_int);
+ALTER TABLE dummy_test_tab RESET (option_bool);
+ALTER TABLE dummy_test_tab RESET (option_real);
+ALTER TABLE dummy_test_tab RESET (option_enum);
+ALTER TABLE dummy_test_tab RESET (option_string_val);
+ALTER TABLE dummy_test_tab RESET (option_string_null);
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+ unnest 
+--------
+(0 rows)
+
+-- Cross-type checks for reloption values
+-- Integer
+ALTER TABLE dummy_test_tab SET (option_int = 3.3); -- ok
+ALTER TABLE dummy_test_tab SET (option_int = true); -- error
+ERROR:  invalid value for integer option "option_int": true
+ALTER TABLE dummy_test_tab SET (option_int = 'val3'); -- error
+ERROR:  invalid value for integer option "option_int": val3
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+     unnest     
+----------------
+ option_int=3.3
+(1 row)
+
+ALTER TABLE dummy_test_tab RESET (option_int);
+-- Boolean
+ALTER TABLE dummy_test_tab SET (option_bool = 4); -- error
+ERROR:  invalid value for boolean option "option_bool": 4
+ALTER TABLE dummy_test_tab SET (option_bool = 1); -- ok, as true
+ALTER TABLE dummy_test_tab SET (option_bool = 3.4); -- error
+ERROR:  invalid value for boolean option "option_bool": 3.4
+ALTER TABLE dummy_test_tab SET (option_bool = 'val4'); -- error
+ERROR:  invalid value for boolean option "option_bool": val4
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+    unnest     
+---------------
+ option_bool=1
+(1 row)
+
+ALTER TABLE dummy_test_tab RESET (option_bool);
+-- Float
+ALTER TABLE dummy_test_tab SET (option_real = 4); -- ok
+ALTER TABLE dummy_test_tab SET (option_real = true); -- error
+ERROR:  invalid value for floating point option "option_real": true
+ALTER TABLE dummy_test_tab SET (option_real = 'val5'); -- error
+ERROR:  invalid value for floating point option "option_real": val5
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+    unnest     
+---------------
+ option_real=4
+(1 row)
+
+ALTER TABLE dummy_test_tab RESET (option_real);
+-- Enum
+ALTER TABLE dummy_test_tab SET (option_enum = 'one'); -- ok
+ALTER TABLE dummy_test_tab SET (option_enum = 0); -- error
+ERROR:  invalid value for enum option "option_enum": 0
+DETAIL:  Valid values are "one" and "two".
+ALTER TABLE dummy_test_tab SET (option_enum = true); -- error
+ERROR:  invalid value for enum option "option_enum": true
+DETAIL:  Valid values are "one" and "two".
+ALTER TABLE dummy_test_tab SET (option_enum = 'three'); -- error
+ERROR:  invalid value for enum option "option_enum": three
+DETAIL:  Valid values are "one" and "two".
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+     unnest      
+-----------------
+ option_enum=one
+(1 row)
+
+ALTER TABLE dummy_test_tab RESET (option_enum);
+-- String
+ALTER TABLE dummy_test_tab SET (option_string_val = 4); -- ok
+ALTER TABLE dummy_test_tab SET (option_string_val = 3.5); -- ok
+ALTER TABLE dummy_test_tab SET (option_string_val = true); -- ok, as "true"
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+         unnest         
+------------------------
+ option_string_val=true
+(1 row)
+
+ALTER TABLE dummy_test_tab RESET (option_string_val);
+DROP TABLE dummy_test_tab;
+-- ALTER TABLE SET ACCESS METHOD OPTIONS
+CREATE TABLE heap_tab (i INT4) WITH (fillfactor=100, toast_tuple_target=1000);
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'heap_tab';
+         unnest          
+-------------------------
+ fillfactor=100
+ toast_tuple_target=1000
+(2 rows)
+
+-- error: fillfactor is out of bounds: maximum value from the new table am is 90
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am;
+ERROR:  value 100 out of bounds for option "fillfactor"
+DETAIL:  Valid values are between "0" and "90".
+-- error: toast_tuple_target does not exist in the new table AM
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (SET fillfactor '50');
+ERROR:  unrecognized parameter "toast_tuple_target"
+-- error: adding is not possible when the parameter is already defined in source reloptions
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (ADD fillfactor '50');
+ERROR:  option "fillfactor" provided more than once
+-- error: the specified option we want to drop does not exist
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (DROP does_not_exist);
+ERROR:  option "does_not_exist" not found
+-- error: adding unrecognized parameter
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (SET fillfactor '50', DROP toast_tuple_target, ADD unrecognized 'foo');
+ERROR:  unrecognized parameter "unrecognized"
+-- ok
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (DROP fillfactor, DROP toast_tuple_target, option_int '1', option_bool 'true', option_real '0.001', option_enum 'one', option_string_val 'hello');
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'heap_tab';
+         unnest          
+-------------------------
+ option_int=1
+ option_bool=true
+ option_real=0.001
+ option_enum=one
+ option_string_val=hello
+(5 rows)
+
+DROP TABLE heap_tab;
diff --git a/src/test/modules/dummy_table_am/meson.build b/src/test/modules/dummy_table_am/meson.build
new file mode 100644
index 00000000000..6b197b15ffa
--- /dev/null
+++ b/src/test/modules/dummy_table_am/meson.build
@@ -0,0 +1,33 @@
+# Copyright (c) 2022-2025, PostgreSQL Global Development Group
+
+dummy_table_am_sources = files(
+  'dummy_table_am.c',
+)
+
+if host_system == 'windows'
+  dummy_table_am_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'dummy_table_am',
+    '--FILEDESC', 'dummy_table_am - table access method template',])
+endif
+
+dummy_table_am = shared_module('dummy_table_am',
+  dummy_table_am_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += dummy_table_am
+
+test_install_data += files(
+  'dummy_table_am.control',
+  'dummy_table_am--1.0.sql',
+)
+
+tests += {
+  'name': 'dummy_table_am',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'reloptions',
+    ],
+  },
+}
diff --git a/src/test/modules/dummy_table_am/sql/reloptions.sql b/src/test/modules/dummy_table_am/sql/reloptions.sql
new file mode 100644
index 00000000000..47fb4862c6c
--- /dev/null
+++ b/src/test/modules/dummy_table_am/sql/reloptions.sql
@@ -0,0 +1,99 @@
+-- Tests for relation options
+CREATE EXTENSION dummy_table_am;
+
+CREATE TABLE dummy_test_tab (i int4) USING dummy_table_am;
+
+-- Silence validation checks for strings
+SET client_min_messages TO 'warning';
+
+-- Test with default values.
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+DROP TABLE dummy_test_tab;
+
+-- Test with full set of options.
+-- Allow validation checks for strings
+SET client_min_messages TO 'notice';
+CREATE TABLE dummy_test_tab (i int4)
+  USING dummy_table_am WITH (
+  option_bool = false,
+  option_int = 5,
+  option_real = 3.1,
+  option_enum = 'two',
+  option_string_val = NULL,
+  option_string_null = 'val');
+-- Silence again validation checks for strings until the end of the test.
+SET client_min_messages TO 'warning';
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+
+-- ALTER TABLE .. SET
+ALTER TABLE dummy_test_tab SET (option_int = 10);
+ALTER TABLE dummy_test_tab SET (option_bool = true);
+ALTER TABLE dummy_test_tab SET (option_real = 3.2);
+ALTER TABLE dummy_test_tab SET (option_string_val = 'val2');
+ALTER TABLE dummy_test_tab SET (option_string_null = NULL);
+ALTER TABLE dummy_test_tab SET (option_enum = 'one');
+ALTER TABLE dummy_test_tab SET (option_enum = 'three');
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+
+-- ALTER TABLE .. RESET
+ALTER TABLE dummy_test_tab RESET (option_int);
+ALTER TABLE dummy_test_tab RESET (option_bool);
+ALTER TABLE dummy_test_tab RESET (option_real);
+ALTER TABLE dummy_test_tab RESET (option_enum);
+ALTER TABLE dummy_test_tab RESET (option_string_val);
+ALTER TABLE dummy_test_tab RESET (option_string_null);
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+
+-- Cross-type checks for reloption values
+-- Integer
+ALTER TABLE dummy_test_tab SET (option_int = 3.3); -- ok
+ALTER TABLE dummy_test_tab SET (option_int = true); -- error
+ALTER TABLE dummy_test_tab SET (option_int = 'val3'); -- error
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+ALTER TABLE dummy_test_tab RESET (option_int);
+-- Boolean
+ALTER TABLE dummy_test_tab SET (option_bool = 4); -- error
+ALTER TABLE dummy_test_tab SET (option_bool = 1); -- ok, as true
+ALTER TABLE dummy_test_tab SET (option_bool = 3.4); -- error
+ALTER TABLE dummy_test_tab SET (option_bool = 'val4'); -- error
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+ALTER TABLE dummy_test_tab RESET (option_bool);
+-- Float
+ALTER TABLE dummy_test_tab SET (option_real = 4); -- ok
+ALTER TABLE dummy_test_tab SET (option_real = true); -- error
+ALTER TABLE dummy_test_tab SET (option_real = 'val5'); -- error
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+ALTER TABLE dummy_test_tab RESET (option_real);
+-- Enum
+ALTER TABLE dummy_test_tab SET (option_enum = 'one'); -- ok
+ALTER TABLE dummy_test_tab SET (option_enum = 0); -- error
+ALTER TABLE dummy_test_tab SET (option_enum = true); -- error
+ALTER TABLE dummy_test_tab SET (option_enum = 'three'); -- error
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+ALTER TABLE dummy_test_tab RESET (option_enum);
+-- String
+ALTER TABLE dummy_test_tab SET (option_string_val = 4); -- ok
+ALTER TABLE dummy_test_tab SET (option_string_val = 3.5); -- ok
+ALTER TABLE dummy_test_tab SET (option_string_val = true); -- ok, as "true"
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'dummy_test_tab';
+ALTER TABLE dummy_test_tab RESET (option_string_val);
+
+DROP TABLE dummy_test_tab;
+
+-- ALTER TABLE SET ACCESS METHOD OPTIONS
+CREATE TABLE heap_tab (i INT4) WITH (fillfactor=100, toast_tuple_target=1000);
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'heap_tab';
+-- error: fillfactor is out of bounds: maximum value from the new table am is 90
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am;
+-- error: toast_tuple_target does not exist in the new table AM
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (SET fillfactor '50');
+-- error: adding is not possible when the parameter is already defined in source reloptions
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (ADD fillfactor '50');
+-- error: the specified option we want to drop does not exist
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (DROP does_not_exist);
+-- error: adding unrecognized parameter
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (SET fillfactor '50', DROP toast_tuple_target, ADD unrecognized 'foo');
+-- ok
+ALTER TABLE heap_tab SET ACCESS METHOD dummy_table_am OPTIONS (DROP fillfactor, DROP toast_tuple_target, option_int '1', option_bool 'true', option_real '0.001', option_enum 'one', option_string_val 'hello');
+SELECT unnest(reloptions) FROM pg_class WHERE relname = 'heap_tab';
+DROP TABLE heap_tab;
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 9de0057bd1d..28005cfc273 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -4,6 +4,7 @@ subdir('brin')
 subdir('commit_ts')
 subdir('delay_execution')
 subdir('dummy_index_am')
+subdir('dummy_table_am')
 subdir('dummy_seclabel')
 subdir('gin')
 subdir('injection_points')
-- 
2.39.5

^ permalink  raw  reply  [nested|flat] 25+ messages in thread

* Re: Allow table AMs to define their own reloptions
@ 2026-06-03 23:02  Andrew Dunstan <andrew@dunslane.net>
  parent: Julien Tachoires <julien@tachoires.me>
  0 siblings, 1 reply; 25+ messages in thread

From: Andrew Dunstan @ 2026-06-03 23:02 UTC (permalink / raw)
  To: Julien Tachoires <julien@tachoires.me>; pgsql-hackers


On 2025-05-26 Mo 7:06 AM, Julien Tachoires wrote:
> On Sat, Mar 29, 2025 at 08:46:01AM +0100, Julien Tachoires wrote:
>> On Sun, Mar 02, 2025 at 02:23:54PM +0100, Julien Tachoires wrote:
>>> On Sun, Mar 02, 2025 at 09:56:41AM +0100, Julien Tachoires wrote:
>>>> With the help of the new TAM routine 'relation_options', table access
>>>> methods can with this patch define their own reloptions
>>>> parser/validator.
>>>>
>>>> These reloptions can be set via the following commands:
>>>> 1. CREATE TABLE ... USING table_am
>>>>         WITH (option1='value1', option2='value2');
>>>> 2. ALTER TABLE ...
>>>>         SET (option1 'value1', option2 'value2');
>>>> 3. ALTER TABLE ... SET ACCESS METHOD table_am
>>>>         OPTIONS (option1 'value1', option2 'value2');
>>>>
>>>> When changing table's access method, the settings inherited from the
>>>> former TAM can be dropped (if not supported by the new TAM) via: DROP
>>>> option, or, updated via: SET option 'value'.
>>>>
>>>> Currently, tables using different TAMs than heap are able to use heap's
>>>> reloptions (fillfactor, toast_tuple_target, etc...). With this patch
>>>> applied, this is not the case anymore: if the TAM needs to have access
>>>> to similar settings to heap ones, they have to explicitly define them.
>>>>
>>>> The 2nd patch file includes a new test module 'dummy_table_am' which
>>>> implements a dummy table access method utilized to exercise TAM
>>>> reloptions. This test module is strongly based on what we already have
>>>> in 'dummy_index_am'. 'dummy_table_am' provides a complete example of TAM
>>>> reloptions definition.
>>>>
>>>> This work is directly derived from SadhuPrasad's patch here [2]. Others
>>>> attempts were posted here [1] and here [3].
>>>>
>>>> [1] https://www.postgresql.org/message-id/flat/429fb58fa3218221bb17c7bf9e70e1aa6cfc6b5d.camel%40j-davis....
>>>> [2] https://www.postgresql.org/message-id/flat/CAFF0-CG4KZHdtYHMsonWiXNzj16gWZpduXAn8yF7pDDub+GQMg@mail....
>>>> [3] https://www.postgresql.org/message-id/flat/AMUA1wBBBxfc3tKRLLdU64rb.1.1683276279979.Hmail.wuhao%40ha...
>>> Please find a new version including minor fixes: 'TAM' terms are
>>> replaced by 'table AM'
>> Please find a new rebased version.
> New rebased version.
>


This thread seems to have gone dormant, sadly. Here's a fresh attempt I 
made (with assistance from Claude), before I was aware of the existence 
of this and other efforts. I think it meets most of the previous 
objections, and is in line with what we do for Index AMs.


cheers


andrew


--
Andrew Dunstan
EDB: https://www.enterprisedb.com

Attachments:

  [text/x-patch] v1-0001-Add-amoptions-callback-to-table-access-methods.patch (50.5K, ../../ea1c4d33-0780-473c-96dc-1468cf733a04@dunslane.net/2-v1-0001-Add-amoptions-callback-to-table-access-methods.patch)
  download | inline diff:
From 8a969146cecf5c5c04404d2c140e4b07f4519acb Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <andrew@dunslane.net>
Date: Tue, 12 May 2026 09:03:33 -0400
Subject: [PATCH v1] Add amoptions callback to table access methods

Give table access methods the same option-extension story that index
access methods already have.

TableAmRoutine gets an optional amoptions field of type
amoptions_function (same signature as IndexAmRoutine.amoptions).
A matching table_reloptions() entry point dispatches to it:

    bytea *
    table_reloptions(amoptions_function amoptions, char relkind,
                     Datum reloptions, bool validate)
    {
        if (amoptions != NULL)
            return amoptions(reloptions, validate);
        return heap_reloptions(relkind, reloptions, validate);
    }

When an AM supplies an amoptions parser it owns the option set
entirely: it may accept all standard heap options, only a subset, or
add its own; the returned bytea is stored verbatim in
Relation->rd_options, so the AM dictates the layout its other
callbacks read.  When amoptions is NULL the result is the standard
StdRdOptions layout, identical to today.

DefineRelation and ATExecSetRelOptions are updated to call
table_reloptions() for RELKIND_RELATION and RELKIND_MATVIEW.  At
CREATE TABLE the AM is resolved early (explicit USING clause,
partition parent, or default_table_access_method) so its amoptions
can be consulted during reloption validation; at ALTER TABLE SET
the AM is read from rel->rd_tableam, with one twist: if SET ACCESS
METHOD is queued in the same statement, the new AM's parser is used
so that users can write
    ALTER TABLE t SET ACCESS METHOD x, SET (foo = bar)
where foo is recognised by x but not by the current AM.
RelationParseRelOptions and extractRelOptions are likewise routed
through table_reloptions when a table relation is opened, so AMs see
their own parsed struct in rd_options.

ALTER TABLE ... SET ACCESS METHOD also runs a final reloption
revalidation after all phase-2 subcommands have committed: the
relation's resulting reloptions are checked against the new AM's
parser so that pre-existing options the new AM does not recognise
fail the statement with a clear message rather than being silently
dropped at the next relcache load.  Users can clear such options in
the same statement, e.g.
    ALTER TABLE t SET ACCESS METHOD x, RESET (fillfactor);

Because an AM that supplies amoptions owns its rd_options layout,
core macros that previously assumed rd_options was always
StdRdOptions (RelationGetFillFactor, RelationGetToastTupleTarget,
RelationIsUsedAsCatalogTable, RelationGetParallelWorkers) now gate
on a new helper RelationHasStdRdOptions().  The same gating is
applied to the direct (StdRdOptions *) casts in vacuum.c and
index.c so a custom layout is never reinterpreted as StdRdOptions.

Heap registers no amoptions (the field is NULL), so its behaviour is
unchanged.

A companion helper, add_reloption_to_kind(name, kind), extends an
existing reloption registration with an additional kind bit:

    void add_reloption_to_kind(const char *name, relopt_kind kind);

Extensions that want their AM-specific parser to accept standard
options (fillfactor, parallel_workers, autovacuum_*, vacuum_truncate)
that core registers only for RELOPT_KIND_HEAP can now call this once
per option in _PG_init instead of duplicating each definition under
the new kind.  Errors if no option with that name has been
registered.

A new test module src/test/modules/dummy_table_am demonstrates the
API: it wraps the heap AM and only overrides amoptions, so the
relation behaves as a heap table but accepts a custom reloption set.
The accompanying regress test exercises CREATE/ALTER/RESET
round-trips, SET ACCESS METHOD revalidation (both the
"options-incompatible-with-new-AM" failure path and the
"RESET-in-same-statement" success path), and partitioned-table
inheritance of an AM-specific option set.

Together the two additions are exactly the surface an extension
table AM needs to register its own reloptions in its own
RELOPT_KIND_* namespace -- AM-specific options no longer have to
pollute StdRdOptions / RELOPT_KIND_HEAP to be parseable.
---
 doc/src/sgml/ref/alter_table.sgml             |  19 ++
 doc/src/sgml/tableam.sgml                     |  67 +++++++
 src/backend/access/common/reloptions.c        |  87 ++++++++-
 src/backend/catalog/index.c                   |   3 +-
 src/backend/commands/tablecmds.c              | 170 +++++++++++++++++-
 src/backend/commands/vacuum.c                 |   7 +-
 src/backend/utils/cache/relcache.c            |   4 +-
 src/include/access/reloptions.h               |   3 +
 src/include/access/tableam.h                  |  32 ++++
 src/include/utils/rel.h                       |  25 ++-
 src/test/modules/Makefile                     |   1 +
 src/test/modules/dummy_table_am/Makefile      |  20 +++
 src/test/modules/dummy_table_am/README        |  21 +++
 .../dummy_table_am/dummy_table_am--1.0.sql    |  13 ++
 .../modules/dummy_table_am/dummy_table_am.c   | 166 +++++++++++++++++
 .../dummy_table_am/dummy_table_am.control     |   5 +
 .../dummy_table_am/expected/reloptions.out    | 146 +++++++++++++++
 src/test/modules/dummy_table_am/meson.build   |  33 ++++
 .../modules/dummy_table_am/sql/reloptions.sql |  89 +++++++++
 src/test/modules/meson.build                  |   1 +
 20 files changed, 898 insertions(+), 14 deletions(-)
 create mode 100644 src/test/modules/dummy_table_am/Makefile
 create mode 100644 src/test/modules/dummy_table_am/README
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am.c
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am.control
 create mode 100644 src/test/modules/dummy_table_am/expected/reloptions.out
 create mode 100644 src/test/modules/dummy_table_am/meson.build
 create mode 100644 src/test/modules/dummy_table_am/sql/reloptions.sql

diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index 453395c5c73..3d123dc17c8 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -803,6 +803,25 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
       causing future partitions to default to
       <varname>default_table_access_method</varname>.
      </para>
+     <para>
+      The new access method must accept every storage parameter
+      currently set on the table.  An access method may define its own
+      set of parameters, so a parameter that was legal under the old
+      access method is not necessarily recognized by the new one; if any
+      such parameter remains, <command>ALTER TABLE</command> raises an
+      error rather than silently dropping the value.  The unwanted
+      parameters can be cleared in the same statement, for example:
+<programlisting>
+ALTER TABLE measurement
+    SET ACCESS METHOD columnar,
+    RESET (fillfactor);
+</programlisting>
+      Validation is performed once, after all storage-parameter
+      sub-commands in the statement have been applied, so the order of
+      <literal>SET</literal>, <literal>RESET</literal>, and
+      <literal>SET ACCESS METHOD</literal> within the same
+      <command>ALTER TABLE</command> does not matter.
+     </para>
     </listitem>
    </varlistentry>
 
diff --git a/doc/src/sgml/tableam.sgml b/doc/src/sgml/tableam.sgml
index 9ccf5b739ed..d47c12177ab 100644
--- a/doc/src/sgml/tableam.sgml
+++ b/doc/src/sgml/tableam.sgml
@@ -152,4 +152,71 @@ my_tableam_handler(PG_FUNCTION_ARGS)
   its implementation.
  </para>
 
+ <sect1 id="tableam-reloptions">
+  <title>Table Access Method Storage Parameters</title>
+
+  <para>
+   A table access method may define its own set of storage parameters
+   (reloptions) by supplying an <structfield>amoptions</structfield>
+   callback in its <structname>TableAmRoutine</structname>.  The callback
+   has the same signature as the corresponding index AM callback; it is
+   invoked at <command>CREATE TABLE</command> and
+   <command>ALTER TABLE</command> time to parse and validate the option
+   set, and at relation open time (with <literal>validate = false</literal>)
+   to build the in-memory representation stored in
+   <structfield>Relation-&gt;rd_options</structfield>.  An AM that does not
+   supply an <structfield>amoptions</structfield> callback inherits the
+   standard heap parser and the <structname>StdRdOptions</structname>
+   layout.
+  </para>
+
+  <para>
+   When the AM provides its own parser it owns the option set entirely:
+   it may accept all standard heap options, only a subset, or define
+   parameters of its own.  The bytea returned from the callback is
+   stored verbatim in <structfield>rd_options</structfield>, so the AM
+   also dictates the in-memory layout that its other callbacks read.
+  </para>
+
+  <para>
+   The parser is expected to validate user-supplied values, but
+   <emphasis>must not silently rewrite them</emphasis>.  In particular
+   it must not coerce out-of-range values to a default, drop unknown
+   options when <literal>validate = true</literal>, or substitute a
+   different unit; the user must be able to verify with
+   <command>SELECT reloptions FROM pg_class</command> that the values
+   they supplied are what the relation will use.  Out-of-range or
+   unknown options should be reported with
+   <function>ereport(ERROR)</function>.
+  </para>
+
+  <para>
+   To honour an option that the core code already registers for
+   <literal>RELOPT_KIND_HEAP</literal> (for example
+   <literal>fillfactor</literal> or the <literal>autovacuum_*</literal>
+   family), call <function>add_reloption_to_kind()</function> once per
+   option in the module's <function>_PG_init</function>.  This extends
+   the existing registration with the AM's own kind without forcing
+   the AM to re-declare each option.
+  </para>
+
+  <para>
+   <command>ALTER TABLE ... SET ACCESS METHOD</command> revalidates the
+   relation's current storage parameters against the new access
+   method's parser after all <literal>SET</literal>,
+   <literal>RESET</literal>, and <literal>REPLACE</literal>
+   sub-commands in the same statement have been applied.  A parameter
+   that is not accepted by the new AM raises an error; the user can
+   clear such parameters in the same statement (see <xref
+   linkend="sql-altertable"/>).
+  </para>
+
+  <para>
+   See <filename>src/test/modules/dummy_table_am</filename> for a
+   minimal example that exercises both
+   <structfield>amoptions</structfield> and
+   <function>add_reloption_to_kind()</function>.
+  </para>
+ </sect1>
+
 </chapter>
diff --git a/src/backend/access/common/reloptions.c b/src/backend/access/common/reloptions.c
index 3e832c3797e..84f67e87646 100644
--- a/src/backend/access/common/reloptions.c
+++ b/src/backend/access/common/reloptions.c
@@ -24,6 +24,7 @@
 #include "access/nbtree.h"
 #include "access/reloptions.h"
 #include "access/spgist_private.h"
+#include "access/tableam.h"
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "commands/tablespace.h"
@@ -749,6 +750,44 @@ add_reloption_kind(void)
 	return (relopt_kind) last_assigned_kind;
 }
 
+/*
+ * add_reloption_to_kind
+ *		Extend an already-registered reloption so it is also accepted for
+ *		the given kind.
+ *
+ * Useful for table access methods that want their own RELOPT_KIND_*
+ * parser to accept standard options (fillfactor, parallel_workers,
+ * autovacuum_*, etc.) that core registers only for RELOPT_KIND_HEAP.
+ * Without this, every AM that wants the standard option set would
+ * have to re-register each option under its own kind.
+ *
+ * 'name' must match an existing option; 'kind' is OR'ed into that
+ * option's kinds mask.  Errors if no option with that name exists.
+ */
+void
+add_reloption_to_kind(const char *name, relopt_kind kind)
+{
+	int			namelen = strlen(name);
+	int			i;
+
+	if (need_initialization)
+		initialize_reloptions();
+
+	for (i = 0; relOpts[i]; i++)
+	{
+		if (relOpts[i]->namelen == namelen &&
+			strncmp(relOpts[i]->name, name, namelen) == 0)
+		{
+			relOpts[i]->kinds |= kind;
+			return;
+		}
+	}
+
+	ereport(ERROR,
+			(errcode(ERRCODE_UNDEFINED_OBJECT),
+			 errmsg("reloption \"%s\" does not exist", name)));
+}
+
 /*
  * add_reloption
  *		Add an already-created custom reloption to the list, and recompute the
@@ -1516,8 +1555,11 @@ extractRelOptions(HeapTuple tuple, TupleDesc tupdesc,
 	switch (classForm->relkind)
 	{
 		case RELKIND_RELATION:
-		case RELKIND_TOASTVALUE:
 		case RELKIND_MATVIEW:
+			options = table_reloptions(amoptions, classForm->relkind,
+									   datum, false);
+			break;
+		case RELKIND_TOASTVALUE:
 			options = heap_reloptions(classForm->relkind, datum, false);
 			break;
 		case RELKIND_PARTITIONED_TABLE:
@@ -2187,6 +2229,49 @@ heap_reloptions(char relkind, Datum reloptions, bool validate)
 	}
 }
 
+/*
+ * Parse options for a table relation, dispatching to the access method's
+ * own option parser when it supplies one.
+ *
+ *	amoptions	the table AM's option parser, or NULL to fall back to the
+ *				standard heap parser for this relkind.
+ *	relkind		the relation's kind.
+ *	reloptions	options as a text[] datum.
+ *	validate	error flag for unknown options or bad values.
+ *
+ * When amoptions is non-NULL the AM owns the option set: it may accept
+ * all standard heap options, only a subset, or define its own.  The
+ * returned bytea is laid out as the AM dictates (it is stored verbatim
+ * in Relation->rd_options).  When amoptions is NULL the result is the
+ * standard StdRdOptions layout.
+ */
+bytea *
+table_reloptions(amoptions_function amoptions, char relkind,
+				 Datum reloptions, bool validate)
+{
+	if (amoptions != NULL)
+		return amoptions(reloptions, validate);
+	return heap_reloptions(relkind, reloptions, validate);
+}
+
+/*
+ * Returns true when the relation's rd_options buffer is laid out as
+ * StdRdOptions.  Used by the rel.h accessor macros (RelationGetFillFactor,
+ * RelationIsUsedAsCatalogTable, ...) to gate StdRdOptions casts so that a
+ * table access method which supplies its own amoptions callback (and
+ * therefore owns the rd_options layout) does not have its bytes
+ * misinterpreted.
+ */
+bool
+RelationHasStdRdOptions(Relation relation)
+{
+	if (relation->rd_options == NULL)
+		return false;
+	if (relation->rd_tableam == NULL)
+		return false;
+	return relation->rd_tableam->amoptions == NULL;
+}
+
 
 /*
  * Parse options for indexes.
diff --git a/src/backend/catalog/index.c b/src/backend/catalog/index.c
index 9407c357f27..b08aa11b206 100644
--- a/src/backend/catalog/index.c
+++ b/src/backend/catalog/index.c
@@ -2871,7 +2871,8 @@ index_update_stats(Relation rel,
 	{
 		if (AutoVacuumingActive())
 		{
-			StdRdOptions *options = (StdRdOptions *) rel->rd_options;
+			StdRdOptions *options = RelationHasStdRdOptions(rel) ?
+				(StdRdOptions *) rel->rd_options : NULL;
 
 			if (options != NULL && !options->autovacuum.enabled)
 				update_stats = false;
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index eec09ba1ded..24e8b787f0b 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -693,9 +693,11 @@ static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
 								const char *tablespacename, LOCKMODE lockmode);
 static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
 static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
+static void ATValidateAccessMethodOptions(List **wqueue);
 static void ATExecSetRelOptions(Relation rel, List *defList,
 								AlterTableType operation,
-								LOCKMODE lockmode);
+								LOCKMODE lockmode,
+								Oid newAccessMethodId);
 static void ATExecEnableDisableTrigger(Relation rel, const char *trigname,
 									   char fires_when, bool skip_system, bool recurse,
 									   LOCKMODE lockmode);
@@ -961,6 +963,41 @@ DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId,
 		case RELKIND_PARTITIONED_TABLE:
 			(void) partitioned_table_reloptions(reloptions, true);
 			break;
+		case RELKIND_RELATION:
+		case RELKIND_MATVIEW:
+			{
+				amoptions_function amoptions = NULL;
+				Oid			amoid = InvalidOid;
+
+				/*
+				 * Resolve the table AM so its option parser can validate
+				 * AM-specific reloptions.  An AM that does not register a
+				 * parser falls back to default_reloptions for
+				 * RELOPT_KIND_HEAP.
+				 */
+				if (stmt->accessMethod != NULL)
+					amoid = get_table_am_oid(stmt->accessMethod, false);
+				else if (stmt->partbound != NULL && inheritOids != NIL)
+					amoid = get_rel_relam(linitial_oid(inheritOids));
+				else
+					amoid = get_table_am_oid(default_table_access_method, false);
+
+				if (OidIsValid(amoid))
+				{
+					HeapTuple	tuple;
+
+					tuple = SearchSysCache1(AMOID, ObjectIdGetDatum(amoid));
+					if (HeapTupleIsValid(tuple))
+					{
+						Form_pg_am	amform = (Form_pg_am) GETSTRUCT(tuple);
+
+						amoptions = GetTableAmRoutine(amform->amhandler)->amoptions;
+						ReleaseSysCache(tuple);
+					}
+				}
+				(void) table_reloptions(amoptions, relkind, reloptions, true);
+			}
+			break;
 		default:
 			(void) heap_reloptions(relkind, reloptions, true);
 	}
@@ -4924,6 +4961,18 @@ ATController(AlterTableStmt *parsetree,
 	/* Phase 2: update system catalogs */
 	ATRewriteCatalogs(&wqueue, lockmode, context);
 
+	/*
+	 * After all phase-2 subcommands have committed any SET / RESET / REPLACE
+	 * option changes to pg_class, but before any rewrite, ensure the final
+	 * reloptions are accepted by the access method the relation will use once
+	 * the ALTER TABLE finishes.  This catches the case where SET ACCESS
+	 * METHOD changes the AM and leaves pre-existing reloptions in pg_class
+	 * that the new AM does not recognise; without this check the new AM's
+	 * option parser would be called with validate=false at relcache load time
+	 * and silently ignore them.
+	 */
+	ATValidateAccessMethodOptions(&wqueue);
+
 	/* Phase 3: scan/rewrite tables as needed, and run afterStmts */
 	ATRewriteTables(parsetree, &wqueue, lockmode, context);
 }
@@ -5595,7 +5644,17 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
 		case AT_SetRelOptions:	/* SET (...) */
 		case AT_ResetRelOptions:	/* RESET (...) */
 		case AT_ReplaceRelOptions:	/* replace entire option list */
-			ATExecSetRelOptions(rel, (List *) cmd->def, cmd->subtype, lockmode);
+
+			/*
+			 * If SET ACCESS METHOD is queued in the same ALTER TABLE, the
+			 * reloptions in pg_class will be parsed by the new AM after the
+			 * statement finishes; tell ATExecSetRelOptions to validate
+			 * against that AM rather than the relation's current AM.  This
+			 * lets a user write ALTER TABLE t SET ACCESS METHOD x, SET (foo =
+			 * bar) where foo is recognised by x but not by the current AM.
+			 */
+			ATExecSetRelOptions(rel, (List *) cmd->def, cmd->subtype, lockmode,
+								tab->chgAccessMethod ? tab->newAccessMethod : InvalidOid);
 			break;
 		case AT_EnableTrig:		/* ENABLE TRIGGER name */
 			ATExecEnableDisableTrigger(rel, cmd->name,
@@ -16885,12 +16944,92 @@ ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel, const char *tablespacen
 	tab->newTableSpace = tablespaceId;
 }
 
+/*
+ * Re-validate pg_class.reloptions for every work-queue entry whose access
+ * method is being changed.  Called between phase 2 (catalog updates) and
+ * phase 3 (table rewrites): SET / RESET / REPLACE subcommands have already
+ * been committed to pg_class, and tab->newAccessMethod identifies the AM
+ * the relation will use once the ALTER TABLE finishes.
+ *
+ * The check exists because relcache.c calls the AM's option parser with
+ * validate=false at relation open: any pre-existing reloption that the
+ * new AM does not recognise would otherwise be silently dropped from the
+ * parsed StdRdOptions / AM-specific options struct, leaving the user
+ * unable to tell that the option is no longer in effect.  Failing the
+ * ALTER TABLE here with a clear message lets the user RESET the option
+ * in the same statement and re-run.
+ */
+static void
+ATValidateAccessMethodOptions(List **wqueue)
+{
+	ListCell   *ltab;
+
+	foreach(ltab, *wqueue)
+	{
+		AlteredTableInfo *tab = (AlteredTableInfo *) lfirst(ltab);
+		HeapTuple	amtup;
+		HeapTuple	reltup;
+		Form_pg_am	amform;
+		Form_pg_class relform;
+		amoptions_function amoptions;
+		Datum		reloptions;
+		bool		isnull;
+		Oid			amoid;
+
+		if (!tab->chgAccessMethod)
+			continue;
+
+		/*
+		 * Partitioned tables may reset the AM to "default" (InvalidOid); each
+		 * partition then chooses its own AM at create time, so there is no
+		 * per-relation AM whose parser to consult here.
+		 */
+		amoid = tab->newAccessMethod;
+		if (!OidIsValid(amoid))
+			continue;
+
+		amtup = SearchSysCache1(AMOID, ObjectIdGetDatum(amoid));
+		if (!HeapTupleIsValid(amtup))
+			elog(ERROR, "cache lookup failed for access method %u", amoid);
+		amform = (Form_pg_am) GETSTRUCT(amtup);
+		amoptions = GetTableAmRoutine(amform->amhandler)->amoptions;
+		ReleaseSysCache(amtup);
+
+		/*
+		 * If the new AM has no option parser of its own, table_reloptions
+		 * falls back to the standard heap parser, which accepts whatever the
+		 * old AM accepted (every other AM in core uses the same StdRdOptions
+		 * today), so there is nothing to re-check.
+		 */
+		if (amoptions == NULL)
+			continue;
+
+		reltup = SearchSysCache1(RELOID, ObjectIdGetDatum(tab->relid));
+		if (!HeapTupleIsValid(reltup))
+			elog(ERROR, "cache lookup failed for relation %u", tab->relid);
+		relform = (Form_pg_class) GETSTRUCT(reltup);
+		reloptions = SysCacheGetAttr(RELOID, reltup,
+									 Anum_pg_class_reloptions, &isnull);
+		if (!isnull)
+			(void) table_reloptions(amoptions, relform->relkind,
+									reloptions, true);
+		ReleaseSysCache(reltup);
+	}
+}
+
 /*
  * Set, reset, or replace reloptions.
+ *
+ * newAccessMethodId, if valid, names the table access method whose option
+ * parser should validate the resulting reloptions.  This is used when SET
+ * ACCESS METHOD is queued in the same ALTER TABLE so that the new options
+ * are checked against the AM the relation will use after the statement
+ * finishes, not the AM it has now.  Pass InvalidOid to use the relation's
+ * current access method.
  */
 static void
 ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
-					LOCKMODE lockmode)
+					LOCKMODE lockmode, Oid newAccessMethodId)
 {
 	Oid			relid;
 	Relation	pgclass;
@@ -16942,7 +17081,30 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
 	{
 		case RELKIND_RELATION:
 		case RELKIND_MATVIEW:
-			(void) heap_reloptions(rel->rd_rel->relkind, newOptions, true);
+			{
+				amoptions_function amoptions;
+
+				if (OidIsValid(newAccessMethodId))
+				{
+					HeapTuple	amtup;
+					Form_pg_am	amform;
+
+					amtup = SearchSysCache1(AMOID,
+											ObjectIdGetDatum(newAccessMethodId));
+					if (!HeapTupleIsValid(amtup))
+						elog(ERROR, "cache lookup failed for access method %u",
+							 newAccessMethodId);
+					amform = (Form_pg_am) GETSTRUCT(amtup);
+					amoptions = GetTableAmRoutine(amform->amhandler)->amoptions;
+					ReleaseSysCache(amtup);
+				}
+				else
+					amoptions = (rel->rd_tableam ?
+								 rel->rd_tableam->amoptions : NULL);
+
+				(void) table_reloptions(amoptions, rel->rd_rel->relkind,
+										newOptions, true);
+			}
 			break;
 		case RELKIND_PARTITIONED_TABLE:
 			(void) partitioned_table_reloptions(newOptions, true);
diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c
index 99d0db82ed7..68d1a5369fb 100644
--- a/src/backend/commands/vacuum.c
+++ b/src/backend/commands/vacuum.c
@@ -2185,7 +2185,7 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 	{
 		StdRdOptIndexCleanup vacuum_index_cleanup;
 
-		if (rel->rd_options == NULL)
+		if (!RelationHasStdRdOptions(rel))
 			vacuum_index_cleanup = STDRD_OPTION_VACUUM_INDEX_CLEANUP_AUTO;
 		else
 			vacuum_index_cleanup =
@@ -2216,7 +2216,7 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 	 * Check if the vacuum_max_eager_freeze_failure_rate table storage
 	 * parameter was specified. This overrides the GUC value.
 	 */
-	if (rel->rd_options != NULL &&
+	if (RelationHasStdRdOptions(rel) &&
 		((StdRdOptions *) rel->rd_options)->vacuum_max_eager_freeze_failure_rate >= 0)
 		params.max_eager_freeze_failure_rate =
 			((StdRdOptions *) rel->rd_options)->vacuum_max_eager_freeze_failure_rate;
@@ -2227,7 +2227,8 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 	 */
 	if (params.truncate == VACOPTVALUE_UNSPECIFIED)
 	{
-		StdRdOptions *opts = (StdRdOptions *) rel->rd_options;
+		StdRdOptions *opts = RelationHasStdRdOptions(rel) ?
+			(StdRdOptions *) rel->rd_options : NULL;
 
 		if (opts && opts->vacuum_truncate != PG_TERNARY_UNSET)
 		{
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index e19f0d3e51c..958c13f8338 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -481,9 +481,11 @@ RelationParseRelOptions(Relation relation, HeapTuple tuple)
 	switch (relation->rd_rel->relkind)
 	{
 		case RELKIND_RELATION:
+		case RELKIND_MATVIEW:
+			amoptsfn = relation->rd_tableam ? relation->rd_tableam->amoptions : NULL;
+			break;
 		case RELKIND_TOASTVALUE:
 		case RELKIND_VIEW:
-		case RELKIND_MATVIEW:
 		case RELKIND_PARTITIONED_TABLE:
 			amoptsfn = NULL;
 			break;
diff --git a/src/include/access/reloptions.h b/src/include/access/reloptions.h
index e8cb7f7a627..1282bccc77f 100644
--- a/src/include/access/reloptions.h
+++ b/src/include/access/reloptions.h
@@ -187,6 +187,7 @@ typedef struct local_relopts
 	 (char *)(optstruct) + (optstruct)->member)
 
 extern relopt_kind add_reloption_kind(void);
+extern void add_reloption_to_kind(const char *name, relopt_kind kind);
 extern void add_bool_reloption(uint32 kinds, const char *name, const char *desc,
 							   bool default_val, LOCKMODE lockmode);
 extern void add_ternary_reloption(uint32 kinds, const char *name,
@@ -248,6 +249,8 @@ extern void *build_local_reloptions(local_relopts *relopts, Datum options,
 extern bytea *default_reloptions(Datum reloptions, bool validate,
 								 relopt_kind kind);
 extern bytea *heap_reloptions(char relkind, Datum reloptions, bool validate);
+extern bytea *table_reloptions(amoptions_function amoptions, char relkind,
+							   Datum reloptions, bool validate);
 extern bytea *view_reloptions(Datum reloptions, bool validate);
 extern bytea *partitioned_table_reloptions(Datum reloptions, bool validate);
 extern bytea *index_reloptions(amoptions_function amoptions, Datum reloptions,
diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h
index c13f05d39db..1816d04e4df 100644
--- a/src/include/access/tableam.h
+++ b/src/include/access/tableam.h
@@ -17,6 +17,7 @@
 #ifndef TABLEAM_H
 #define TABLEAM_H
 
+#include "access/amapi.h"
 #include "access/relscan.h"
 #include "access/sdir.h"
 #include "access/xact.h"
@@ -324,6 +325,37 @@ typedef struct TableAmRoutine
 	NodeTag		type;
 
 
+	/* ------------------------------------------------------------------------
+	 * Reloption parsing.
+	 * ------------------------------------------------------------------------
+	 */
+
+	/*
+	 * Parse and validate AM-specific reloptions.  Optional: when NULL, the
+	 * caller falls back to the standard heap reloption parser
+	 * (default_reloptions with RELOPT_KIND_HEAP) and the result is laid out
+	 * as StdRdOptions.
+	 *
+	 * When non-NULL, the AM owns the option set entirely.  It is free to
+	 * accept all standard heap options, only a subset, or to add its own. The
+	 * returned bytea must begin with a VARSIZE header and is stored in
+	 * Relation->rd_options, so the AM dictates the in-memory layout that its
+	 * other callbacks read.  Core code that reads StdRdOptions fields out of
+	 * rd_options (RelationGetFillFactor, RelationIsUsedAsCatalogTable, ...)
+	 * gates on RelationHasStdRdOptions(), so a custom layout will not be
+	 * misinterpreted.
+	 *
+	 * The callback validates user-supplied values but must not silently
+	 * rewrite them: a user inspecting pg_class.reloptions must see exactly
+	 * what they passed in.  Out-of-range or unknown options should be
+	 * reported with ereport(ERROR) when validate is true.
+	 *
+	 * Signature matches the index AM's amoptions callback so the same helper
+	 * machinery (add_string_reloption, add_int_reloption, etc.) can be used.
+	 */
+	amoptions_function amoptions;
+
+
 	/* ------------------------------------------------------------------------
 	 * Slot related callbacks.
 	 * ------------------------------------------------------------------------
diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h
index cd1e92f2302..d4c484fd13b 100644
--- a/src/include/utils/rel.h
+++ b/src/include/utils/rel.h
@@ -361,12 +361,29 @@ typedef struct StdRdOptions
 #define HEAP_MIN_FILLFACTOR			10
 #define HEAP_DEFAULT_FILLFACTOR		100
 
+/*
+ * RelationHasStdRdOptions
+ *		Returns true when the relation's rd_options buffer is laid out as
+ *		StdRdOptions, i.e. it was produced by the standard heap reloption
+ *		parser.  A table access method that supplies its own amoptions
+ *		callback owns its rd_options layout and is not required to expose
+ *		StdRdOptions fields; macros that read those fields must check this
+ *		first to avoid reading garbage data.  For indexes and other
+ *		relkinds rd_options is in an AM-specific layout, so this returns
+ *		false for them.
+ *
+ *		Defined as a function (in reloptions.c) rather than a macro
+ *		because the test needs the full TableAmRoutine struct definition,
+ *		which would create an #include cycle if pulled into rel.h.
+ */
+extern bool RelationHasStdRdOptions(Relation relation);
+
 /*
  * RelationGetToastTupleTarget
  *		Returns the relation's toast_tuple_target.  Note multiple eval of argument!
  */
 #define RelationGetToastTupleTarget(relation, defaulttarg) \
-	((relation)->rd_options ? \
+	(RelationHasStdRdOptions(relation) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->toast_tuple_target : (defaulttarg))
 
 /*
@@ -374,7 +391,7 @@ typedef struct StdRdOptions
  *		Returns the relation's fillfactor.  Note multiple eval of argument!
  */
 #define RelationGetFillFactor(relation, defaultff) \
-	((relation)->rd_options ? \
+	(RelationHasStdRdOptions(relation) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->fillfactor : (defaultff))
 
 /*
@@ -397,7 +414,7 @@ typedef struct StdRdOptions
  *		from the pov of logical decoding.  Note multiple eval of argument!
  */
 #define RelationIsUsedAsCatalogTable(relation)	\
-	((relation)->rd_options && \
+	(RelationHasStdRdOptions(relation) && \
 	 ((relation)->rd_rel->relkind == RELKIND_RELATION || \
 	  (relation)->rd_rel->relkind == RELKIND_MATVIEW) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->user_catalog_table : false)
@@ -408,7 +425,7 @@ typedef struct StdRdOptions
  *		Note multiple eval of argument!
  */
 #define RelationGetParallelWorkers(relation, defaultpw) \
-	((relation)->rd_options ? \
+	(RelationHasStdRdOptions(relation) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->parallel_workers : (defaultpw))
 
 /* ViewOptions->check_option values */
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 0a74ab5c86f..223005fdf98 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -10,6 +10,7 @@ SUBDIRS = \
 		  delay_execution \
 		  dummy_index_am \
 		  dummy_seclabel \
+		  dummy_table_am \
 		  index \
 		  libpq_pipeline \
 		  oauth_validator \
diff --git a/src/test/modules/dummy_table_am/Makefile b/src/test/modules/dummy_table_am/Makefile
new file mode 100644
index 00000000000..94837dff392
--- /dev/null
+++ b/src/test/modules/dummy_table_am/Makefile
@@ -0,0 +1,20 @@
+# src/test/modules/dummy_table_am/Makefile
+
+MODULES = dummy_table_am
+
+EXTENSION = dummy_table_am
+DATA = dummy_table_am--1.0.sql
+PGFILEDESC = "dummy_table_am - table access method template"
+
+REGRESS = reloptions
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/dummy_table_am
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/dummy_table_am/README b/src/test/modules/dummy_table_am/README
new file mode 100644
index 00000000000..a234a1f107f
--- /dev/null
+++ b/src/test/modules/dummy_table_am/README
@@ -0,0 +1,21 @@
+Dummy Table AM
+==============
+
+Dummy table AM is a module for testing the table access method
+amoptions callback and the add_reloption_to_kind() helper.  It
+delegates all storage and scan callbacks to the heap AM and only
+swaps in its own option parser, so a relation created with USING
+dummy_table_am behaves like a heap table but accepts a different
+set of reloptions:
+
+  - "fillfactor"     (inherited from the core heap registration via
+                      add_reloption_to_kind)
+  - "option_int"     (integer)
+  - "option_real"    (real)
+  - "option_bool"    (boolean)
+  - "option_enum"    (enum, one|two)
+
+Standard heap options such as parallel_workers, autovacuum_*, and
+toast_tuple_target are intentionally NOT accepted, to exercise the
+"AM rejects an unknown option" path in ALTER TABLE ... SET ACCESS
+METHOD revalidation.
diff --git a/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql b/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
new file mode 100644
index 00000000000..2e295b95845
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
@@ -0,0 +1,13 @@
+/* src/test/modules/dummy_table_am/dummy_table_am--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION dummy_table_am" to load this file. \quit
+
+CREATE FUNCTION dthandler(internal)
+RETURNS table_am_handler
+AS 'MODULE_PATHNAME'
+LANGUAGE C;
+
+-- Access method
+CREATE ACCESS METHOD dummy_table_am TYPE TABLE HANDLER dthandler;
+COMMENT ON ACCESS METHOD dummy_table_am IS 'dummy table access method';
diff --git a/src/test/modules/dummy_table_am/dummy_table_am.c b/src/test/modules/dummy_table_am/dummy_table_am.c
new file mode 100644
index 00000000000..834a9acd5bb
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am.c
@@ -0,0 +1,166 @@
+/*-------------------------------------------------------------------------
+ *
+ * dummy_table_am.c
+ *		Table AM template main file.
+ *
+ * This module exists primarily to demonstrate and exercise the table AM
+ * amoptions callback and the add_reloption_to_kind() helper.  Storage
+ * and scan callbacks are delegated to the heap AM, so a relation
+ * created with USING dummy_table_am behaves like a heap table; only the
+ * reloption surface differs.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/test/modules/dummy_table_am/dummy_table_am.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/reloptions.h"
+#include "access/tableam.h"
+#include "fmgr.h"
+
+PG_MODULE_MAGIC;
+
+/* Parse table for build_reloptions */
+static relopt_parse_elt dt_relopt_tab[5];
+
+/* Kind of relation options for dummy table */
+static relopt_kind dt_relopt_kind;
+
+typedef enum DummyTableEnum
+{
+	DUMMY_TABLE_ENUM_ONE,
+	DUMMY_TABLE_ENUM_TWO,
+}			DummyTableEnum;
+
+/*
+ * Dummy table options.
+ *
+ * The first two fields are the standard heap options (fillfactor +
+ * autovacuum_enabled) that we inherit by calling add_reloption_to_kind()
+ * on the matching names; the remaining ones are AM-specific options.
+ */
+typedef struct DummyTableOptions
+{
+	int32		vl_len_;		/* varlena header (do not touch directly!) */
+	int			fillfactor;
+	int			option_int;
+	double		option_real;
+	bool		option_bool;
+	DummyTableEnum option_enum;
+}			DummyTableOptions;
+
+static relopt_enum_elt_def dummyTableEnumValues[] =
+{
+	{"one", DUMMY_TABLE_ENUM_ONE},
+	{"two", DUMMY_TABLE_ENUM_TWO},
+	{(const char *) NULL}		/* list terminator */
+};
+
+PG_FUNCTION_INFO_V1(dthandler);
+
+/*
+ * Register a relopt_kind for this AM and populate the parse table.
+ */
+static void
+create_reloptions_table(void)
+{
+	int			i = 0;
+
+	dt_relopt_kind = add_reloption_kind();
+
+	/*
+	 * Accept the standard "fillfactor" option (registered by core for
+	 * RELOPT_KIND_HEAP only) under our own kind.  This is the canonical use
+	 * of add_reloption_to_kind(): an AM that wants to honour an existing
+	 * core-registered option without duplicating its definition.
+	 */
+	add_reloption_to_kind("fillfactor", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "fillfactor";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, fillfactor);
+	i++;
+
+	add_int_reloption(dt_relopt_kind, "option_int",
+					  "Integer option for dummy_table_am",
+					  10, -10, 100, AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_int";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_int);
+	i++;
+
+	add_real_reloption(dt_relopt_kind, "option_real",
+					   "Real option for dummy_table_am",
+					   3.1415, -10, 100, AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_real";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_REAL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_real);
+	i++;
+
+	add_bool_reloption(dt_relopt_kind, "option_bool",
+					   "Boolean option for dummy_table_am",
+					   true, AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_bool";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_BOOL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_bool);
+	i++;
+
+	add_enum_reloption(dt_relopt_kind, "option_enum",
+					   "Enum option for dummy_table_am",
+					   dummyTableEnumValues,
+					   DUMMY_TABLE_ENUM_ONE,
+					   "Valid values are \"one\" and \"two\".",
+					   AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_enum";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_ENUM;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_enum);
+	i++;
+}
+
+/*
+ * Parse reloptions for dummy_table_am.
+ *
+ * Returning DummyTableOptions tells the caller (relcache.c) to store
+ * exactly that layout in Relation->rd_options.
+ */
+static bytea *
+dtoptions(Datum reloptions, bool validate)
+{
+	return (bytea *) build_reloptions(reloptions, validate,
+									  dt_relopt_kind,
+									  sizeof(DummyTableOptions),
+									  dt_relopt_tab, lengthof(dt_relopt_tab));
+}
+
+/*
+ * Handler for table AM.
+ *
+ * All storage-side callbacks are inherited from heap; we only swap in
+ * our own amoptions so that the AM owns its reloption set.  This keeps
+ * the example focused on the new API without duplicating the heap AM.
+ */
+Datum
+dthandler(PG_FUNCTION_ARGS)
+{
+	static TableAmRoutine routine;
+	static bool initialized = false;
+
+	if (!initialized)
+	{
+		memcpy(&routine, GetHeapamTableAmRoutine(), sizeof(routine));
+		routine.amoptions = dtoptions;
+		initialized = true;
+	}
+
+	PG_RETURN_POINTER(&routine);
+}
+
+void
+_PG_init(void)
+{
+	create_reloptions_table();
+}
diff --git a/src/test/modules/dummy_table_am/dummy_table_am.control b/src/test/modules/dummy_table_am/dummy_table_am.control
new file mode 100644
index 00000000000..08f2f868d49
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am.control
@@ -0,0 +1,5 @@
+# dummy_table_am extension
+comment = 'dummy_table_am - table access method template'
+default_version = '1.0'
+module_pathname = '$libdir/dummy_table_am'
+relocatable = true
diff --git a/src/test/modules/dummy_table_am/expected/reloptions.out b/src/test/modules/dummy_table_am/expected/reloptions.out
new file mode 100644
index 00000000000..c6cc6d21da7
--- /dev/null
+++ b/src/test/modules/dummy_table_am/expected/reloptions.out
@@ -0,0 +1,146 @@
+-- Tests for the table AM amoptions callback and add_reloption_to_kind()
+CREATE EXTENSION dummy_table_am;
+-- Sanity: CREATE TABLE with AM-specific options succeeds and round-trips
+CREATE TABLE dummy_t (a int) USING dummy_table_am
+    WITH (option_int = 17, option_real = 2.5, option_bool = false,
+          option_enum = 'two', fillfactor = 60);
+SELECT reloptions FROM pg_class
+    WHERE oid = 'dummy_t'::regclass ORDER BY reloptions;
+                                   reloptions                                    
+---------------------------------------------------------------------------------
+ {option_int=17,option_real=2.5,option_bool=false,option_enum=two,fillfactor=60}
+(1 row)
+
+-- AM-specific option ranges are enforced (option_int allows -10..100)
+CREATE TABLE dummy_oor (a int) USING dummy_table_am WITH (option_int = 9999);
+ERROR:  value 9999 out of bounds for option "option_int"
+DETAIL:  Valid values are between "-10" and "100".
+-- Unknown options are rejected at CREATE TABLE time
+CREATE TABLE dummy_bad (a int) USING dummy_table_am WITH (parallel_workers = 4);
+ERROR:  unrecognized parameter "parallel_workers"
+-- Default values land in pg_class only when the user did not set them
+CREATE TABLE dummy_defaults (a int) USING dummy_table_am;
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_defaults'::regclass;
+ reloptions 
+------------
+ 
+(1 row)
+
+DROP TABLE dummy_defaults;
+-- ALTER TABLE ... SET (...) with AM-specific option
+ALTER TABLE dummy_t SET (option_int = 42);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+                                   reloptions                                    
+---------------------------------------------------------------------------------
+ {option_real=2.5,option_bool=false,option_enum=two,fillfactor=60,option_int=42}
+(1 row)
+
+-- ALTER TABLE ... SET (...) with an unknown option errors
+ALTER TABLE dummy_t SET (parallel_workers = 4);
+ERROR:  unrecognized parameter "parallel_workers"
+-- ALTER TABLE ... RESET (option) round-trips
+ALTER TABLE dummy_t RESET (option_int);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+                            reloptions                             
+-------------------------------------------------------------------
+ {option_real=2.5,option_bool=false,option_enum=two,fillfactor=60}
+(1 row)
+
+-- SET ACCESS METHOD revalidation:
+--   moving a heap table that has standard heap options not accepted by the
+--   new AM (parallel_workers) into dummy_table_am must fail with a clear
+--   message and must NOT silently drop the option.
+CREATE TABLE heap_t (a int) WITH (fillfactor = 70, parallel_workers = 4);
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+             reloptions             
+------------------------------------
+ {fillfactor=70,parallel_workers=4}
+(1 row)
+
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am;
+ERROR:  unrecognized parameter "parallel_workers"
+-- Confirm nothing changed
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+ amname 
+--------
+ heap
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+             reloptions             
+------------------------------------
+ {fillfactor=70,parallel_workers=4}
+(1 row)
+
+-- After RESETing the offending option in the same statement the swap
+-- succeeds; fillfactor survives because dummy_table_am inherits it via
+-- add_reloption_to_kind().
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am, RESET (parallel_workers);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+     amname     
+----------------
+ dummy_table_am
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+   reloptions    
+-----------------
+ {fillfactor=70}
+(1 row)
+
+-- Going back to heap still works: heap accepts fillfactor.
+ALTER TABLE heap_t SET ACCESS METHOD heap;
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+ amname 
+--------
+ heap
+(1 row)
+
+-- SET ACCESS METHOD + SET (...) of an option that only the new AM accepts.
+CREATE TABLE heap_to_dt (a int);
+ALTER TABLE heap_to_dt SET ACCESS METHOD dummy_table_am, SET (option_int = 25);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+     amname     
+----------------
+ dummy_table_am
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+   reloptions    
+-----------------
+ {option_int=25}
+(1 row)
+
+-- Partitioned-table inheritance: AM declared on the parent partition flows
+-- to partitions that don't override it.  Partitioned tables themselves
+-- cannot carry reloptions; the test verifies the AM lookup that
+-- DefineRelation does for partitions.
+CREATE TABLE parted (a int) PARTITION BY RANGE (a) USING dummy_table_am;
+CREATE TABLE parted_p1 PARTITION OF parted FOR VALUES FROM (0) TO (100)
+    WITH (option_int = 11);
+SELECT c.relname,
+       (SELECT amname FROM pg_am WHERE oid = c.relam) AS amname,
+       c.reloptions
+    FROM pg_class c
+    WHERE c.oid IN ('parted'::regclass, 'parted_p1'::regclass)
+    ORDER BY c.relname;
+  relname  |     amname     |   reloptions    
+-----------+----------------+-----------------
+ parted    | dummy_table_am | 
+ parted_p1 | dummy_table_am | {option_int=11}
+(2 rows)
+
+-- A partition that explicitly chooses heap must reject options that are
+-- only known to the parent's AM.
+CREATE TABLE parted_p2 PARTITION OF parted FOR VALUES FROM (100) TO (200)
+    USING heap WITH (option_int = 9);
+ERROR:  unrecognized parameter "option_int"
+DROP TABLE parted;
+DROP TABLE heap_to_dt;
+DROP TABLE heap_t;
+DROP TABLE dummy_t;
+DROP EXTENSION dummy_table_am;
diff --git a/src/test/modules/dummy_table_am/meson.build b/src/test/modules/dummy_table_am/meson.build
new file mode 100644
index 00000000000..ad3fa2410cc
--- /dev/null
+++ b/src/test/modules/dummy_table_am/meson.build
@@ -0,0 +1,33 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+dummy_table_am_sources = files(
+  'dummy_table_am.c',
+)
+
+if host_system == 'windows'
+  dummy_table_am_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'dummy_table_am',
+    '--FILEDESC', 'dummy_table_am - table access method template',])
+endif
+
+dummy_table_am = shared_module('dummy_table_am',
+  dummy_table_am_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += dummy_table_am
+
+test_install_data += files(
+  'dummy_table_am.control',
+  'dummy_table_am--1.0.sql',
+)
+
+tests += {
+  'name': 'dummy_table_am',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'reloptions',
+    ],
+  },
+}
diff --git a/src/test/modules/dummy_table_am/sql/reloptions.sql b/src/test/modules/dummy_table_am/sql/reloptions.sql
new file mode 100644
index 00000000000..1444247b367
--- /dev/null
+++ b/src/test/modules/dummy_table_am/sql/reloptions.sql
@@ -0,0 +1,89 @@
+-- Tests for the table AM amoptions callback and add_reloption_to_kind()
+CREATE EXTENSION dummy_table_am;
+
+-- Sanity: CREATE TABLE with AM-specific options succeeds and round-trips
+CREATE TABLE dummy_t (a int) USING dummy_table_am
+    WITH (option_int = 17, option_real = 2.5, option_bool = false,
+          option_enum = 'two', fillfactor = 60);
+SELECT reloptions FROM pg_class
+    WHERE oid = 'dummy_t'::regclass ORDER BY reloptions;
+
+-- AM-specific option ranges are enforced (option_int allows -10..100)
+CREATE TABLE dummy_oor (a int) USING dummy_table_am WITH (option_int = 9999);
+
+-- Unknown options are rejected at CREATE TABLE time
+CREATE TABLE dummy_bad (a int) USING dummy_table_am WITH (parallel_workers = 4);
+
+-- Default values land in pg_class only when the user did not set them
+CREATE TABLE dummy_defaults (a int) USING dummy_table_am;
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_defaults'::regclass;
+DROP TABLE dummy_defaults;
+
+-- ALTER TABLE ... SET (...) with AM-specific option
+ALTER TABLE dummy_t SET (option_int = 42);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+
+-- ALTER TABLE ... SET (...) with an unknown option errors
+ALTER TABLE dummy_t SET (parallel_workers = 4);
+
+-- ALTER TABLE ... RESET (option) round-trips
+ALTER TABLE dummy_t RESET (option_int);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+
+-- SET ACCESS METHOD revalidation:
+--   moving a heap table that has standard heap options not accepted by the
+--   new AM (parallel_workers) into dummy_table_am must fail with a clear
+--   message and must NOT silently drop the option.
+CREATE TABLE heap_t (a int) WITH (fillfactor = 70, parallel_workers = 4);
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am;
+-- Confirm nothing changed
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+
+-- After RESETing the offending option in the same statement the swap
+-- succeeds; fillfactor survives because dummy_table_am inherits it via
+-- add_reloption_to_kind().
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am, RESET (parallel_workers);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+
+-- Going back to heap still works: heap accepts fillfactor.
+ALTER TABLE heap_t SET ACCESS METHOD heap;
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+
+-- SET ACCESS METHOD + SET (...) of an option that only the new AM accepts.
+CREATE TABLE heap_to_dt (a int);
+ALTER TABLE heap_to_dt SET ACCESS METHOD dummy_table_am, SET (option_int = 25);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+
+-- Partitioned-table inheritance: AM declared on the parent partition flows
+-- to partitions that don't override it.  Partitioned tables themselves
+-- cannot carry reloptions; the test verifies the AM lookup that
+-- DefineRelation does for partitions.
+CREATE TABLE parted (a int) PARTITION BY RANGE (a) USING dummy_table_am;
+CREATE TABLE parted_p1 PARTITION OF parted FOR VALUES FROM (0) TO (100)
+    WITH (option_int = 11);
+SELECT c.relname,
+       (SELECT amname FROM pg_am WHERE oid = c.relam) AS amname,
+       c.reloptions
+    FROM pg_class c
+    WHERE c.oid IN ('parted'::regclass, 'parted_p1'::regclass)
+    ORDER BY c.relname;
+
+-- A partition that explicitly chooses heap must reject options that are
+-- only known to the parent's AM.
+CREATE TABLE parted_p2 PARTITION OF parted FOR VALUES FROM (100) TO (200)
+    USING heap WITH (option_int = 9);
+
+DROP TABLE parted;
+DROP TABLE heap_to_dt;
+DROP TABLE heap_t;
+DROP TABLE dummy_t;
+
+DROP EXTENSION dummy_table_am;
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 4bca42bb370..07b6b24a5ab 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -5,6 +5,7 @@ subdir('commit_ts')
 subdir('delay_execution')
 subdir('dummy_index_am')
 subdir('dummy_seclabel')
+subdir('dummy_table_am')
 subdir('gin')
 subdir('index')
 subdir('injection_points')
-- 
2.43.0



^ permalink  raw  reply  [nested|flat] 25+ messages in thread

* Re: Allow table AMs to define their own reloptions
@ 2026-07-16 13:37  Rafia Sabih <rafia.pghackers@gmail.com>
  parent: Andrew Dunstan <andrew@dunslane.net>
  0 siblings, 1 reply; 25+ messages in thread

From: Rafia Sabih @ 2026-07-16 13:37 UTC (permalink / raw)
  To: Andrew Dunstan <andrew@dunslane.net>; +Cc: Julien Tachoires <julien@tachoires.me>; pgsql-hackers

On Thu, 4 Jun 2026 at 04:32, Andrew Dunstan <andrew@dunslane.net> wrote:

>
> On 2025-05-26 Mo 7:06 AM, Julien Tachoires wrote:
> > On Sat, Mar 29, 2025 at 08:46:01AM +0100, Julien Tachoires wrote:
> >> On Sun, Mar 02, 2025 at 02:23:54PM +0100, Julien Tachoires wrote:
> >>> On Sun, Mar 02, 2025 at 09:56:41AM +0100, Julien Tachoires wrote:
> >>>> With the help of the new TAM routine 'relation_options', table access
> >>>> methods can with this patch define their own reloptions
> >>>> parser/validator.
> >>>>
> >>>> These reloptions can be set via the following commands:
> >>>> 1. CREATE TABLE ... USING table_am
> >>>>         WITH (option1='value1', option2='value2');
> >>>> 2. ALTER TABLE ...
> >>>>         SET (option1 'value1', option2 'value2');
> >>>> 3. ALTER TABLE ... SET ACCESS METHOD table_am
> >>>>         OPTIONS (option1 'value1', option2 'value2');
> >>>>
> >>>> When changing table's access method, the settings inherited from the
> >>>> former TAM can be dropped (if not supported by the new TAM) via: DROP
> >>>> option, or, updated via: SET option 'value'.
> >>>>
> >>>> Currently, tables using different TAMs than heap are able to use
> heap's
> >>>> reloptions (fillfactor, toast_tuple_target, etc...). With this patch
> >>>> applied, this is not the case anymore: if the TAM needs to have access
> >>>> to similar settings to heap ones, they have to explicitly define them.
> >>>>
> >>>> The 2nd patch file includes a new test module 'dummy_table_am' which
> >>>> implements a dummy table access method utilized to exercise TAM
> >>>> reloptions. This test module is strongly based on what we already have
> >>>> in 'dummy_index_am'. 'dummy_table_am' provides a complete example of
> TAM
> >>>> reloptions definition.
> >>>>
> >>>> This work is directly derived from SadhuPrasad's patch here [2].
> Others
> >>>> attempts were posted here [1] and here [3].
> >>>>
> >>>> [1]
> https://www.postgresql.org/message-id/flat/429fb58fa3218221bb17c7bf9e70e1aa6cfc6b5d.camel%40j-davis....
> >>>> [2]
> https://www.postgresql.org/message-id/flat/CAFF0-CG4KZHdtYHMsonWiXNzj16gWZpduXAn8yF7pDDub+GQMg@mail....
> >>>> [3]
> https://www.postgresql.org/message-id/flat/AMUA1wBBBxfc3tKRLLdU64rb.1.1683276279979.Hmail.wuhao%40ha...
> >>> Please find a new version including minor fixes: 'TAM' terms are
> >>> replaced by 'table AM'
> >> Please find a new rebased version.
> > New rebased version.
> >
>
>
> This thread seems to have gone dormant, sadly. Here's a fresh attempt I
> made (with assistance from Claude), before I was aware of the existence
> of this and other efforts. I think it meets most of the previous
> objections, and is in line with what we do for Index AMs.
>
> I was testing this patch and found this issue,
CREATE EXTENSION dummy_table_am;
CREATE TABLE t (a int) USING dummy_table_am WITH (option_int = 42);
ALTER TABLE t SET ACCESS METHOD heap;
SELECT reloptions FROM pg_class WHERE oid = 't'::regclass;
  reloptions
-----------------
 {option_int=42}
(1 row)
ALTER TABLE t SET (fillfactor = 50);
2026-07-16 19:01:36.060 IST [95058] ERROR:  unrecognized parameter
"option_int"
2026-07-16 19:01:36.060 IST [95058] STATEMENT:  ALTER TABLE t SET
(fillfactor = 50);
ERROR:  unrecognized parameter "option_int"

Basically, the option is left behind after changing the tableam to heap, I
think that isn't intentional.

>
> cheers
>
>
> andrew
>
>
> --
> Andrew Dunstan
> EDB: https://www.enterprisedb.com
>


-- 
Regards,
Rafia Sabih
CYBERTEC PostgreSQL International GmbH

^ permalink  raw  reply  [nested|flat] 25+ messages in thread

* Re: Allow table AMs to define their own reloptions
@ 2026-07-16 14:32  Andrew Dunstan <andrew@dunslane.net>
  parent: Rafia Sabih <rafia.pghackers@gmail.com>
  0 siblings, 3 replies; 25+ messages in thread

From: Andrew Dunstan @ 2026-07-16 14:32 UTC (permalink / raw)
  To: Rafia Sabih <rafia.pghackers@gmail.com>; +Cc: Julien Tachoires <julien@tachoires.me>; pgsql-hackers

On Thu, Jul 16, 2026 at 9:37 AM Rafia Sabih <rafia.pghackers@gmail.com>
wrote:

>
>
> On Thu, 4 Jun 2026 at 04:32, Andrew Dunstan <andrew@dunslane.net> wrote:
>
>>
>> On 2025-05-26 Mo 7:06 AM, Julien Tachoires wrote:
>> > On Sat, Mar 29, 2025 at 08:46:01AM +0100, Julien Tachoires wrote:
>> >> On Sun, Mar 02, 2025 at 02:23:54PM +0100, Julien Tachoires wrote:
>> >>> On Sun, Mar 02, 2025 at 09:56:41AM +0100, Julien Tachoires wrote:
>> >>>> With the help of the new TAM routine 'relation_options', table access
>> >>>> methods can with this patch define their own reloptions
>> >>>> parser/validator.
>> >>>>
>> >>>> These reloptions can be set via the following commands:
>> >>>> 1. CREATE TABLE ... USING table_am
>> >>>>         WITH (option1='value1', option2='value2');
>> >>>> 2. ALTER TABLE ...
>> >>>>         SET (option1 'value1', option2 'value2');
>> >>>> 3. ALTER TABLE ... SET ACCESS METHOD table_am
>> >>>>         OPTIONS (option1 'value1', option2 'value2');
>> >>>>
>> >>>> When changing table's access method, the settings inherited from the
>> >>>> former TAM can be dropped (if not supported by the new TAM) via: DROP
>> >>>> option, or, updated via: SET option 'value'.
>> >>>>
>> >>>> Currently, tables using different TAMs than heap are able to use
>> heap's
>> >>>> reloptions (fillfactor, toast_tuple_target, etc...). With this patch
>> >>>> applied, this is not the case anymore: if the TAM needs to have
>> access
>> >>>> to similar settings to heap ones, they have to explicitly define
>> them.
>> >>>>
>> >>>> The 2nd patch file includes a new test module 'dummy_table_am' which
>> >>>> implements a dummy table access method utilized to exercise TAM
>> >>>> reloptions. This test module is strongly based on what we already
>> have
>> >>>> in 'dummy_index_am'. 'dummy_table_am' provides a complete example of
>> TAM
>> >>>> reloptions definition.
>> >>>>
>> >>>> This work is directly derived from SadhuPrasad's patch here [2].
>> Others
>> >>>> attempts were posted here [1] and here [3].
>> >>>>
>> >>>> [1]
>> https://www.postgresql.org/message-id/flat/429fb58fa3218221bb17c7bf9e70e1aa6cfc6b5d.camel%40j-davis....
>> >>>> [2]
>> https://www.postgresql.org/message-id/flat/CAFF0-CG4KZHdtYHMsonWiXNzj16gWZpduXAn8yF7pDDub+GQMg@mail....
>> >>>> [3]
>> https://www.postgresql.org/message-id/flat/AMUA1wBBBxfc3tKRLLdU64rb.1.1683276279979.Hmail.wuhao%40ha...
>> >>> Please find a new version including minor fixes: 'TAM' terms are
>> >>> replaced by 'table AM'
>> >> Please find a new rebased version.
>> > New rebased version.
>> >
>>
>>
>> This thread seems to have gone dormant, sadly. Here's a fresh attempt I
>> made (with assistance from Claude), before I was aware of the existence
>> of this and other efforts. I think it meets most of the previous
>> objections, and is in line with what we do for Index AMs.
>>
>> I was testing this patch and found this issue,
> CREATE EXTENSION dummy_table_am;
> CREATE TABLE t (a int) USING dummy_table_am WITH (option_int = 42);
> ALTER TABLE t SET ACCESS METHOD heap;
> SELECT reloptions FROM pg_class WHERE oid = 't'::regclass;
>   reloptions
> -----------------
>  {option_int=42}
> (1 row)
> ALTER TABLE t SET (fillfactor = 50);
> 2026-07-16 19:01:36.060 IST [95058] ERROR:  unrecognized parameter
> "option_int"
> 2026-07-16 19:01:36.060 IST [95058] STATEMENT:  ALTER TABLE t SET
> (fillfactor = 50);
> ERROR:  unrecognized parameter "option_int"
>
> Basically, the option is left behind after changing the tableam to heap, I
> think that isn't intentional.
>
>>
>>
>


Thanks for reviewing. Good catch!

Basically the code had an early exit in the validation code that shouldn't
have been there.

 v2 attached with a fix for that, plus a regression test covering this
direction (dummy_table_am -> heap with a still-set AM-specific option),
which I verified fails against v1 and passes with the fix. I also
folded the two independent access-method-resolution code paths in
DefineRelation into one and added the new test module's typedefs to
typedefs.list

cheers

andrew

Attachments:

  [text/x-patch] v2-0001-Add-amoptions-callback-to-table-access-methods.patch (54.5K, ../../CAD5tBcKJOUYZunmx=RVZh4MsxGOcY-ftAWhb63nNAntRWZ7XAw@mail.gmail.com/3-v2-0001-Add-amoptions-callback-to-table-access-methods.patch)
  download | inline diff:
From fde77afbef3cb1cc53a60c069b5fb332c67ce945 Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <andrew@dunslane.net>
Date: Tue, 12 May 2026 09:03:33 -0400
Subject: [PATCH v2] Add amoptions callback to table access methods

Give table access methods the same option-extension story that index
access methods already have.

TableAmRoutine gets an optional amoptions field of type
amoptions_function (same signature as IndexAmRoutine.amoptions).
A matching table_reloptions() entry point dispatches to it:

    bytea *
    table_reloptions(amoptions_function amoptions, char relkind,
                     Datum reloptions, bool validate)
    {
        if (amoptions != NULL)
            return amoptions(reloptions, validate);
        return heap_reloptions(relkind, reloptions, validate);
    }

When an AM supplies an amoptions parser it owns the option set
entirely: it may accept all standard heap options, only a subset, or
add its own; the returned bytea is stored verbatim in
Relation->rd_options, so the AM dictates the layout its other
callbacks read.  When amoptions is NULL the result is the standard
StdRdOptions layout, identical to today.

DefineRelation and ATExecSetRelOptions are updated to call
table_reloptions() for RELKIND_RELATION and RELKIND_MATVIEW.  At
CREATE TABLE the AM is resolved early (explicit USING clause,
partition parent, or default_table_access_method) so its amoptions
can be consulted during reloption validation; at ALTER TABLE SET
the AM is read from rel->rd_tableam, with one twist: if SET ACCESS
METHOD is queued in the same statement, the new AM's parser is used
so that users can write
    ALTER TABLE t SET ACCESS METHOD x, SET (foo = bar)
where foo is recognised by x but not by the current AM.
RelationParseRelOptions and extractRelOptions are likewise routed
through table_reloptions when a table relation is opened, so AMs see
their own parsed struct in rd_options.

ALTER TABLE ... SET ACCESS METHOD also runs a final reloption
revalidation after all phase-2 subcommands have committed: the
relation's resulting reloptions are checked against the new AM's
parser so that pre-existing options the new AM does not recognise
fail the statement with a clear message rather than being silently
dropped at the next relcache load.  Users can clear such options in
the same statement, e.g.
    ALTER TABLE t SET ACCESS METHOD x, RESET (fillfactor);

Because an AM that supplies amoptions owns its rd_options layout,
core macros that previously assumed rd_options was always
StdRdOptions (RelationGetFillFactor, RelationGetToastTupleTarget,
RelationIsUsedAsCatalogTable, RelationGetParallelWorkers) now gate
on a new helper RelationHasStdRdOptions().  The same gating is
applied to the direct (StdRdOptions *) casts in vacuum.c and
index.c so a custom layout is never reinterpreted as StdRdOptions.

Heap registers no amoptions (the field is NULL), so its behaviour is
unchanged.

A companion helper, add_reloption_to_kind(name, kind), extends an
existing reloption registration with an additional kind bit:

    void add_reloption_to_kind(const char *name, relopt_kind kind);

Extensions that want their AM-specific parser to accept standard
options (fillfactor, parallel_workers, autovacuum_*, vacuum_truncate)
that core registers only for RELOPT_KIND_HEAP can now call this once
per option in _PG_init instead of duplicating each definition under
the new kind.  Errors if no option with that name has been
registered.

A new test module src/test/modules/dummy_table_am demonstrates the
API: it wraps the heap AM and only overrides amoptions, so the
relation behaves as a heap table but accepts a custom reloption set.
The accompanying regress test exercises CREATE/ALTER/RESET
round-trips, SET ACCESS METHOD revalidation (both the
"options-incompatible-with-new-AM" failure path and the
"RESET-in-same-statement" success path), and partitioned-table
inheritance of an AM-specific option set.

Together the two additions are exactly the surface an extension
table AM needs to register its own reloptions in its own
RELOPT_KIND_* namespace -- AM-specific options no longer have to
pollute StdRdOptions / RELOPT_KIND_HEAP to be parseable.
---
 doc/src/sgml/ref/alter_table.sgml             |  19 ++
 doc/src/sgml/tableam.sgml                     |  67 ++++++
 src/backend/access/common/reloptions.c        |  87 +++++++-
 src/backend/catalog/index.c                   |   3 +-
 src/backend/commands/tablecmds.c              | 199 +++++++++++++++---
 src/backend/commands/vacuum.c                 |   7 +-
 src/backend/utils/cache/relcache.c            |   4 +-
 src/include/access/reloptions.h               |   3 +
 src/include/access/tableam.h                  |  32 +++
 src/include/utils/rel.h                       |  25 ++-
 src/test/modules/Makefile                     |   1 +
 src/test/modules/dummy_table_am/Makefile      |  20 ++
 src/test/modules/dummy_table_am/README        |  21 ++
 .../dummy_table_am/dummy_table_am--1.0.sql    |  13 ++
 .../modules/dummy_table_am/dummy_table_am.c   | 166 +++++++++++++++
 .../dummy_table_am/dummy_table_am.control     |   5 +
 .../dummy_table_am/expected/reloptions.out    | 182 ++++++++++++++++
 src/test/modules/dummy_table_am/meson.build   |  33 +++
 .../modules/dummy_table_am/sql/reloptions.sql | 105 +++++++++
 src/test/modules/meson.build                  |   1 +
 src/tools/pgindent/typedefs.list              |   2 +
 21 files changed, 959 insertions(+), 36 deletions(-)
 create mode 100644 src/test/modules/dummy_table_am/Makefile
 create mode 100644 src/test/modules/dummy_table_am/README
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am.c
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am.control
 create mode 100644 src/test/modules/dummy_table_am/expected/reloptions.out
 create mode 100644 src/test/modules/dummy_table_am/meson.build
 create mode 100644 src/test/modules/dummy_table_am/sql/reloptions.sql

diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index ff7071bef5b..4ff4ea9096d 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -813,6 +813,25 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
       causing future partitions to default to
       <varname>default_table_access_method</varname>.
      </para>
+     <para>
+      The new access method must accept every storage parameter
+      currently set on the table.  An access method may define its own
+      set of parameters, so a parameter that was legal under the old
+      access method is not necessarily recognized by the new one; if any
+      such parameter remains, <command>ALTER TABLE</command> raises an
+      error rather than silently dropping the value.  The unwanted
+      parameters can be cleared in the same statement, for example:
+<programlisting>
+ALTER TABLE measurement
+    SET ACCESS METHOD columnar,
+    RESET (fillfactor);
+</programlisting>
+      Validation is performed once, after all storage-parameter
+      sub-commands in the statement have been applied, so the order of
+      <literal>SET</literal>, <literal>RESET</literal>, and
+      <literal>SET ACCESS METHOD</literal> within the same
+      <command>ALTER TABLE</command> does not matter.
+     </para>
     </listitem>
    </varlistentry>
 
diff --git a/doc/src/sgml/tableam.sgml b/doc/src/sgml/tableam.sgml
index 9ccf5b739ed..d47c12177ab 100644
--- a/doc/src/sgml/tableam.sgml
+++ b/doc/src/sgml/tableam.sgml
@@ -152,4 +152,71 @@ my_tableam_handler(PG_FUNCTION_ARGS)
   its implementation.
  </para>
 
+ <sect1 id="tableam-reloptions">
+  <title>Table Access Method Storage Parameters</title>
+
+  <para>
+   A table access method may define its own set of storage parameters
+   (reloptions) by supplying an <structfield>amoptions</structfield>
+   callback in its <structname>TableAmRoutine</structname>.  The callback
+   has the same signature as the corresponding index AM callback; it is
+   invoked at <command>CREATE TABLE</command> and
+   <command>ALTER TABLE</command> time to parse and validate the option
+   set, and at relation open time (with <literal>validate = false</literal>)
+   to build the in-memory representation stored in
+   <structfield>Relation-&gt;rd_options</structfield>.  An AM that does not
+   supply an <structfield>amoptions</structfield> callback inherits the
+   standard heap parser and the <structname>StdRdOptions</structname>
+   layout.
+  </para>
+
+  <para>
+   When the AM provides its own parser it owns the option set entirely:
+   it may accept all standard heap options, only a subset, or define
+   parameters of its own.  The bytea returned from the callback is
+   stored verbatim in <structfield>rd_options</structfield>, so the AM
+   also dictates the in-memory layout that its other callbacks read.
+  </para>
+
+  <para>
+   The parser is expected to validate user-supplied values, but
+   <emphasis>must not silently rewrite them</emphasis>.  In particular
+   it must not coerce out-of-range values to a default, drop unknown
+   options when <literal>validate = true</literal>, or substitute a
+   different unit; the user must be able to verify with
+   <command>SELECT reloptions FROM pg_class</command> that the values
+   they supplied are what the relation will use.  Out-of-range or
+   unknown options should be reported with
+   <function>ereport(ERROR)</function>.
+  </para>
+
+  <para>
+   To honour an option that the core code already registers for
+   <literal>RELOPT_KIND_HEAP</literal> (for example
+   <literal>fillfactor</literal> or the <literal>autovacuum_*</literal>
+   family), call <function>add_reloption_to_kind()</function> once per
+   option in the module's <function>_PG_init</function>.  This extends
+   the existing registration with the AM's own kind without forcing
+   the AM to re-declare each option.
+  </para>
+
+  <para>
+   <command>ALTER TABLE ... SET ACCESS METHOD</command> revalidates the
+   relation's current storage parameters against the new access
+   method's parser after all <literal>SET</literal>,
+   <literal>RESET</literal>, and <literal>REPLACE</literal>
+   sub-commands in the same statement have been applied.  A parameter
+   that is not accepted by the new AM raises an error; the user can
+   clear such parameters in the same statement (see <xref
+   linkend="sql-altertable"/>).
+  </para>
+
+  <para>
+   See <filename>src/test/modules/dummy_table_am</filename> for a
+   minimal example that exercises both
+   <structfield>amoptions</structfield> and
+   <function>add_reloption_to_kind()</function>.
+  </para>
+ </sect1>
+
 </chapter>
diff --git a/src/backend/access/common/reloptions.c b/src/backend/access/common/reloptions.c
index 3e832c3797e..84f67e87646 100644
--- a/src/backend/access/common/reloptions.c
+++ b/src/backend/access/common/reloptions.c
@@ -24,6 +24,7 @@
 #include "access/nbtree.h"
 #include "access/reloptions.h"
 #include "access/spgist_private.h"
+#include "access/tableam.h"
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "commands/tablespace.h"
@@ -749,6 +750,44 @@ add_reloption_kind(void)
 	return (relopt_kind) last_assigned_kind;
 }
 
+/*
+ * add_reloption_to_kind
+ *		Extend an already-registered reloption so it is also accepted for
+ *		the given kind.
+ *
+ * Useful for table access methods that want their own RELOPT_KIND_*
+ * parser to accept standard options (fillfactor, parallel_workers,
+ * autovacuum_*, etc.) that core registers only for RELOPT_KIND_HEAP.
+ * Without this, every AM that wants the standard option set would
+ * have to re-register each option under its own kind.
+ *
+ * 'name' must match an existing option; 'kind' is OR'ed into that
+ * option's kinds mask.  Errors if no option with that name exists.
+ */
+void
+add_reloption_to_kind(const char *name, relopt_kind kind)
+{
+	int			namelen = strlen(name);
+	int			i;
+
+	if (need_initialization)
+		initialize_reloptions();
+
+	for (i = 0; relOpts[i]; i++)
+	{
+		if (relOpts[i]->namelen == namelen &&
+			strncmp(relOpts[i]->name, name, namelen) == 0)
+		{
+			relOpts[i]->kinds |= kind;
+			return;
+		}
+	}
+
+	ereport(ERROR,
+			(errcode(ERRCODE_UNDEFINED_OBJECT),
+			 errmsg("reloption \"%s\" does not exist", name)));
+}
+
 /*
  * add_reloption
  *		Add an already-created custom reloption to the list, and recompute the
@@ -1516,8 +1555,11 @@ extractRelOptions(HeapTuple tuple, TupleDesc tupdesc,
 	switch (classForm->relkind)
 	{
 		case RELKIND_RELATION:
-		case RELKIND_TOASTVALUE:
 		case RELKIND_MATVIEW:
+			options = table_reloptions(amoptions, classForm->relkind,
+									   datum, false);
+			break;
+		case RELKIND_TOASTVALUE:
 			options = heap_reloptions(classForm->relkind, datum, false);
 			break;
 		case RELKIND_PARTITIONED_TABLE:
@@ -2187,6 +2229,49 @@ heap_reloptions(char relkind, Datum reloptions, bool validate)
 	}
 }
 
+/*
+ * Parse options for a table relation, dispatching to the access method's
+ * own option parser when it supplies one.
+ *
+ *	amoptions	the table AM's option parser, or NULL to fall back to the
+ *				standard heap parser for this relkind.
+ *	relkind		the relation's kind.
+ *	reloptions	options as a text[] datum.
+ *	validate	error flag for unknown options or bad values.
+ *
+ * When amoptions is non-NULL the AM owns the option set: it may accept
+ * all standard heap options, only a subset, or define its own.  The
+ * returned bytea is laid out as the AM dictates (it is stored verbatim
+ * in Relation->rd_options).  When amoptions is NULL the result is the
+ * standard StdRdOptions layout.
+ */
+bytea *
+table_reloptions(amoptions_function amoptions, char relkind,
+				 Datum reloptions, bool validate)
+{
+	if (amoptions != NULL)
+		return amoptions(reloptions, validate);
+	return heap_reloptions(relkind, reloptions, validate);
+}
+
+/*
+ * Returns true when the relation's rd_options buffer is laid out as
+ * StdRdOptions.  Used by the rel.h accessor macros (RelationGetFillFactor,
+ * RelationIsUsedAsCatalogTable, ...) to gate StdRdOptions casts so that a
+ * table access method which supplies its own amoptions callback (and
+ * therefore owns the rd_options layout) does not have its bytes
+ * misinterpreted.
+ */
+bool
+RelationHasStdRdOptions(Relation relation)
+{
+	if (relation->rd_options == NULL)
+		return false;
+	if (relation->rd_tableam == NULL)
+		return false;
+	return relation->rd_tableam->amoptions == NULL;
+}
+
 
 /*
  * Parse options for indexes.
diff --git a/src/backend/catalog/index.c b/src/backend/catalog/index.c
index 81bba4beac7..1230be892b4 100644
--- a/src/backend/catalog/index.c
+++ b/src/backend/catalog/index.c
@@ -2871,7 +2871,8 @@ index_update_stats(Relation rel,
 	{
 		if (AutoVacuumingActive())
 		{
-			StdRdOptions *options = (StdRdOptions *) rel->rd_options;
+			StdRdOptions *options = RelationHasStdRdOptions(rel) ?
+				(StdRdOptions *) rel->rd_options : NULL;
 
 			if (options != NULL && !options->autovacuum.enabled)
 				update_stats = false;
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index cb93c3e935a..86d821e0c55 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -724,9 +724,11 @@ static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
 								const char *tablespacename, LOCKMODE lockmode);
 static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
 static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
+static void ATValidateAccessMethodOptions(List **wqueue);
 static void ATExecSetRelOptions(Relation rel, List *defList,
 								AlterTableType operation,
-								LOCKMODE lockmode);
+								LOCKMODE lockmode,
+								Oid newAccessMethodId);
 static void ATExecEnableDisableTrigger(Relation rel, const char *trigname,
 									   char fires_when, bool skip_system, bool recurse,
 									   LOCKMODE lockmode);
@@ -981,6 +983,30 @@ DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId,
 	if (!OidIsValid(ownerId))
 		ownerId = GetUserId();
 
+	/*
+	 * For relations with table AM and partitioned tables, select access
+	 * method to use: an explicitly indicated one, or (in the case of a
+	 * partitioned table) the parent's, if it has one.  Done ahead of
+	 * reloptions validation below so that RELKIND_RELATION/RELKIND_MATVIEW
+	 * reloptions can be checked against the AM's own parser.
+	 */
+	if (stmt->accessMethod != NULL)
+	{
+		Assert(RELKIND_HAS_TABLE_AM(relkind) || relkind == RELKIND_PARTITIONED_TABLE);
+		accessMethodId = get_table_am_oid(stmt->accessMethod, false);
+	}
+	else if (RELKIND_HAS_TABLE_AM(relkind) || relkind == RELKIND_PARTITIONED_TABLE)
+	{
+		if (stmt->partbound)
+		{
+			Assert(list_length(inheritOids) == 1);
+			accessMethodId = get_rel_relam(linitial_oid(inheritOids));
+		}
+
+		if (RELKIND_HAS_TABLE_AM(relkind) && !OidIsValid(accessMethodId))
+			accessMethodId = get_table_am_oid(default_table_access_method, false);
+	}
+
 	/*
 	 * Parse and validate reloptions, if any.
 	 */
@@ -995,6 +1021,27 @@ DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId,
 		case RELKIND_PARTITIONED_TABLE:
 			(void) partitioned_table_reloptions(reloptions, true);
 			break;
+		case RELKIND_RELATION:
+		case RELKIND_MATVIEW:
+			{
+				amoptions_function amoptions = NULL;
+
+				if (OidIsValid(accessMethodId))
+				{
+					HeapTuple	tuple;
+
+					tuple = SearchSysCache1(AMOID, ObjectIdGetDatum(accessMethodId));
+					if (HeapTupleIsValid(tuple))
+					{
+						Form_pg_am	amform = (Form_pg_am) GETSTRUCT(tuple);
+
+						amoptions = GetTableAmRoutine(amform->amhandler)->amoptions;
+						ReleaseSysCache(tuple);
+					}
+				}
+				(void) table_reloptions(amoptions, relkind, reloptions, true);
+			}
+			break;
 		default:
 			(void) heap_reloptions(relkind, reloptions, true);
 	}
@@ -1081,28 +1128,6 @@ DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId,
 
 	TupleDescFinalize(descriptor);
 
-	/*
-	 * For relations with table AM and partitioned tables, select access
-	 * method to use: an explicitly indicated one, or (in the case of a
-	 * partitioned table) the parent's, if it has one.
-	 */
-	if (stmt->accessMethod != NULL)
-	{
-		Assert(RELKIND_HAS_TABLE_AM(relkind) || relkind == RELKIND_PARTITIONED_TABLE);
-		accessMethodId = get_table_am_oid(stmt->accessMethod, false);
-	}
-	else if (RELKIND_HAS_TABLE_AM(relkind) || relkind == RELKIND_PARTITIONED_TABLE)
-	{
-		if (stmt->partbound)
-		{
-			Assert(list_length(inheritOids) == 1);
-			accessMethodId = get_rel_relam(linitial_oid(inheritOids));
-		}
-
-		if (RELKIND_HAS_TABLE_AM(relkind) && !OidIsValid(accessMethodId))
-			accessMethodId = get_table_am_oid(default_table_access_method, false);
-	}
-
 	/*
 	 * Create the relation.  Inherited defaults and CHECK constraints are
 	 * passed in for immediate handling --- since they don't need parsing,
@@ -4985,6 +5010,18 @@ ATController(AlterTableStmt *parsetree,
 	/* Phase 2: update system catalogs */
 	ATRewriteCatalogs(&wqueue, lockmode, context);
 
+	/*
+	 * After all phase-2 subcommands have committed any SET / RESET / REPLACE
+	 * option changes to pg_class, but before any rewrite, ensure the final
+	 * reloptions are accepted by the access method the relation will use once
+	 * the ALTER TABLE finishes.  This catches the case where SET ACCESS
+	 * METHOD changes the AM and leaves pre-existing reloptions in pg_class
+	 * that the new AM does not recognise; without this check the new AM's
+	 * option parser would be called with validate=false at relcache load time
+	 * and silently ignore them.
+	 */
+	ATValidateAccessMethodOptions(&wqueue);
+
 	/* Phase 3: scan/rewrite tables as needed, and run afterStmts */
 	ATRewriteTables(parsetree, &wqueue, lockmode, context);
 }
@@ -5656,7 +5693,17 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
 		case AT_SetRelOptions:	/* SET (...) */
 		case AT_ResetRelOptions:	/* RESET (...) */
 		case AT_ReplaceRelOptions:	/* replace entire option list */
-			ATExecSetRelOptions(rel, (List *) cmd->def, cmd->subtype, lockmode);
+
+			/*
+			 * If SET ACCESS METHOD is queued in the same ALTER TABLE, the
+			 * reloptions in pg_class will be parsed by the new AM after the
+			 * statement finishes; tell ATExecSetRelOptions to validate
+			 * against that AM rather than the relation's current AM.  This
+			 * lets a user write ALTER TABLE t SET ACCESS METHOD x, SET (foo =
+			 * bar) where foo is recognised by x but not by the current AM.
+			 */
+			ATExecSetRelOptions(rel, (List *) cmd->def, cmd->subtype, lockmode,
+								tab->chgAccessMethod ? tab->newAccessMethod : InvalidOid);
 			break;
 		case AT_EnableTrig:		/* ENABLE TRIGGER name */
 			ATExecEnableDisableTrigger(rel, cmd->name,
@@ -17344,12 +17391,89 @@ ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel, const char *tablespacen
 	tab->newTableSpace = tablespaceId;
 }
 
+/*
+ * Re-validate pg_class.reloptions for every work-queue entry whose access
+ * method is being changed.  Called between phase 2 (catalog updates) and
+ * phase 3 (table rewrites): SET / RESET / REPLACE subcommands have already
+ * been committed to pg_class, and tab->newAccessMethod identifies the AM
+ * the relation will use once the ALTER TABLE finishes.
+ *
+ * The check exists because relcache.c calls the AM's option parser with
+ * validate=false at relation open: any pre-existing reloption that the
+ * new AM does not recognise would otherwise be silently dropped from the
+ * parsed StdRdOptions / AM-specific options struct, leaving the user
+ * unable to tell that the option is no longer in effect.  Failing the
+ * ALTER TABLE here with a clear message lets the user RESET the option
+ * in the same statement and re-run.
+ */
+static void
+ATValidateAccessMethodOptions(List **wqueue)
+{
+	ListCell   *ltab;
+
+	foreach(ltab, *wqueue)
+	{
+		AlteredTableInfo *tab = (AlteredTableInfo *) lfirst(ltab);
+		HeapTuple	amtup;
+		HeapTuple	reltup;
+		Form_pg_am	amform;
+		Form_pg_class relform;
+		amoptions_function amoptions;
+		Datum		reloptions;
+		bool		isnull;
+		Oid			amoid;
+
+		if (!tab->chgAccessMethod)
+			continue;
+
+		/*
+		 * Partitioned tables may reset the AM to "default" (InvalidOid); each
+		 * partition then chooses its own AM at create time, so there is no
+		 * per-relation AM whose parser to consult here.
+		 */
+		amoid = tab->newAccessMethod;
+		if (!OidIsValid(amoid))
+			continue;
+
+		amtup = SearchSysCache1(AMOID, ObjectIdGetDatum(amoid));
+		if (!HeapTupleIsValid(amtup))
+			elog(ERROR, "cache lookup failed for access method %u", amoid);
+		amform = (Form_pg_am) GETSTRUCT(amtup);
+		amoptions = GetTableAmRoutine(amform->amhandler)->amoptions;
+		ReleaseSysCache(amtup);
+
+		/*
+		 * Always revalidate, even when the new AM has no amoptions of its
+		 * own: table_reloptions() then falls back to heap_reloptions(), which
+		 * is exactly the check needed to catch options that only the
+		 * relation's previous (AM-specific) parser accepted.
+		 */
+		reltup = SearchSysCache1(RELOID, ObjectIdGetDatum(tab->relid));
+		if (!HeapTupleIsValid(reltup))
+			elog(ERROR, "cache lookup failed for relation %u", tab->relid);
+		relform = (Form_pg_class) GETSTRUCT(reltup);
+		reloptions = SysCacheGetAttr(RELOID, reltup,
+									 Anum_pg_class_reloptions, &isnull);
+		if (!isnull)
+			(void) table_reloptions(amoptions, relform->relkind,
+									reloptions, true);
+		ReleaseSysCache(reltup);
+	}
+}
+
 /*
  * Set, reset, or replace reloptions.
+ *
+ * newAccessMethodId, if valid, names the table access method whose option
+ * parser should validate the resulting reloptions.  This is used when SET
+ * ACCESS METHOD is queued in the same ALTER TABLE so that the new options
+ * are checked against the AM the relation will use after the statement
+ * finishes, not the AM it has now.  Pass InvalidOid to use the relation's
+ * current access method.
  */
 static void
 ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
-					LOCKMODE lockmode)
+					LOCKMODE lockmode, Oid newAccessMethodId)
 {
 	Oid			relid;
 	Relation	pgclass;
@@ -17401,7 +17525,30 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
 	{
 		case RELKIND_RELATION:
 		case RELKIND_MATVIEW:
-			(void) heap_reloptions(rel->rd_rel->relkind, newOptions, true);
+			{
+				amoptions_function amoptions;
+
+				if (OidIsValid(newAccessMethodId))
+				{
+					HeapTuple	amtup;
+					Form_pg_am	amform;
+
+					amtup = SearchSysCache1(AMOID,
+											ObjectIdGetDatum(newAccessMethodId));
+					if (!HeapTupleIsValid(amtup))
+						elog(ERROR, "cache lookup failed for access method %u",
+							 newAccessMethodId);
+					amform = (Form_pg_am) GETSTRUCT(amtup);
+					amoptions = GetTableAmRoutine(amform->amhandler)->amoptions;
+					ReleaseSysCache(amtup);
+				}
+				else
+					amoptions = (rel->rd_tableam ?
+								 rel->rd_tableam->amoptions : NULL);
+
+				(void) table_reloptions(amoptions, rel->rd_rel->relkind,
+										newOptions, true);
+			}
 			break;
 		case RELKIND_PARTITIONED_TABLE:
 			(void) partitioned_table_reloptions(newOptions, true);
diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c
index 38539a6fd3d..e83364def5c 100644
--- a/src/backend/commands/vacuum.c
+++ b/src/backend/commands/vacuum.c
@@ -2190,7 +2190,7 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 	{
 		StdRdOptIndexCleanup vacuum_index_cleanup;
 
-		if (rel->rd_options == NULL)
+		if (!RelationHasStdRdOptions(rel))
 			vacuum_index_cleanup = STDRD_OPTION_VACUUM_INDEX_CLEANUP_AUTO;
 		else
 			vacuum_index_cleanup =
@@ -2221,7 +2221,7 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 	 * Check if the vacuum_max_eager_freeze_failure_rate table storage
 	 * parameter was specified. This overrides the GUC value.
 	 */
-	if (rel->rd_options != NULL &&
+	if (RelationHasStdRdOptions(rel) &&
 		((StdRdOptions *) rel->rd_options)->vacuum_max_eager_freeze_failure_rate >= 0)
 		params.max_eager_freeze_failure_rate =
 			((StdRdOptions *) rel->rd_options)->vacuum_max_eager_freeze_failure_rate;
@@ -2232,7 +2232,8 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 	 */
 	if (params.truncate == VACOPTVALUE_UNSPECIFIED)
 	{
-		StdRdOptions *opts = (StdRdOptions *) rel->rd_options;
+		StdRdOptions *opts = RelationHasStdRdOptions(rel) ?
+			(StdRdOptions *) rel->rd_options : NULL;
 
 		if (opts && opts->vacuum_truncate != PG_TERNARY_UNSET)
 		{
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index 19c4ff6e75e..dc1e7745e42 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -483,9 +483,11 @@ RelationParseRelOptions(Relation relation, HeapTuple tuple)
 	switch (relation->rd_rel->relkind)
 	{
 		case RELKIND_RELATION:
+		case RELKIND_MATVIEW:
+			amoptsfn = relation->rd_tableam ? relation->rd_tableam->amoptions : NULL;
+			break;
 		case RELKIND_TOASTVALUE:
 		case RELKIND_VIEW:
-		case RELKIND_MATVIEW:
 		case RELKIND_PARTITIONED_TABLE:
 			amoptsfn = NULL;
 			break;
diff --git a/src/include/access/reloptions.h b/src/include/access/reloptions.h
index e8cb7f7a627..1282bccc77f 100644
--- a/src/include/access/reloptions.h
+++ b/src/include/access/reloptions.h
@@ -187,6 +187,7 @@ typedef struct local_relopts
 	 (char *)(optstruct) + (optstruct)->member)
 
 extern relopt_kind add_reloption_kind(void);
+extern void add_reloption_to_kind(const char *name, relopt_kind kind);
 extern void add_bool_reloption(uint32 kinds, const char *name, const char *desc,
 							   bool default_val, LOCKMODE lockmode);
 extern void add_ternary_reloption(uint32 kinds, const char *name,
@@ -248,6 +249,8 @@ extern void *build_local_reloptions(local_relopts *relopts, Datum options,
 extern bytea *default_reloptions(Datum reloptions, bool validate,
 								 relopt_kind kind);
 extern bytea *heap_reloptions(char relkind, Datum reloptions, bool validate);
+extern bytea *table_reloptions(amoptions_function amoptions, char relkind,
+							   Datum reloptions, bool validate);
 extern bytea *view_reloptions(Datum reloptions, bool validate);
 extern bytea *partitioned_table_reloptions(Datum reloptions, bool validate);
 extern bytea *index_reloptions(amoptions_function amoptions, Datum reloptions,
diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h
index f2c36696bca..15e46d7995a 100644
--- a/src/include/access/tableam.h
+++ b/src/include/access/tableam.h
@@ -17,6 +17,7 @@
 #ifndef TABLEAM_H
 #define TABLEAM_H
 
+#include "access/amapi.h"
 #include "access/relscan.h"
 #include "access/sdir.h"
 #include "access/xact.h"
@@ -324,6 +325,37 @@ typedef struct TableAmRoutine
 	NodeTag		type;
 
 
+	/* ------------------------------------------------------------------------
+	 * Reloption parsing.
+	 * ------------------------------------------------------------------------
+	 */
+
+	/*
+	 * Parse and validate AM-specific reloptions.  Optional: when NULL, the
+	 * caller falls back to the standard heap reloption parser
+	 * (default_reloptions with RELOPT_KIND_HEAP) and the result is laid out
+	 * as StdRdOptions.
+	 *
+	 * When non-NULL, the AM owns the option set entirely.  It is free to
+	 * accept all standard heap options, only a subset, or to add its own. The
+	 * returned bytea must begin with a VARSIZE header and is stored in
+	 * Relation->rd_options, so the AM dictates the in-memory layout that its
+	 * other callbacks read.  Core code that reads StdRdOptions fields out of
+	 * rd_options (RelationGetFillFactor, RelationIsUsedAsCatalogTable, ...)
+	 * gates on RelationHasStdRdOptions(), so a custom layout will not be
+	 * misinterpreted.
+	 *
+	 * The callback validates user-supplied values but must not silently
+	 * rewrite them: a user inspecting pg_class.reloptions must see exactly
+	 * what they passed in.  Out-of-range or unknown options should be
+	 * reported with ereport(ERROR) when validate is true.
+	 *
+	 * Signature matches the index AM's amoptions callback so the same helper
+	 * machinery (add_string_reloption, add_int_reloption, etc.) can be used.
+	 */
+	amoptions_function amoptions;
+
+
 	/* ------------------------------------------------------------------------
 	 * Slot related callbacks.
 	 * ------------------------------------------------------------------------
diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h
index 89c159b133f..b88544c9617 100644
--- a/src/include/utils/rel.h
+++ b/src/include/utils/rel.h
@@ -361,12 +361,29 @@ typedef struct StdRdOptions
 #define HEAP_MIN_FILLFACTOR			10
 #define HEAP_DEFAULT_FILLFACTOR		100
 
+/*
+ * RelationHasStdRdOptions
+ *		Returns true when the relation's rd_options buffer is laid out as
+ *		StdRdOptions, i.e. it was produced by the standard heap reloption
+ *		parser.  A table access method that supplies its own amoptions
+ *		callback owns its rd_options layout and is not required to expose
+ *		StdRdOptions fields; macros that read those fields must check this
+ *		first to avoid reading garbage data.  For indexes and other
+ *		relkinds rd_options is in an AM-specific layout, so this returns
+ *		false for them.
+ *
+ *		Defined as a function (in reloptions.c) rather than a macro
+ *		because the test needs the full TableAmRoutine struct definition,
+ *		which would create an #include cycle if pulled into rel.h.
+ */
+extern bool RelationHasStdRdOptions(Relation relation);
+
 /*
  * RelationGetToastTupleTarget
  *		Returns the relation's toast_tuple_target.  Note multiple eval of argument!
  */
 #define RelationGetToastTupleTarget(relation, defaulttarg) \
-	((relation)->rd_options ? \
+	(RelationHasStdRdOptions(relation) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->toast_tuple_target : (defaulttarg))
 
 /*
@@ -374,7 +391,7 @@ typedef struct StdRdOptions
  *		Returns the relation's fillfactor.  Note multiple eval of argument!
  */
 #define RelationGetFillFactor(relation, defaultff) \
-	((relation)->rd_options ? \
+	(RelationHasStdRdOptions(relation) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->fillfactor : (defaultff))
 
 /*
@@ -397,7 +414,7 @@ typedef struct StdRdOptions
  *		from the pov of logical decoding.  Note multiple eval of argument!
  */
 #define RelationIsUsedAsCatalogTable(relation)	\
-	((relation)->rd_options && \
+	(RelationHasStdRdOptions(relation) && \
 	 ((relation)->rd_rel->relkind == RELKIND_RELATION || \
 	  (relation)->rd_rel->relkind == RELKIND_MATVIEW) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->user_catalog_table : false)
@@ -408,7 +425,7 @@ typedef struct StdRdOptions
  *		Note multiple eval of argument!
  */
 #define RelationGetParallelWorkers(relation, defaultpw) \
-	((relation)->rd_options ? \
+	(RelationHasStdRdOptions(relation) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->parallel_workers : (defaultpw))
 
 /* ViewOptions->check_option values */
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 0a74ab5c86f..223005fdf98 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -10,6 +10,7 @@ SUBDIRS = \
 		  delay_execution \
 		  dummy_index_am \
 		  dummy_seclabel \
+		  dummy_table_am \
 		  index \
 		  libpq_pipeline \
 		  oauth_validator \
diff --git a/src/test/modules/dummy_table_am/Makefile b/src/test/modules/dummy_table_am/Makefile
new file mode 100644
index 00000000000..94837dff392
--- /dev/null
+++ b/src/test/modules/dummy_table_am/Makefile
@@ -0,0 +1,20 @@
+# src/test/modules/dummy_table_am/Makefile
+
+MODULES = dummy_table_am
+
+EXTENSION = dummy_table_am
+DATA = dummy_table_am--1.0.sql
+PGFILEDESC = "dummy_table_am - table access method template"
+
+REGRESS = reloptions
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/dummy_table_am
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/dummy_table_am/README b/src/test/modules/dummy_table_am/README
new file mode 100644
index 00000000000..a234a1f107f
--- /dev/null
+++ b/src/test/modules/dummy_table_am/README
@@ -0,0 +1,21 @@
+Dummy Table AM
+==============
+
+Dummy table AM is a module for testing the table access method
+amoptions callback and the add_reloption_to_kind() helper.  It
+delegates all storage and scan callbacks to the heap AM and only
+swaps in its own option parser, so a relation created with USING
+dummy_table_am behaves like a heap table but accepts a different
+set of reloptions:
+
+  - "fillfactor"     (inherited from the core heap registration via
+                      add_reloption_to_kind)
+  - "option_int"     (integer)
+  - "option_real"    (real)
+  - "option_bool"    (boolean)
+  - "option_enum"    (enum, one|two)
+
+Standard heap options such as parallel_workers, autovacuum_*, and
+toast_tuple_target are intentionally NOT accepted, to exercise the
+"AM rejects an unknown option" path in ALTER TABLE ... SET ACCESS
+METHOD revalidation.
diff --git a/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql b/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
new file mode 100644
index 00000000000..2e295b95845
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
@@ -0,0 +1,13 @@
+/* src/test/modules/dummy_table_am/dummy_table_am--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION dummy_table_am" to load this file. \quit
+
+CREATE FUNCTION dthandler(internal)
+RETURNS table_am_handler
+AS 'MODULE_PATHNAME'
+LANGUAGE C;
+
+-- Access method
+CREATE ACCESS METHOD dummy_table_am TYPE TABLE HANDLER dthandler;
+COMMENT ON ACCESS METHOD dummy_table_am IS 'dummy table access method';
diff --git a/src/test/modules/dummy_table_am/dummy_table_am.c b/src/test/modules/dummy_table_am/dummy_table_am.c
new file mode 100644
index 00000000000..834a9acd5bb
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am.c
@@ -0,0 +1,166 @@
+/*-------------------------------------------------------------------------
+ *
+ * dummy_table_am.c
+ *		Table AM template main file.
+ *
+ * This module exists primarily to demonstrate and exercise the table AM
+ * amoptions callback and the add_reloption_to_kind() helper.  Storage
+ * and scan callbacks are delegated to the heap AM, so a relation
+ * created with USING dummy_table_am behaves like a heap table; only the
+ * reloption surface differs.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/test/modules/dummy_table_am/dummy_table_am.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/reloptions.h"
+#include "access/tableam.h"
+#include "fmgr.h"
+
+PG_MODULE_MAGIC;
+
+/* Parse table for build_reloptions */
+static relopt_parse_elt dt_relopt_tab[5];
+
+/* Kind of relation options for dummy table */
+static relopt_kind dt_relopt_kind;
+
+typedef enum DummyTableEnum
+{
+	DUMMY_TABLE_ENUM_ONE,
+	DUMMY_TABLE_ENUM_TWO,
+}			DummyTableEnum;
+
+/*
+ * Dummy table options.
+ *
+ * The first two fields are the standard heap options (fillfactor +
+ * autovacuum_enabled) that we inherit by calling add_reloption_to_kind()
+ * on the matching names; the remaining ones are AM-specific options.
+ */
+typedef struct DummyTableOptions
+{
+	int32		vl_len_;		/* varlena header (do not touch directly!) */
+	int			fillfactor;
+	int			option_int;
+	double		option_real;
+	bool		option_bool;
+	DummyTableEnum option_enum;
+}			DummyTableOptions;
+
+static relopt_enum_elt_def dummyTableEnumValues[] =
+{
+	{"one", DUMMY_TABLE_ENUM_ONE},
+	{"two", DUMMY_TABLE_ENUM_TWO},
+	{(const char *) NULL}		/* list terminator */
+};
+
+PG_FUNCTION_INFO_V1(dthandler);
+
+/*
+ * Register a relopt_kind for this AM and populate the parse table.
+ */
+static void
+create_reloptions_table(void)
+{
+	int			i = 0;
+
+	dt_relopt_kind = add_reloption_kind();
+
+	/*
+	 * Accept the standard "fillfactor" option (registered by core for
+	 * RELOPT_KIND_HEAP only) under our own kind.  This is the canonical use
+	 * of add_reloption_to_kind(): an AM that wants to honour an existing
+	 * core-registered option without duplicating its definition.
+	 */
+	add_reloption_to_kind("fillfactor", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "fillfactor";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, fillfactor);
+	i++;
+
+	add_int_reloption(dt_relopt_kind, "option_int",
+					  "Integer option for dummy_table_am",
+					  10, -10, 100, AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_int";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_int);
+	i++;
+
+	add_real_reloption(dt_relopt_kind, "option_real",
+					   "Real option for dummy_table_am",
+					   3.1415, -10, 100, AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_real";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_REAL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_real);
+	i++;
+
+	add_bool_reloption(dt_relopt_kind, "option_bool",
+					   "Boolean option for dummy_table_am",
+					   true, AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_bool";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_BOOL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_bool);
+	i++;
+
+	add_enum_reloption(dt_relopt_kind, "option_enum",
+					   "Enum option for dummy_table_am",
+					   dummyTableEnumValues,
+					   DUMMY_TABLE_ENUM_ONE,
+					   "Valid values are \"one\" and \"two\".",
+					   AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_enum";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_ENUM;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_enum);
+	i++;
+}
+
+/*
+ * Parse reloptions for dummy_table_am.
+ *
+ * Returning DummyTableOptions tells the caller (relcache.c) to store
+ * exactly that layout in Relation->rd_options.
+ */
+static bytea *
+dtoptions(Datum reloptions, bool validate)
+{
+	return (bytea *) build_reloptions(reloptions, validate,
+									  dt_relopt_kind,
+									  sizeof(DummyTableOptions),
+									  dt_relopt_tab, lengthof(dt_relopt_tab));
+}
+
+/*
+ * Handler for table AM.
+ *
+ * All storage-side callbacks are inherited from heap; we only swap in
+ * our own amoptions so that the AM owns its reloption set.  This keeps
+ * the example focused on the new API without duplicating the heap AM.
+ */
+Datum
+dthandler(PG_FUNCTION_ARGS)
+{
+	static TableAmRoutine routine;
+	static bool initialized = false;
+
+	if (!initialized)
+	{
+		memcpy(&routine, GetHeapamTableAmRoutine(), sizeof(routine));
+		routine.amoptions = dtoptions;
+		initialized = true;
+	}
+
+	PG_RETURN_POINTER(&routine);
+}
+
+void
+_PG_init(void)
+{
+	create_reloptions_table();
+}
diff --git a/src/test/modules/dummy_table_am/dummy_table_am.control b/src/test/modules/dummy_table_am/dummy_table_am.control
new file mode 100644
index 00000000000..08f2f868d49
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am.control
@@ -0,0 +1,5 @@
+# dummy_table_am extension
+comment = 'dummy_table_am - table access method template'
+default_version = '1.0'
+module_pathname = '$libdir/dummy_table_am'
+relocatable = true
diff --git a/src/test/modules/dummy_table_am/expected/reloptions.out b/src/test/modules/dummy_table_am/expected/reloptions.out
new file mode 100644
index 00000000000..0015ef0df51
--- /dev/null
+++ b/src/test/modules/dummy_table_am/expected/reloptions.out
@@ -0,0 +1,182 @@
+-- Tests for the table AM amoptions callback and add_reloption_to_kind()
+CREATE EXTENSION dummy_table_am;
+-- Sanity: CREATE TABLE with AM-specific options succeeds and round-trips
+CREATE TABLE dummy_t (a int) USING dummy_table_am
+    WITH (option_int = 17, option_real = 2.5, option_bool = false,
+          option_enum = 'two', fillfactor = 60);
+SELECT reloptions FROM pg_class
+    WHERE oid = 'dummy_t'::regclass ORDER BY reloptions;
+                                   reloptions                                    
+---------------------------------------------------------------------------------
+ {option_int=17,option_real=2.5,option_bool=false,option_enum=two,fillfactor=60}
+(1 row)
+
+-- AM-specific option ranges are enforced (option_int allows -10..100)
+CREATE TABLE dummy_oor (a int) USING dummy_table_am WITH (option_int = 9999);
+ERROR:  value 9999 out of bounds for option "option_int"
+DETAIL:  Valid values are between "-10" and "100".
+-- Unknown options are rejected at CREATE TABLE time
+CREATE TABLE dummy_bad (a int) USING dummy_table_am WITH (parallel_workers = 4);
+ERROR:  unrecognized parameter "parallel_workers"
+-- Default values land in pg_class only when the user did not set them
+CREATE TABLE dummy_defaults (a int) USING dummy_table_am;
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_defaults'::regclass;
+ reloptions 
+------------
+ 
+(1 row)
+
+DROP TABLE dummy_defaults;
+-- ALTER TABLE ... SET (...) with AM-specific option
+ALTER TABLE dummy_t SET (option_int = 42);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+                                   reloptions                                    
+---------------------------------------------------------------------------------
+ {option_real=2.5,option_bool=false,option_enum=two,fillfactor=60,option_int=42}
+(1 row)
+
+-- ALTER TABLE ... SET (...) with an unknown option errors
+ALTER TABLE dummy_t SET (parallel_workers = 4);
+ERROR:  unrecognized parameter "parallel_workers"
+-- ALTER TABLE ... RESET (option) round-trips
+ALTER TABLE dummy_t RESET (option_int);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+                            reloptions                             
+-------------------------------------------------------------------
+ {option_real=2.5,option_bool=false,option_enum=two,fillfactor=60}
+(1 row)
+
+-- SET ACCESS METHOD to an AM with no amoptions of its own (heap) must
+-- still revalidate: an option that only the *current* AM understands
+-- (option_int) must not be silently left behind when switching away.
+ALTER TABLE dummy_t RESET (option_real, option_bool, option_enum);
+ALTER TABLE dummy_t SET (option_int = 42);
+ALTER TABLE dummy_t SET ACCESS METHOD heap;
+ERROR:  unrecognized parameter "option_int"
+-- Confirm nothing changed
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'dummy_t'::regclass;
+     amname     
+----------------
+ dummy_table_am
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+          reloptions           
+-------------------------------
+ {fillfactor=60,option_int=42}
+(1 row)
+
+-- Succeeds once the offending option is cleared in the same statement.
+ALTER TABLE dummy_t SET ACCESS METHOD heap, RESET (option_int);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'dummy_t'::regclass;
+ amname 
+--------
+ heap
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+   reloptions    
+-----------------
+ {fillfactor=60}
+(1 row)
+
+-- SET ACCESS METHOD revalidation:
+--   moving a heap table that has standard heap options not accepted by the
+--   new AM (parallel_workers) into dummy_table_am must fail with a clear
+--   message and must NOT silently drop the option.
+CREATE TABLE heap_t (a int) WITH (fillfactor = 70, parallel_workers = 4);
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+             reloptions             
+------------------------------------
+ {fillfactor=70,parallel_workers=4}
+(1 row)
+
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am;
+ERROR:  unrecognized parameter "parallel_workers"
+-- Confirm nothing changed
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+ amname 
+--------
+ heap
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+             reloptions             
+------------------------------------
+ {fillfactor=70,parallel_workers=4}
+(1 row)
+
+-- After RESETing the offending option in the same statement the swap
+-- succeeds; fillfactor survives because dummy_table_am inherits it via
+-- add_reloption_to_kind().
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am, RESET (parallel_workers);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+     amname     
+----------------
+ dummy_table_am
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+   reloptions    
+-----------------
+ {fillfactor=70}
+(1 row)
+
+-- Going back to heap still works: heap accepts fillfactor.
+ALTER TABLE heap_t SET ACCESS METHOD heap;
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+ amname 
+--------
+ heap
+(1 row)
+
+-- SET ACCESS METHOD + SET (...) of an option that only the new AM accepts.
+CREATE TABLE heap_to_dt (a int);
+ALTER TABLE heap_to_dt SET ACCESS METHOD dummy_table_am, SET (option_int = 25);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+     amname     
+----------------
+ dummy_table_am
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+   reloptions    
+-----------------
+ {option_int=25}
+(1 row)
+
+-- Partitioned-table inheritance: AM declared on the parent partition flows
+-- to partitions that don't override it.  Partitioned tables themselves
+-- cannot carry reloptions; the test verifies the AM lookup that
+-- DefineRelation does for partitions.
+CREATE TABLE parted (a int) PARTITION BY RANGE (a) USING dummy_table_am;
+CREATE TABLE parted_p1 PARTITION OF parted FOR VALUES FROM (0) TO (100)
+    WITH (option_int = 11);
+SELECT c.relname,
+       (SELECT amname FROM pg_am WHERE oid = c.relam) AS amname,
+       c.reloptions
+    FROM pg_class c
+    WHERE c.oid IN ('parted'::regclass, 'parted_p1'::regclass)
+    ORDER BY c.relname;
+  relname  |     amname     |   reloptions    
+-----------+----------------+-----------------
+ parted    | dummy_table_am | 
+ parted_p1 | dummy_table_am | {option_int=11}
+(2 rows)
+
+-- A partition that explicitly chooses heap must reject options that are
+-- only known to the parent's AM.
+CREATE TABLE parted_p2 PARTITION OF parted FOR VALUES FROM (100) TO (200)
+    USING heap WITH (option_int = 9);
+ERROR:  unrecognized parameter "option_int"
+DROP TABLE parted;
+DROP TABLE heap_to_dt;
+DROP TABLE heap_t;
+DROP TABLE dummy_t;
+DROP EXTENSION dummy_table_am;
diff --git a/src/test/modules/dummy_table_am/meson.build b/src/test/modules/dummy_table_am/meson.build
new file mode 100644
index 00000000000..ad3fa2410cc
--- /dev/null
+++ b/src/test/modules/dummy_table_am/meson.build
@@ -0,0 +1,33 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+dummy_table_am_sources = files(
+  'dummy_table_am.c',
+)
+
+if host_system == 'windows'
+  dummy_table_am_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'dummy_table_am',
+    '--FILEDESC', 'dummy_table_am - table access method template',])
+endif
+
+dummy_table_am = shared_module('dummy_table_am',
+  dummy_table_am_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += dummy_table_am
+
+test_install_data += files(
+  'dummy_table_am.control',
+  'dummy_table_am--1.0.sql',
+)
+
+tests += {
+  'name': 'dummy_table_am',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'reloptions',
+    ],
+  },
+}
diff --git a/src/test/modules/dummy_table_am/sql/reloptions.sql b/src/test/modules/dummy_table_am/sql/reloptions.sql
new file mode 100644
index 00000000000..20785d7834a
--- /dev/null
+++ b/src/test/modules/dummy_table_am/sql/reloptions.sql
@@ -0,0 +1,105 @@
+-- Tests for the table AM amoptions callback and add_reloption_to_kind()
+CREATE EXTENSION dummy_table_am;
+
+-- Sanity: CREATE TABLE with AM-specific options succeeds and round-trips
+CREATE TABLE dummy_t (a int) USING dummy_table_am
+    WITH (option_int = 17, option_real = 2.5, option_bool = false,
+          option_enum = 'two', fillfactor = 60);
+SELECT reloptions FROM pg_class
+    WHERE oid = 'dummy_t'::regclass ORDER BY reloptions;
+
+-- AM-specific option ranges are enforced (option_int allows -10..100)
+CREATE TABLE dummy_oor (a int) USING dummy_table_am WITH (option_int = 9999);
+
+-- Unknown options are rejected at CREATE TABLE time
+CREATE TABLE dummy_bad (a int) USING dummy_table_am WITH (parallel_workers = 4);
+
+-- Default values land in pg_class only when the user did not set them
+CREATE TABLE dummy_defaults (a int) USING dummy_table_am;
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_defaults'::regclass;
+DROP TABLE dummy_defaults;
+
+-- ALTER TABLE ... SET (...) with AM-specific option
+ALTER TABLE dummy_t SET (option_int = 42);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+
+-- ALTER TABLE ... SET (...) with an unknown option errors
+ALTER TABLE dummy_t SET (parallel_workers = 4);
+
+-- ALTER TABLE ... RESET (option) round-trips
+ALTER TABLE dummy_t RESET (option_int);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+
+-- SET ACCESS METHOD to an AM with no amoptions of its own (heap) must
+-- still revalidate: an option that only the *current* AM understands
+-- (option_int) must not be silently left behind when switching away.
+ALTER TABLE dummy_t RESET (option_real, option_bool, option_enum);
+ALTER TABLE dummy_t SET (option_int = 42);
+ALTER TABLE dummy_t SET ACCESS METHOD heap;
+-- Confirm nothing changed
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'dummy_t'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+-- Succeeds once the offending option is cleared in the same statement.
+ALTER TABLE dummy_t SET ACCESS METHOD heap, RESET (option_int);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'dummy_t'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+
+-- SET ACCESS METHOD revalidation:
+--   moving a heap table that has standard heap options not accepted by the
+--   new AM (parallel_workers) into dummy_table_am must fail with a clear
+--   message and must NOT silently drop the option.
+CREATE TABLE heap_t (a int) WITH (fillfactor = 70, parallel_workers = 4);
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am;
+-- Confirm nothing changed
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+
+-- After RESETing the offending option in the same statement the swap
+-- succeeds; fillfactor survives because dummy_table_am inherits it via
+-- add_reloption_to_kind().
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am, RESET (parallel_workers);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+
+-- Going back to heap still works: heap accepts fillfactor.
+ALTER TABLE heap_t SET ACCESS METHOD heap;
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+
+-- SET ACCESS METHOD + SET (...) of an option that only the new AM accepts.
+CREATE TABLE heap_to_dt (a int);
+ALTER TABLE heap_to_dt SET ACCESS METHOD dummy_table_am, SET (option_int = 25);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+
+-- Partitioned-table inheritance: AM declared on the parent partition flows
+-- to partitions that don't override it.  Partitioned tables themselves
+-- cannot carry reloptions; the test verifies the AM lookup that
+-- DefineRelation does for partitions.
+CREATE TABLE parted (a int) PARTITION BY RANGE (a) USING dummy_table_am;
+CREATE TABLE parted_p1 PARTITION OF parted FOR VALUES FROM (0) TO (100)
+    WITH (option_int = 11);
+SELECT c.relname,
+       (SELECT amname FROM pg_am WHERE oid = c.relam) AS amname,
+       c.reloptions
+    FROM pg_class c
+    WHERE c.oid IN ('parted'::regclass, 'parted_p1'::regclass)
+    ORDER BY c.relname;
+
+-- A partition that explicitly chooses heap must reject options that are
+-- only known to the parent's AM.
+CREATE TABLE parted_p2 PARTITION OF parted FOR VALUES FROM (100) TO (200)
+    USING heap WITH (option_int = 9);
+
+DROP TABLE parted;
+DROP TABLE heap_to_dt;
+DROP TABLE heap_t;
+DROP TABLE dummy_t;
+
+DROP EXTENSION dummy_table_am;
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 4bca42bb370..07b6b24a5ab 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -5,6 +5,7 @@ subdir('commit_ts')
 subdir('delay_execution')
 subdir('dummy_index_am')
 subdir('dummy_seclabel')
+subdir('dummy_table_am')
 subdir('gin')
 subdir('index')
 subdir('injection_points')
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 56c1f997f88..e6c6443fdf9 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -710,6 +710,8 @@ DropSubscriptionStmt
 DropTableSpaceStmt
 DropUserMappingStmt
 DropdbStmt
+DummyTableEnum
+DummyTableOptions
 DumpComponents
 DumpId
 DumpOptions
-- 
2.43.0



^ permalink  raw  reply  [nested|flat] 25+ messages in thread

* Re: Allow table AMs to define their own reloptions
@ 2026-07-20 06:52  Rafia Sabih <rafia.pghackers@gmail.com>
  parent: Andrew Dunstan <andrew@dunslane.net>
  2 siblings, 1 reply; 25+ messages in thread

From: Rafia Sabih @ 2026-07-20 06:52 UTC (permalink / raw)
  To: Andrew Dunstan <andrew@dunslane.net>; +Cc: Julien Tachoires <julien@tachoires.me>; pgsql-hackers

On Thu, 16 Jul 2026 at 20:02, Andrew Dunstan <andrew@dunslane.net> wrote:

>
>
> On Thu, Jul 16, 2026 at 9:37 AM Rafia Sabih <rafia.pghackers@gmail.com>
> wrote:
>
>>
>>
>> On Thu, 4 Jun 2026 at 04:32, Andrew Dunstan <andrew@dunslane.net> wrote:
>>
>>>
>>> On 2025-05-26 Mo 7:06 AM, Julien Tachoires wrote:
>>> > On Sat, Mar 29, 2025 at 08:46:01AM +0100, Julien Tachoires wrote:
>>> >> On Sun, Mar 02, 2025 at 02:23:54PM +0100, Julien Tachoires wrote:
>>> >>> On Sun, Mar 02, 2025 at 09:56:41AM +0100, Julien Tachoires wrote:
>>> >>>> With the help of the new TAM routine 'relation_options', table
>>> access
>>> >>>> methods can with this patch define their own reloptions
>>> >>>> parser/validator.
>>> >>>>
>>> >>>> These reloptions can be set via the following commands:
>>> >>>> 1. CREATE TABLE ... USING table_am
>>> >>>>         WITH (option1='value1', option2='value2');
>>> >>>> 2. ALTER TABLE ...
>>> >>>>         SET (option1 'value1', option2 'value2');
>>> >>>> 3. ALTER TABLE ... SET ACCESS METHOD table_am
>>> >>>>         OPTIONS (option1 'value1', option2 'value2');
>>> >>>>
>>> >>>> When changing table's access method, the settings inherited from the
>>> >>>> former TAM can be dropped (if not supported by the new TAM) via:
>>> DROP
>>> >>>> option, or, updated via: SET option 'value'.
>>> >>>>
>>> >>>> Currently, tables using different TAMs than heap are able to use
>>> heap's
>>> >>>> reloptions (fillfactor, toast_tuple_target, etc...). With this patch
>>> >>>> applied, this is not the case anymore: if the TAM needs to have
>>> access
>>> >>>> to similar settings to heap ones, they have to explicitly define
>>> them.
>>> >>>>
>>> >>>> The 2nd patch file includes a new test module 'dummy_table_am' which
>>> >>>> implements a dummy table access method utilized to exercise TAM
>>> >>>> reloptions. This test module is strongly based on what we already
>>> have
>>> >>>> in 'dummy_index_am'. 'dummy_table_am' provides a complete example
>>> of TAM
>>> >>>> reloptions definition.
>>> >>>>
>>> >>>> This work is directly derived from SadhuPrasad's patch here [2].
>>> Others
>>> >>>> attempts were posted here [1] and here [3].
>>> >>>>
>>> >>>> [1]
>>> https://www.postgresql.org/message-id/flat/429fb58fa3218221bb17c7bf9e70e1aa6cfc6b5d.camel%40j-davis....
>>> >>>> [2]
>>> https://www.postgresql.org/message-id/flat/CAFF0-CG4KZHdtYHMsonWiXNzj16gWZpduXAn8yF7pDDub+GQMg@mail....
>>> >>>> [3]
>>> https://www.postgresql.org/message-id/flat/AMUA1wBBBxfc3tKRLLdU64rb.1.1683276279979.Hmail.wuhao%40ha...
>>> >>> Please find a new version including minor fixes: 'TAM' terms are
>>> >>> replaced by 'table AM'
>>> >> Please find a new rebased version.
>>> > New rebased version.
>>> >
>>>
>>>
>>> This thread seems to have gone dormant, sadly. Here's a fresh attempt I
>>> made (with assistance from Claude), before I was aware of the existence
>>> of this and other efforts. I think it meets most of the previous
>>> objections, and is in line with what we do for Index AMs.
>>>
>>> I was testing this patch and found this issue,
>> CREATE EXTENSION dummy_table_am;
>> CREATE TABLE t (a int) USING dummy_table_am WITH (option_int = 42);
>> ALTER TABLE t SET ACCESS METHOD heap;
>> SELECT reloptions FROM pg_class WHERE oid = 't'::regclass;
>>   reloptions
>> -----------------
>>  {option_int=42}
>> (1 row)
>> ALTER TABLE t SET (fillfactor = 50);
>> 2026-07-16 19:01:36.060 IST [95058] ERROR:  unrecognized parameter
>> "option_int"
>> 2026-07-16 19:01:36.060 IST [95058] STATEMENT:  ALTER TABLE t SET
>> (fillfactor = 50);
>> ERROR:  unrecognized parameter "option_int"
>>
>> Basically, the option is left behind after changing the tableam to heap,
>> I think that isn't intentional.
>>
>>>
>>>
>>
>
>
> Thanks for reviewing. Good catch!
>
> Basically the code had an early exit in the validation code that shouldn't
> have been there.
>
>  v2 attached with a fix for that, plus a regression test covering this
> direction (dummy_table_am -> heap with a still-set AM-specific option),
> which I verified fails against v1 and passes with the fix. I also
> folded the two independent access-method-resolution code paths in
> DefineRelation into one and added the new test module's typedefs to
> typedefs.list
>
> I can confirm that on the new patch, the alter command gives error and the
one with the RESET does the job,
 ALTER TABLE t SET ACCESS METHOD heap;
2026-07-17 12:55:04.244 IST [55465] ERROR:  unrecognized parameter
"option_int"
2026-07-17 12:55:04.244 IST [55465] STATEMENT:  ALTER TABLE t SET ACCESS
METHOD heap;
ALTER TABLE t SET ACCESS METHOD heap, RESET(option_int);
ALTER TABLE

However, I encountered another issues now, this time with partitioning,
CREATE TABLE parted(a int) PARTITION BY RANGE(a);
CREATE TABLE parted_p1 PARTITION OF parted for values from (1) to (10) WITH
(option_int=50);
                                                  2026-07-20 12:20:54.137
IST [55465] ERROR:  unrecognized parameter "option_int"
2026-07-20 12:20:54.137 IST [55465] STATEMENT:  CREATE TABLE parted_p1
PARTITION OF parted for values from (1) to (10) WITH (option_int=50);
ERROR:  unrecognized parameter "option_int"
So basically partitioned table is not able to read the new reloption.

cheers
>
> andrew
>
>


-- 
Regards,
Rafia Sabih
CYBERTEC PostgreSQL International GmbH

^ permalink  raw  reply  [nested|flat] 25+ messages in thread

* Re: Allow table AMs to define their own reloptions
@ 2026-08-13 11:11  Andrew Dunstan <andrew@dunslane.net>
  parent: Rafia Sabih <rafia.pghackers@gmail.com>
  0 siblings, 0 replies; 25+ messages in thread

From: Andrew Dunstan @ 2026-08-13 11:11 UTC (permalink / raw)
  To: Rafia Sabih <rafia.pghackers@gmail.com>; +Cc: Julien Tachoires <julien@tachoires.me>; pgsql-hackers


On 2026-07-20 Mo 2:52 AM, Rafia Sabih wrote:
>
>
> On Thu, 16 Jul 2026 at 20:02, Andrew Dunstan <andrew@dunslane.net> wrote:
>
>
>
>     On Thu, Jul 16, 2026 at 9:37 AM Rafia Sabih
>     <rafia.pghackers@gmail.com> wrote:
>
>
>
>         On Thu, 4 Jun 2026 at 04:32, Andrew Dunstan
>         <andrew@dunslane.net> wrote:
>
>
>             On 2025-05-26 Mo 7:06 AM, Julien Tachoires wrote:
>             > On Sat, Mar 29, 2025 at 08:46:01AM +0100, Julien
>             Tachoires wrote:
>             >> On Sun, Mar 02, 2025 at 02:23:54PM +0100, Julien
>             Tachoires wrote:
>             >>> On Sun, Mar 02, 2025 at 09:56:41AM +0100, Julien
>             Tachoires wrote:
>             >>>> With the help of the new TAM routine
>             'relation_options', table access
>             >>>> methods can with this patch define their own reloptions
>             >>>> parser/validator.
>             >>>>
>             >>>> These reloptions can be set via the following commands:
>             >>>> 1. CREATE TABLE ... USING table_am
>             >>>>         WITH (option1='value1', option2='value2');
>             >>>> 2. ALTER TABLE ...
>             >>>>         SET (option1 'value1', option2 'value2');
>             >>>> 3. ALTER TABLE ... SET ACCESS METHOD table_am
>             >>>>         OPTIONS (option1 'value1', option2 'value2');
>             >>>>
>             >>>> When changing table's access method, the settings
>             inherited from the
>             >>>> former TAM can be dropped (if not supported by the
>             new TAM) via: DROP
>             >>>> option, or, updated via: SET option 'value'.
>             >>>>
>             >>>> Currently, tables using different TAMs than heap are
>             able to use heap's
>             >>>> reloptions (fillfactor, toast_tuple_target, etc...).
>             With this patch
>             >>>> applied, this is not the case anymore: if the TAM
>             needs to have access
>             >>>> to similar settings to heap ones, they have to
>             explicitly define them.
>             >>>>
>             >>>> The 2nd patch file includes a new test module
>             'dummy_table_am' which
>             >>>> implements a dummy table access method utilized to
>             exercise TAM
>             >>>> reloptions. This test module is strongly based on
>             what we already have
>             >>>> in 'dummy_index_am'. 'dummy_table_am' provides a
>             complete example of TAM
>             >>>> reloptions definition.
>             >>>>
>             >>>> This work is directly derived from SadhuPrasad's
>             patch here [2]. Others
>             >>>> attempts were posted here [1] and here [3].
>             >>>>
>             >>>> [1]
>             https://www.postgresql.org/message-id/flat/429fb58fa3218221bb17c7bf9e70e1aa6cfc6b5d.camel%40j-davis....
>             >>>> [2]
>             https://www.postgresql.org/message-id/flat/CAFF0-CG4KZHdtYHMsonWiXNzj16gWZpduXAn8yF7pDDub+GQMg@mail....
>             >>>> [3]
>             https://www.postgresql.org/message-id/flat/AMUA1wBBBxfc3tKRLLdU64rb.1.1683276279979.Hmail.wuhao%40ha...
>             >>> Please find a new version including minor fixes: 'TAM'
>             terms are
>             >>> replaced by 'table AM'
>             >> Please find a new rebased version.
>             > New rebased version.
>             >
>
>
>             This thread seems to have gone dormant, sadly. Here's a
>             fresh attempt I
>             made (with assistance from Claude), before I was aware of
>             the existence
>             of this and other efforts. I think it meets most of the
>             previous
>             objections, and is in line with what we do for Index AMs.
>
>         I was testing this patch and found this issue,
>         CREATE EXTENSION dummy_table_am;
>         CREATE TABLE t (a int) USING dummy_table_am WITH (option_int =
>         42);
>         ALTER TABLE t SET ACCESS METHOD heap;
>         SELECT reloptions FROM pg_class WHERE oid = 't'::regclass;
>           reloptions
>         -----------------
>          {option_int=42}
>         (1 row)
>         ALTER TABLE t SET (fillfactor = 50);
>         2026-07-16 19:01:36.060 IST [95058] ERROR:  unrecognized
>         parameter "option_int"
>         2026-07-16 19:01:36.060 IST [95058] STATEMENT:  ALTER TABLE t
>         SET (fillfactor = 50);
>         ERROR:  unrecognized parameter "option_int"
>
>         Basically, the option is left behind after changing the
>         tableam to heap, I think that isn't intentional.
>
>
>
>
>     Thanks for reviewing. Good catch!
>
>     Basically the code had an early exit in the validation code that
>     shouldn't have been there.
>
>      v2 attached with a fix for that, plus a regression test covering this
>     direction (dummy_table_am -> heap with a still-set AM-specific
>     option),
>     which I verified fails against v1 and passes with the fix. I also
>     folded the two independent access-method-resolution code paths in
>     DefineRelation into one and added the new test module's typedefs to
>     typedefs.list
>
> I can confirm that on the new patch, the alter command gives error and 
> the one with the RESET does the job,
>  ALTER TABLE t SET ACCESS METHOD heap;
> 2026-07-17 12:55:04.244 IST [55465] ERROR:  unrecognized parameter 
> "option_int"
> 2026-07-17 12:55:04.244 IST [55465] STATEMENT:  ALTER TABLE t SET 
> ACCESS METHOD heap;
> ALTER TABLE t SET ACCESS METHOD heap, RESET(option_int);
> ALTER TABLE
>
> However, I encountered another issues now, this time with partitioning,
> CREATE TABLE parted(a int) PARTITION BY RANGE(a);
> CREATE TABLE parted_p1 PARTITION OF parted for values from (1) to (10) 
> WITH (option_int=50);                           2026-07-20 
> 12:20:54.137 IST [55465] ERROR:  unrecognized parameter "option_int"
> 2026-07-20 12:20:54.137 IST [55465] STATEMENT:  CREATE TABLE parted_p1 
> PARTITION OF parted for values from (1) to (10) WITH (option_int=50);
> ERROR:  unrecognized parameter "option_int"
> So basically partitioned table is not able to read the new reloption.
>
>
>

I think this is a test issue rather than a patch issue. Neither
statement names an access method: "parted" is a plain partitioned table
with no USING, so it falls back to default_table_access_method, i.e.
heap; parted_p1 doesn't override that either. heap has no idea what
option_int is, so the error is correct -- dummy_table_am was never in
the picture for either table.

If I add USING dummy_table_am to the parent, it works as expected:

     CREATE TABLE parted(a int) PARTITION BY RANGE(a) USING dummy_table_am;
     CREATE TABLE parted_p1 PARTITION OF parted FOR VALUES FROM (1) TO (10)
         WITH (option_int=50);
     -- CREATE TABLE, no error; reloptions = {option_int=50}


cheers


andrew

--
Andrew Dunstan
EDB:https://www.enterprisedb.com

^ permalink  raw  reply  [nested|flat] 25+ messages in thread

* Re: Allow table AMs to define their own reloptions
@ 2026-08-14 21:46  Zsolt Parragi <zsolt.parragi@percona.com>
  parent: Andrew Dunstan <andrew@dunslane.net>
  2 siblings, 2 replies; 25+ messages in thread

From: Zsolt Parragi @ 2026-08-14 21:46 UTC (permalink / raw)
  To: Andrew Dunstan <andrew@dunslane.net>; +Cc: pgsql-hackers@lists.postgresql.org, Rafia Sabih <rafia.pghackers@gmail.com>; Julien Tachoires <julien@tachoires.me>

Hello!

+dummy_table_am behaves like a heap table but accepts a different
+set of reloptions:
+
+  - "fillfactor"     (inherited from the core heap registration via
+                      add_reloption_to_kind)

+bool
+RelationHasStdRdOptions(Relation relation)
+{
+	if (relation->rd_options == NULL)
+		return false;
+	if (relation->rd_tableam == NULL)
+		return false;
+	return relation->rd_tableam->amoptions == NULL;
+}

dummy_table_am seems to accept but ignore fillfactor options with
this, which based on the documentation above seem unintended?

CREATE TABLE heap_ff10  (a int) WITH (fillfactor=10);
CREATE TABLE dummy_ff10 (a int) USING dummy_table_am WITH (fillfactor=10);

INSERT INTO heap_ff10  SELECT generate_series(1,200000);
INSERT INTO dummy_ff10 SELECT generate_series(1,200000);

SELECT relname, relpages,
       pg_size_pretty(pg_relation_size(oid)) AS size
FROM pg_class
WHERE relname IN ('heap_ff10','dummy_ff10')
ORDER BY relname;
  relname   | relpages |  size
------------+----------+---------
 dummy_ff10 |      885 | 7080 kB
 heap_ff10  |     9091 | 71 MB
(2 rows)

Also it isn't critical for the current tests, but it doesn't seem to
support text columns, so that contradicts the generic behaves like
heap claim a bit:

CREATE TABLE t_txt (a int, b text) USING dummy_table_am;
ERROR:  only heap AM is supported






^ permalink  raw  reply  [nested|flat] 25+ messages in thread

* Re: Allow table AMs to define their own reloptions
@ 2026-08-17 09:40  Junwang Zhao <zhjwpku@gmail.com>
  parent: Andrew Dunstan <andrew@dunslane.net>
  2 siblings, 0 replies; 25+ messages in thread

From: Junwang Zhao @ 2026-08-17 09:40 UTC (permalink / raw)
  To: Andrew Dunstan <andrew@dunslane.net>; +Cc: Rafia Sabih <rafia.pghackers@gmail.com>; Julien Tachoires <julien@tachoires.me>; pgsql-hackers

Hi Andrew,

On Thu, Jul 16, 2026 at 10:33 PM Andrew Dunstan <andrew@dunslane.net> wrote:
>
>
>
> On Thu, Jul 16, 2026 at 9:37 AM Rafia Sabih <rafia.pghackers@gmail.com> wrote:
>>
>>
>>
>> On Thu, 4 Jun 2026 at 04:32, Andrew Dunstan <andrew@dunslane.net> wrote:
>>>
>>>
>>> On 2025-05-26 Mo 7:06 AM, Julien Tachoires wrote:
>>> > On Sat, Mar 29, 2025 at 08:46:01AM +0100, Julien Tachoires wrote:
>>> >> On Sun, Mar 02, 2025 at 02:23:54PM +0100, Julien Tachoires wrote:
>>> >>> On Sun, Mar 02, 2025 at 09:56:41AM +0100, Julien Tachoires wrote:
>>> >>>> With the help of the new TAM routine 'relation_options', table access
>>> >>>> methods can with this patch define their own reloptions
>>> >>>> parser/validator.
>>> >>>>
>>> >>>> These reloptions can be set via the following commands:
>>> >>>> 1. CREATE TABLE ... USING table_am
>>> >>>>         WITH (option1='value1', option2='value2');
>>> >>>> 2. ALTER TABLE ...
>>> >>>>         SET (option1 'value1', option2 'value2');
>>> >>>> 3. ALTER TABLE ... SET ACCESS METHOD table_am
>>> >>>>         OPTIONS (option1 'value1', option2 'value2');
>>> >>>>
>>> >>>> When changing table's access method, the settings inherited from the
>>> >>>> former TAM can be dropped (if not supported by the new TAM) via: DROP
>>> >>>> option, or, updated via: SET option 'value'.
>>> >>>>
>>> >>>> Currently, tables using different TAMs than heap are able to use heap's
>>> >>>> reloptions (fillfactor, toast_tuple_target, etc...). With this patch
>>> >>>> applied, this is not the case anymore: if the TAM needs to have access
>>> >>>> to similar settings to heap ones, they have to explicitly define them.
>>> >>>>
>>> >>>> The 2nd patch file includes a new test module 'dummy_table_am' which
>>> >>>> implements a dummy table access method utilized to exercise TAM
>>> >>>> reloptions. This test module is strongly based on what we already have
>>> >>>> in 'dummy_index_am'. 'dummy_table_am' provides a complete example of TAM
>>> >>>> reloptions definition.
>>> >>>>
>>> >>>> This work is directly derived from SadhuPrasad's patch here [2]. Others
>>> >>>> attempts were posted here [1] and here [3].
>>> >>>>
>>> >>>> [1] https://www.postgresql.org/message-id/flat/429fb58fa3218221bb17c7bf9e70e1aa6cfc6b5d.camel%40j-davis....
>>> >>>> [2] https://www.postgresql.org/message-id/flat/CAFF0-CG4KZHdtYHMsonWiXNzj16gWZpduXAn8yF7pDDub+GQMg@mail....
>>> >>>> [3] https://www.postgresql.org/message-id/flat/AMUA1wBBBxfc3tKRLLdU64rb.1.1683276279979.Hmail.wuhao%40ha...
>>> >>> Please find a new version including minor fixes: 'TAM' terms are
>>> >>> replaced by 'table AM'
>>> >> Please find a new rebased version.
>>> > New rebased version.
>>> >
>>>
>>>
>>> This thread seems to have gone dormant, sadly. Here's a fresh attempt I
>>> made (with assistance from Claude), before I was aware of the existence
>>> of this and other efforts. I think it meets most of the previous
>>> objections, and is in line with what we do for Index AMs.
>>>
>> I was testing this patch and found this issue,
>> CREATE EXTENSION dummy_table_am;
>> CREATE TABLE t (a int) USING dummy_table_am WITH (option_int = 42);
>> ALTER TABLE t SET ACCESS METHOD heap;
>> SELECT reloptions FROM pg_class WHERE oid = 't'::regclass;
>>   reloptions
>> -----------------
>>  {option_int=42}
>> (1 row)
>> ALTER TABLE t SET (fillfactor = 50);
>> 2026-07-16 19:01:36.060 IST [95058] ERROR:  unrecognized parameter "option_int"
>> 2026-07-16 19:01:36.060 IST [95058] STATEMENT:  ALTER TABLE t SET (fillfactor = 50);
>> ERROR:  unrecognized parameter "option_int"
>>
>> Basically, the option is left behind after changing the tableam to heap, I think that isn't intentional.
>>>
>>>
>>
>
>
>
> Thanks for reviewing. Good catch!
>
> Basically the code had an early exit in the validation code that shouldn't have been there.
>
>  v2 attached with a fix for that, plus a regression test covering this
> direction (dummy_table_am -> heap with a still-set AM-specific option),
> which I verified fails against v1 and passes with the fix. I also
> folded the two independent access-method-resolution code paths in
> DefineRelation into one and added the new test module's typedefs to
> typedefs.list
>
> cheers
>
> andrew
>

I checked the usage of extractRelOptions, and found that in
extract_autovac_opts, it unconditionally interprets the result as
StdRdOptions, the patch doesn't seem to consider that, is that
intentional?

-- 
Regards
Junwang Zhao





^ permalink  raw  reply  [nested|flat] 25+ messages in thread

* Re: Allow table AMs to define their own reloptions
@ 2026-08-17 09:45  Junwang Zhao <zhjwpku@gmail.com>
  parent: Zsolt Parragi <zsolt.parragi@percona.com>
  1 sibling, 1 reply; 25+ messages in thread

From: Junwang Zhao @ 2026-08-17 09:45 UTC (permalink / raw)
  To: Zsolt Parragi <zsolt.parragi@percona.com>; +Cc: Andrew Dunstan <andrew@dunslane.net>; pgsql-hackers@lists.postgresql.org, Rafia Sabih <rafia.pghackers@gmail.com>; Julien Tachoires <julien@tachoires.me>

On Sat, Aug 15, 2026 at 5:46 AM Zsolt Parragi <zsolt.parragi@percona.com> wrote:
>
> Hello!
>
> +dummy_table_am behaves like a heap table but accepts a different
> +set of reloptions:
> +
> +  - "fillfactor"     (inherited from the core heap registration via
> +                      add_reloption_to_kind)
>
> +bool
> +RelationHasStdRdOptions(Relation relation)
> +{
> +       if (relation->rd_options == NULL)
> +               return false;
> +       if (relation->rd_tableam == NULL)
> +               return false;
> +       return relation->rd_tableam->amoptions == NULL;
> +}
>
> dummy_table_am seems to accept but ignore fillfactor options with
> this, which based on the documentation above seem unintended?
>
> CREATE TABLE heap_ff10  (a int) WITH (fillfactor=10);
> CREATE TABLE dummy_ff10 (a int) USING dummy_table_am WITH (fillfactor=10);
>
> INSERT INTO heap_ff10  SELECT generate_series(1,200000);
> INSERT INTO dummy_ff10 SELECT generate_series(1,200000);
>
> SELECT relname, relpages,
>        pg_size_pretty(pg_relation_size(oid)) AS size
> FROM pg_class
> WHERE relname IN ('heap_ff10','dummy_ff10')
> ORDER BY relname;
>   relname   | relpages |  size
> ------------+----------+---------
>  dummy_ff10 |      885 | 7080 kB
>  heap_ff10  |     9091 | 71 MB
> (2 rows)
>

I think this might be due to the fact that dummy_table_am doesn't
handle the `fillfactor` itself, since it's just a test module, I think
it's ok.

> Also it isn't critical for the current tests, but it doesn't seem to
> support text columns, so that contradicts the generic behaves like
> heap claim a bit:
>
> CREATE TABLE t_txt (a int, b text) USING dummy_table_am;
> ERROR:  only heap AM is supported
>
>


-- 
Regards
Junwang Zhao






^ permalink  raw  reply  [nested|flat] 25+ messages in thread

* Re: Allow table AMs to define their own reloptions
@ 2026-08-21 19:09  Andrew Dunstan <andrew@dunslane.net>
  parent: Zsolt Parragi <zsolt.parragi@percona.com>
  1 sibling, 0 replies; 25+ messages in thread

From: Andrew Dunstan @ 2026-08-21 19:09 UTC (permalink / raw)
  To: Zsolt Parragi <zsolt.parragi@percona.com>; Junwang Zhao <zhjwpku@gmail.com>; +Cc: pgsql-hackers@lists.postgresql.org, Rafia Sabih <rafia.pghackers@gmail.com>; Julien Tachoires <julien@tachoires.me>


On 2026-08-14 Fr 5:46 PM, Zsolt Parragi wrote:
> Hello!
>
> +dummy_table_am behaves like a heap table but accepts a different
> +set of reloptions:
> +
> +  - "fillfactor"     (inherited from the core heap registration via
> +                      add_reloption_to_kind)
>
> +bool
> +RelationHasStdRdOptions(Relation relation)
> +{
> +	if (relation->rd_options == NULL)
> +		return false;
> +	if (relation->rd_tableam == NULL)
> +		return false;
> +	return relation->rd_tableam->amoptions == NULL;
> +}
>
> dummy_table_am seems to accept but ignore fillfactor options with
> this, which based on the documentation above seem unintended?
>
> CREATE TABLE heap_ff10  (a int) WITH (fillfactor=10);
> CREATE TABLE dummy_ff10 (a int) USING dummy_table_am WITH (fillfactor=10);
>
> INSERT INTO heap_ff10  SELECT generate_series(1,200000);
> INSERT INTO dummy_ff10 SELECT generate_series(1,200000);
>
> SELECT relname, relpages,
>         pg_size_pretty(pg_relation_size(oid)) AS size
> FROM pg_class
> WHERE relname IN ('heap_ff10','dummy_ff10')
> ORDER BY relname;
>    relname   | relpages |  size
> ------------+----------+---------
>   dummy_ff10 |      885 | 7080 kB
>   heap_ff10  |     9091 | 71 MB
> (2 rows)
>
> Also it isn't critical for the current tests, but it doesn't seem to
> support text columns, so that contradicts the generic behaves like
> heap claim a bit:
>
> CREATE TABLE t_txt (a int, b text) USING dummy_table_am;
> ERROR:  only heap AM is supported


Both of your reports are real bugs, fixed in v5. Replying to both your 
email and Junwang's together.

 > [Zsolt] dummy_table_am seems to accept but ignore fillfactor
 > options [...]
 > heap_ff10 | 9091 | 71 MB
 > dummy_ff10 | 885 | 7080 kB

Confirmed, and not just fillfactor. Four macros/call sites read
StdRdOptions fields straight out of rd_options behind a check,
RelationHasStdRdOptions(), that only asked "does this AM have a
custom amoptions at all" rather than "is this field actually there"
-- blocking exactly the case add_reloption_to_kind() exists for.
Same problem for toast_tuple_target, parallel_workers,
vacuum_index_cleanup, vacuum_truncate,
vacuum_max_eager_freeze_failure_rate, and autovacuum_enabled.

Fixed with TableAmRoutine.has_std_options_prefix: an AM sets it when
its amoptions struct embeds a full StdRdOptions as its first member,
and RelationHasStdRdOptions() trusts that. dummy_table_am now embeds
StdRdOptions and registers all seven other fields too, not just
fillfactor, so each gets its real default instead of zero. Re-ran
your repro:

     dummy_ff10 | 9091 | 71 MB
     heap_ff10  | 9091 | 71 MB

Matches now. Added a regression case that fails on pre-v5, passes on v5.

 > [Zsolt] doesn't seem to support text columns [...]
 > ERROR: only heap AM is supported
 > [Junwang] I think it's ok, it's just a test module

Also a real bug. Fixed (after some -hackers discussion) by having
dummy_table_am override relation_toast_am directly. Text columns work
now.

 > [Junwang] extract_autovac_opts... unconditionally interprets the
 > result as StdRdOptions, is that intentional?

It is intentional.

I think the behaviour is right: it means autovacuum's own
scheduling only ever sees the standard autovacuum_* names, never an
AM's own amoptions. (And making it AM-aware would cost a catalog lookup
on every relation in autovacuum's periodic scan, for a capability
nothing needs.) Documented that constraint in tableam.sgml rather
than leave it to be rediscovered.


cheers


andrew

--
Andrew Dunstan
EDB: https://www.enterprisedb.com

Attachments:

  [text/x-patch] v5-0001-Add-amoptions-callback-to-table-access-methods.patch (60.5K, ../../5a9683e8-b825-48e9-945b-0ab668cef4f2@dunslane.net/2-v5-0001-Add-amoptions-callback-to-table-access-methods.patch)
  download | inline diff:
From eb7fa4ae07f7bcb67b22266580392e1585a1d013 Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <andrew@dunslane.net>
Date: Tue, 12 May 2026 09:03:33 -0400
Subject: [PATCH v5] Add amoptions callback to table access methods

Table access methods have no way to define their own storage
parameters: CREATE/ALTER TABLE always parse the WITH clause with the
standard heap parser, regardless of the table's actual AM. Index AMs
have had this ability via IndexAmRoutine.amoptions for a long time;
give table AMs the same option.

TableAmRoutine gets an optional amoptions field, same signature as
the index AM version. table_reloptions() dispatches to it when set,
else falls back to heap_reloptions() as today. An AM that supplies
amoptions owns the option set entirely, and the bytea it returns,
stored verbatim in Relation->rd_options, dictates the layout its
other callbacks read.

DefineRelation, ATExecSetRelOptions, RelationParseRelOptions and
extractRelOptions all route through table_reloptions() for
RELKIND_RELATION/RELKIND_MATVIEW. The AM is resolved from the USING
clause, the partition parent, or default_table_access_method at
CREATE TABLE, and from rel->rd_tableam at ALTER TABLE SET -- except
a SET ACCESS METHOD queued in the same statement uses the new AM's
parser instead, so "SET ACCESS METHOD x, SET (foo = bar)" works when
foo belongs to x. SET ACCESS METHOD also revalidates the relation's
final reloptions against the new AM once other subcommands in the
statement have run, turning a reloption the new AM doesn't accept
into an immediate error instead of a silent drop at the next
relcache load; RESET in the same statement clears it.

Core code that reads StdRdOptions fields straight out of rd_options
(RelationGetFillFactor and friends, plus direct casts in
vacuum.c/index.c) now checks RelationHasStdRdOptions() first, since
an AM-owned bytea isn't guaranteed to be StdRdOptions-shaped. An AM
can opt back into all of these at once via
TableAmRoutine.has_std_options_prefix, set when its own struct embeds
a full StdRdOptions as its first member -- and it must register every
field these call sites read, not just the ones it cares about, since
an embedded field it never registers is left zeroed rather than at
its real default (wrong for e.g. parallel_workers, default -1).

add_reloption_to_kind(name, kind) extends an existing reloption's
kind bitmask, so an AM can accept a core-registered option
(fillfactor, autovacuum_*, ...) without redeclaring it; this is how
an AM fills in the embedded StdRdOptions fields above. One exception:
autovacuum's own scheduling always parses via the standard heap
parser regardless of AM, so it only ever sees the standard
autovacuum_* names, never an AM's own amoptions -- consulting each
relation's AM during autovacuum's periodic scan isn't worth a
catalog lookup per relation for a capability nothing needs.

src/test/modules/dummy_table_am demonstrates the API: it copies
heap's TableAmRoutine wholesale and overrides two callbacks.
amoptions parses a mix of inherited standard options and its own
into a struct embedding StdRdOptions. relation_toast_am also needs
overriding, since heap's default returns the calling relation's own
AM oid -- correct for heap, wrong for an AM that just inherits it,
whose TOAST tables would then belong to that AM instead of heap.
---
 doc/src/sgml/ref/alter_table.sgml             |  19 ++
 doc/src/sgml/tableam.sgml                     |  92 +++++++
 src/backend/access/common/reloptions.c        |  88 +++++-
 src/backend/catalog/index.c                   |   3 +-
 src/backend/commands/tablecmds.c              | 170 +++++++++++-
 src/backend/commands/vacuum.c                 |   7 +-
 src/backend/utils/cache/relcache.c            |   4 +-
 src/include/access/reloptions.h               |   3 +
 src/include/access/tableam.h                  |  60 +++++
 src/include/utils/rel.h                       |  29 +-
 src/test/modules/Makefile                     |   1 +
 src/test/modules/dummy_table_am/Makefile      |  20 ++
 src/test/modules/dummy_table_am/README        |  21 ++
 .../dummy_table_am/dummy_table_am--1.0.sql    |  13 +
 .../modules/dummy_table_am/dummy_table_am.c   | 253 ++++++++++++++++++
 .../dummy_table_am/dummy_table_am.control     |   5 +
 .../dummy_table_am/expected/reloptions.out    | 184 +++++++++++++
 src/test/modules/dummy_table_am/meson.build   |  33 +++
 .../modules/dummy_table_am/sql/reloptions.sql | 119 ++++++++
 src/test/modules/meson.build                  |   1 +
 20 files changed, 1111 insertions(+), 14 deletions(-)
 create mode 100644 src/test/modules/dummy_table_am/Makefile
 create mode 100644 src/test/modules/dummy_table_am/README
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am.c
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am.control
 create mode 100644 src/test/modules/dummy_table_am/expected/reloptions.out
 create mode 100644 src/test/modules/dummy_table_am/meson.build
 create mode 100644 src/test/modules/dummy_table_am/sql/reloptions.sql

diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index 453395c5c73..3d123dc17c8 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -803,6 +803,25 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
       causing future partitions to default to
       <varname>default_table_access_method</varname>.
      </para>
+     <para>
+      The new access method must accept every storage parameter
+      currently set on the table.  An access method may define its own
+      set of parameters, so a parameter that was legal under the old
+      access method is not necessarily recognized by the new one; if any
+      such parameter remains, <command>ALTER TABLE</command> raises an
+      error rather than silently dropping the value.  The unwanted
+      parameters can be cleared in the same statement, for example:
+<programlisting>
+ALTER TABLE measurement
+    SET ACCESS METHOD columnar,
+    RESET (fillfactor);
+</programlisting>
+      Validation is performed once, after all storage-parameter
+      sub-commands in the statement have been applied, so the order of
+      <literal>SET</literal>, <literal>RESET</literal>, and
+      <literal>SET ACCESS METHOD</literal> within the same
+      <command>ALTER TABLE</command> does not matter.
+     </para>
     </listitem>
    </varlistentry>
 
diff --git a/doc/src/sgml/tableam.sgml b/doc/src/sgml/tableam.sgml
index 9ccf5b739ed..68d8fd62f44 100644
--- a/doc/src/sgml/tableam.sgml
+++ b/doc/src/sgml/tableam.sgml
@@ -152,4 +152,96 @@ my_tableam_handler(PG_FUNCTION_ARGS)
   its implementation.
  </para>
 
+ <sect1 id="tableam-reloptions">
+  <title>Table Access Method Storage Parameters</title>
+
+  <para>
+   A table access method may define its own set of storage parameters
+   (reloptions) by supplying an <structfield>amoptions</structfield>
+   callback in its <structname>TableAmRoutine</structname>.  The callback
+   has the same signature as the corresponding index AM callback; it is
+   invoked at <command>CREATE TABLE</command> and
+   <command>ALTER TABLE</command> time to parse and validate the option
+   set, and at relation open time (with <literal>validate = false</literal>)
+   to build the in-memory representation stored in
+   <structfield>Relation-&gt;rd_options</structfield>.  An AM that does not
+   supply an <structfield>amoptions</structfield> callback inherits the
+   standard heap parser and the <structname>StdRdOptions</structname>
+   layout.
+  </para>
+
+  <para>
+   When the AM provides its own parser it owns the option set entirely:
+   it may accept all standard heap options, only a subset, or define
+   parameters of its own.  The bytea returned from the callback is
+   stored verbatim in <structfield>rd_options</structfield>, so the AM
+   also dictates the in-memory layout that its other callbacks read.
+  </para>
+
+  <para>
+   The parser is expected to validate user-supplied values, but
+   <emphasis>must not silently rewrite them</emphasis>.  In particular
+   it must not coerce out-of-range values to a default, drop unknown
+   options when <literal>validate = true</literal>, or substitute a
+   different unit; the user must be able to verify with
+   <command>SELECT reloptions FROM pg_class</command> that the values
+   they supplied are what the relation will use.  Out-of-range or
+   unknown options should be reported with
+   <function>ereport(ERROR)</function>.
+  </para>
+
+  <para>
+   To honour an option that the core code already registers for
+   <literal>RELOPT_KIND_HEAP</literal> (for example
+   <literal>fillfactor</literal> or the <literal>autovacuum_*</literal>
+   family), call <function>add_reloption_to_kind()</function> once per
+   option in the module's <function>_PG_init</function>.  This extends
+   the existing registration with the AM's own kind without forcing
+   the AM to re-declare each option.
+  </para>
+
+  <para>
+   The <literal>autovacuum_*</literal> family is a special case, and
+   reusing the standard names is the only way to reach autovacuum's own
+   scheduling logic with them at all: every field of
+   <structname>AutoVacOpts</structname> (whether autovacuum runs on the
+   table, its vacuum/analyze thresholds and scale factors, freeze ages,
+   cost delay and limit, log-duration settings, and so on) is extracted
+   by autovacuum's periodic scan of <structname>pg_class</structname>
+   using the standard heap parser unconditionally, never the relation's
+   own <structfield>amoptions</structfield> callback -- looking up and
+   calling the AM's own parser for every relation on every autovacuum
+   cycle would add a catalog lookup to a hot path for no AM that
+   currently needs it.  This is independent of
+   <structfield>has_std_options_prefix</structfield>: it applies even to an
+   AM whose reloptions struct does not embed
+   <structname>StdRdOptions</structname> at all.  A table AM can only
+   affect autovacuum's own scheduling by exposing these standard
+   <literal>autovacuum_*</literal> names via
+   <function>add_reloption_to_kind()</function>; a differently-named
+   option of its own is stored and readable from
+   <structfield>Relation-&gt;rd_options</structfield> like any other
+   AM-specific option, but autovacuum's scheduling logic will never see
+   it.
+  </para>
+
+  <para>
+   <command>ALTER TABLE ... SET ACCESS METHOD</command> revalidates the
+   relation's current storage parameters against the new access
+   method's parser after all <literal>SET</literal>,
+   <literal>RESET</literal>, and <literal>REPLACE</literal>
+   sub-commands in the same statement have been applied.  A parameter
+   that is not accepted by the new AM raises an error; the user can
+   clear such parameters in the same statement (see <xref
+   linkend="sql-altertable"/>).
+  </para>
+
+  <para>
+   See <filename>src/test/modules/dummy_table_am</filename> for a
+   minimal example that exercises both
+   <structfield>amoptions</structfield> and
+   <function>add_reloption_to_kind()</function>.
+  </para>
+ </sect1>
+
 </chapter>
diff --git a/src/backend/access/common/reloptions.c b/src/backend/access/common/reloptions.c
index 3e832c3797e..ccf58f53110 100644
--- a/src/backend/access/common/reloptions.c
+++ b/src/backend/access/common/reloptions.c
@@ -24,6 +24,7 @@
 #include "access/nbtree.h"
 #include "access/reloptions.h"
 #include "access/spgist_private.h"
+#include "access/tableam.h"
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "commands/tablespace.h"
@@ -749,6 +750,44 @@ add_reloption_kind(void)
 	return (relopt_kind) last_assigned_kind;
 }
 
+/*
+ * add_reloption_to_kind
+ *		Extend an already-registered reloption so it is also accepted for
+ *		the given kind.
+ *
+ * Useful for table access methods that want their own RELOPT_KIND_*
+ * parser to accept standard options (fillfactor, parallel_workers,
+ * autovacuum_*, etc.) that core registers only for RELOPT_KIND_HEAP.
+ * Without this, every AM that wants the standard option set would
+ * have to re-register each option under its own kind.
+ *
+ * 'name' must match an existing option; 'kind' is OR'ed into that
+ * option's kinds mask.  Errors if no option with that name exists.
+ */
+void
+add_reloption_to_kind(const char *name, relopt_kind kind)
+{
+	int			namelen = strlen(name);
+	int			i;
+
+	if (need_initialization)
+		initialize_reloptions();
+
+	for (i = 0; relOpts[i]; i++)
+	{
+		if (relOpts[i]->namelen == namelen &&
+			strncmp(relOpts[i]->name, name, namelen) == 0)
+		{
+			relOpts[i]->kinds |= kind;
+			return;
+		}
+	}
+
+	ereport(ERROR,
+			(errcode(ERRCODE_UNDEFINED_OBJECT),
+			 errmsg("reloption \"%s\" does not exist", name)));
+}
+
 /*
  * add_reloption
  *		Add an already-created custom reloption to the list, and recompute the
@@ -1516,8 +1555,11 @@ extractRelOptions(HeapTuple tuple, TupleDesc tupdesc,
 	switch (classForm->relkind)
 	{
 		case RELKIND_RELATION:
-		case RELKIND_TOASTVALUE:
 		case RELKIND_MATVIEW:
+			options = table_reloptions(amoptions, classForm->relkind,
+									   datum, false);
+			break;
+		case RELKIND_TOASTVALUE:
 			options = heap_reloptions(classForm->relkind, datum, false);
 			break;
 		case RELKIND_PARTITIONED_TABLE:
@@ -2187,6 +2229,50 @@ heap_reloptions(char relkind, Datum reloptions, bool validate)
 	}
 }
 
+/*
+ * Parse options for a table relation, dispatching to the access method's
+ * own option parser when it supplies one.
+ *
+ *	amoptions	the table AM's option parser, or NULL to fall back to the
+ *				standard heap parser for this relkind.
+ *	relkind		the relation's kind.
+ *	reloptions	options as a text[] datum.
+ *	validate	error flag for unknown options or bad values.
+ *
+ * When amoptions is non-NULL the AM owns the option set: it may accept
+ * all standard heap options, only a subset, or define its own.  The
+ * returned bytea is laid out as the AM dictates (it is stored verbatim
+ * in Relation->rd_options).  When amoptions is NULL the result is the
+ * standard StdRdOptions layout.
+ */
+bytea *
+table_reloptions(amoptions_function amoptions, char relkind,
+				 Datum reloptions, bool validate)
+{
+	if (amoptions != NULL)
+		return amoptions(reloptions, validate);
+	return heap_reloptions(relkind, reloptions, validate);
+}
+
+/*
+ * Returns true when the relation's rd_options buffer is laid out as
+ * StdRdOptions.  The rel.h accessor macros (RelationGetFillFactor,
+ * RelationIsUsedAsCatalogTable, ...) check this first before casting
+ * rd_options to StdRdOptions, so that a table access method which supplies
+ * its own amoptions callback (and therefore owns the rd_options layout)
+ * does not have its bytes misinterpreted.
+ */
+bool
+RelationHasStdRdOptions(Relation relation)
+{
+	if (relation->rd_options == NULL)
+		return false;
+	if (relation->rd_tableam == NULL)
+		return false;
+	return relation->rd_tableam->amoptions == NULL ||
+		relation->rd_tableam->has_std_options_prefix;
+}
+
 
 /*
  * Parse options for indexes.
diff --git a/src/backend/catalog/index.c b/src/backend/catalog/index.c
index 9407c357f27..b08aa11b206 100644
--- a/src/backend/catalog/index.c
+++ b/src/backend/catalog/index.c
@@ -2871,7 +2871,8 @@ index_update_stats(Relation rel,
 	{
 		if (AutoVacuumingActive())
 		{
-			StdRdOptions *options = (StdRdOptions *) rel->rd_options;
+			StdRdOptions *options = RelationHasStdRdOptions(rel) ?
+				(StdRdOptions *) rel->rd_options : NULL;
 
 			if (options != NULL && !options->autovacuum.enabled)
 				update_stats = false;
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index eec09ba1ded..24e8b787f0b 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -693,9 +693,11 @@ static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
 								const char *tablespacename, LOCKMODE lockmode);
 static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
 static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
+static void ATValidateAccessMethodOptions(List **wqueue);
 static void ATExecSetRelOptions(Relation rel, List *defList,
 								AlterTableType operation,
-								LOCKMODE lockmode);
+								LOCKMODE lockmode,
+								Oid newAccessMethodId);
 static void ATExecEnableDisableTrigger(Relation rel, const char *trigname,
 									   char fires_when, bool skip_system, bool recurse,
 									   LOCKMODE lockmode);
@@ -961,6 +963,41 @@ DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId,
 		case RELKIND_PARTITIONED_TABLE:
 			(void) partitioned_table_reloptions(reloptions, true);
 			break;
+		case RELKIND_RELATION:
+		case RELKIND_MATVIEW:
+			{
+				amoptions_function amoptions = NULL;
+				Oid			amoid = InvalidOid;
+
+				/*
+				 * Resolve the table AM so its option parser can validate
+				 * AM-specific reloptions.  An AM that does not register a
+				 * parser falls back to default_reloptions for
+				 * RELOPT_KIND_HEAP.
+				 */
+				if (stmt->accessMethod != NULL)
+					amoid = get_table_am_oid(stmt->accessMethod, false);
+				else if (stmt->partbound != NULL && inheritOids != NIL)
+					amoid = get_rel_relam(linitial_oid(inheritOids));
+				else
+					amoid = get_table_am_oid(default_table_access_method, false);
+
+				if (OidIsValid(amoid))
+				{
+					HeapTuple	tuple;
+
+					tuple = SearchSysCache1(AMOID, ObjectIdGetDatum(amoid));
+					if (HeapTupleIsValid(tuple))
+					{
+						Form_pg_am	amform = (Form_pg_am) GETSTRUCT(tuple);
+
+						amoptions = GetTableAmRoutine(amform->amhandler)->amoptions;
+						ReleaseSysCache(tuple);
+					}
+				}
+				(void) table_reloptions(amoptions, relkind, reloptions, true);
+			}
+			break;
 		default:
 			(void) heap_reloptions(relkind, reloptions, true);
 	}
@@ -4924,6 +4961,18 @@ ATController(AlterTableStmt *parsetree,
 	/* Phase 2: update system catalogs */
 	ATRewriteCatalogs(&wqueue, lockmode, context);
 
+	/*
+	 * After all phase-2 subcommands have committed any SET / RESET / REPLACE
+	 * option changes to pg_class, but before any rewrite, ensure the final
+	 * reloptions are accepted by the access method the relation will use once
+	 * the ALTER TABLE finishes.  This catches the case where SET ACCESS
+	 * METHOD changes the AM and leaves pre-existing reloptions in pg_class
+	 * that the new AM does not recognise; without this check the new AM's
+	 * option parser would be called with validate=false at relcache load time
+	 * and silently ignore them.
+	 */
+	ATValidateAccessMethodOptions(&wqueue);
+
 	/* Phase 3: scan/rewrite tables as needed, and run afterStmts */
 	ATRewriteTables(parsetree, &wqueue, lockmode, context);
 }
@@ -5595,7 +5644,17 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
 		case AT_SetRelOptions:	/* SET (...) */
 		case AT_ResetRelOptions:	/* RESET (...) */
 		case AT_ReplaceRelOptions:	/* replace entire option list */
-			ATExecSetRelOptions(rel, (List *) cmd->def, cmd->subtype, lockmode);
+
+			/*
+			 * If SET ACCESS METHOD is queued in the same ALTER TABLE, the
+			 * reloptions in pg_class will be parsed by the new AM after the
+			 * statement finishes; tell ATExecSetRelOptions to validate
+			 * against that AM rather than the relation's current AM.  This
+			 * lets a user write ALTER TABLE t SET ACCESS METHOD x, SET (foo =
+			 * bar) where foo is recognised by x but not by the current AM.
+			 */
+			ATExecSetRelOptions(rel, (List *) cmd->def, cmd->subtype, lockmode,
+								tab->chgAccessMethod ? tab->newAccessMethod : InvalidOid);
 			break;
 		case AT_EnableTrig:		/* ENABLE TRIGGER name */
 			ATExecEnableDisableTrigger(rel, cmd->name,
@@ -16885,12 +16944,92 @@ ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel, const char *tablespacen
 	tab->newTableSpace = tablespaceId;
 }
 
+/*
+ * Re-validate pg_class.reloptions for every work-queue entry whose access
+ * method is being changed.  Called between phase 2 (catalog updates) and
+ * phase 3 (table rewrites): SET / RESET / REPLACE subcommands have already
+ * been committed to pg_class, and tab->newAccessMethod identifies the AM
+ * the relation will use once the ALTER TABLE finishes.
+ *
+ * The check exists because relcache.c calls the AM's option parser with
+ * validate=false at relation open: any pre-existing reloption that the
+ * new AM does not recognise would otherwise be silently dropped from the
+ * parsed StdRdOptions / AM-specific options struct, leaving the user
+ * unable to tell that the option is no longer in effect.  Failing the
+ * ALTER TABLE here with a clear message lets the user RESET the option
+ * in the same statement and re-run.
+ */
+static void
+ATValidateAccessMethodOptions(List **wqueue)
+{
+	ListCell   *ltab;
+
+	foreach(ltab, *wqueue)
+	{
+		AlteredTableInfo *tab = (AlteredTableInfo *) lfirst(ltab);
+		HeapTuple	amtup;
+		HeapTuple	reltup;
+		Form_pg_am	amform;
+		Form_pg_class relform;
+		amoptions_function amoptions;
+		Datum		reloptions;
+		bool		isnull;
+		Oid			amoid;
+
+		if (!tab->chgAccessMethod)
+			continue;
+
+		/*
+		 * Partitioned tables may reset the AM to "default" (InvalidOid); each
+		 * partition then chooses its own AM at create time, so there is no
+		 * per-relation AM whose parser to consult here.
+		 */
+		amoid = tab->newAccessMethod;
+		if (!OidIsValid(amoid))
+			continue;
+
+		amtup = SearchSysCache1(AMOID, ObjectIdGetDatum(amoid));
+		if (!HeapTupleIsValid(amtup))
+			elog(ERROR, "cache lookup failed for access method %u", amoid);
+		amform = (Form_pg_am) GETSTRUCT(amtup);
+		amoptions = GetTableAmRoutine(amform->amhandler)->amoptions;
+		ReleaseSysCache(amtup);
+
+		/*
+		 * If the new AM has no option parser of its own, table_reloptions
+		 * falls back to the standard heap parser, which accepts whatever the
+		 * old AM accepted (every other AM in core uses the same StdRdOptions
+		 * today), so there is nothing to re-check.
+		 */
+		if (amoptions == NULL)
+			continue;
+
+		reltup = SearchSysCache1(RELOID, ObjectIdGetDatum(tab->relid));
+		if (!HeapTupleIsValid(reltup))
+			elog(ERROR, "cache lookup failed for relation %u", tab->relid);
+		relform = (Form_pg_class) GETSTRUCT(reltup);
+		reloptions = SysCacheGetAttr(RELOID, reltup,
+									 Anum_pg_class_reloptions, &isnull);
+		if (!isnull)
+			(void) table_reloptions(amoptions, relform->relkind,
+									reloptions, true);
+		ReleaseSysCache(reltup);
+	}
+}
+
 /*
  * Set, reset, or replace reloptions.
+ *
+ * newAccessMethodId, if valid, names the table access method whose option
+ * parser should validate the resulting reloptions.  This is used when SET
+ * ACCESS METHOD is queued in the same ALTER TABLE so that the new options
+ * are checked against the AM the relation will use after the statement
+ * finishes, not the AM it has now.  Pass InvalidOid to use the relation's
+ * current access method.
  */
 static void
 ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
-					LOCKMODE lockmode)
+					LOCKMODE lockmode, Oid newAccessMethodId)
 {
 	Oid			relid;
 	Relation	pgclass;
@@ -16942,7 +17081,30 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
 	{
 		case RELKIND_RELATION:
 		case RELKIND_MATVIEW:
-			(void) heap_reloptions(rel->rd_rel->relkind, newOptions, true);
+			{
+				amoptions_function amoptions;
+
+				if (OidIsValid(newAccessMethodId))
+				{
+					HeapTuple	amtup;
+					Form_pg_am	amform;
+
+					amtup = SearchSysCache1(AMOID,
+											ObjectIdGetDatum(newAccessMethodId));
+					if (!HeapTupleIsValid(amtup))
+						elog(ERROR, "cache lookup failed for access method %u",
+							 newAccessMethodId);
+					amform = (Form_pg_am) GETSTRUCT(amtup);
+					amoptions = GetTableAmRoutine(amform->amhandler)->amoptions;
+					ReleaseSysCache(amtup);
+				}
+				else
+					amoptions = (rel->rd_tableam ?
+								 rel->rd_tableam->amoptions : NULL);
+
+				(void) table_reloptions(amoptions, rel->rd_rel->relkind,
+										newOptions, true);
+			}
 			break;
 		case RELKIND_PARTITIONED_TABLE:
 			(void) partitioned_table_reloptions(newOptions, true);
diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c
index 99d0db82ed7..68d1a5369fb 100644
--- a/src/backend/commands/vacuum.c
+++ b/src/backend/commands/vacuum.c
@@ -2185,7 +2185,7 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 	{
 		StdRdOptIndexCleanup vacuum_index_cleanup;
 
-		if (rel->rd_options == NULL)
+		if (!RelationHasStdRdOptions(rel))
 			vacuum_index_cleanup = STDRD_OPTION_VACUUM_INDEX_CLEANUP_AUTO;
 		else
 			vacuum_index_cleanup =
@@ -2216,7 +2216,7 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 	 * Check if the vacuum_max_eager_freeze_failure_rate table storage
 	 * parameter was specified. This overrides the GUC value.
 	 */
-	if (rel->rd_options != NULL &&
+	if (RelationHasStdRdOptions(rel) &&
 		((StdRdOptions *) rel->rd_options)->vacuum_max_eager_freeze_failure_rate >= 0)
 		params.max_eager_freeze_failure_rate =
 			((StdRdOptions *) rel->rd_options)->vacuum_max_eager_freeze_failure_rate;
@@ -2227,7 +2227,8 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 	 */
 	if (params.truncate == VACOPTVALUE_UNSPECIFIED)
 	{
-		StdRdOptions *opts = (StdRdOptions *) rel->rd_options;
+		StdRdOptions *opts = RelationHasStdRdOptions(rel) ?
+			(StdRdOptions *) rel->rd_options : NULL;
 
 		if (opts && opts->vacuum_truncate != PG_TERNARY_UNSET)
 		{
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index e19f0d3e51c..958c13f8338 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -481,9 +481,11 @@ RelationParseRelOptions(Relation relation, HeapTuple tuple)
 	switch (relation->rd_rel->relkind)
 	{
 		case RELKIND_RELATION:
+		case RELKIND_MATVIEW:
+			amoptsfn = relation->rd_tableam ? relation->rd_tableam->amoptions : NULL;
+			break;
 		case RELKIND_TOASTVALUE:
 		case RELKIND_VIEW:
-		case RELKIND_MATVIEW:
 		case RELKIND_PARTITIONED_TABLE:
 			amoptsfn = NULL;
 			break;
diff --git a/src/include/access/reloptions.h b/src/include/access/reloptions.h
index e8cb7f7a627..1282bccc77f 100644
--- a/src/include/access/reloptions.h
+++ b/src/include/access/reloptions.h
@@ -187,6 +187,7 @@ typedef struct local_relopts
 	 (char *)(optstruct) + (optstruct)->member)
 
 extern relopt_kind add_reloption_kind(void);
+extern void add_reloption_to_kind(const char *name, relopt_kind kind);
 extern void add_bool_reloption(uint32 kinds, const char *name, const char *desc,
 							   bool default_val, LOCKMODE lockmode);
 extern void add_ternary_reloption(uint32 kinds, const char *name,
@@ -248,6 +249,8 @@ extern void *build_local_reloptions(local_relopts *relopts, Datum options,
 extern bytea *default_reloptions(Datum reloptions, bool validate,
 								 relopt_kind kind);
 extern bytea *heap_reloptions(char relkind, Datum reloptions, bool validate);
+extern bytea *table_reloptions(amoptions_function amoptions, char relkind,
+							   Datum reloptions, bool validate);
 extern bytea *view_reloptions(Datum reloptions, bool validate);
 extern bytea *partitioned_table_reloptions(Datum reloptions, bool validate);
 extern bytea *index_reloptions(amoptions_function amoptions, Datum reloptions,
diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h
index c13f05d39db..332b70a1e11 100644
--- a/src/include/access/tableam.h
+++ b/src/include/access/tableam.h
@@ -17,6 +17,7 @@
 #ifndef TABLEAM_H
 #define TABLEAM_H
 
+#include "access/amapi.h"
 #include "access/relscan.h"
 #include "access/sdir.h"
 #include "access/xact.h"
@@ -324,6 +325,65 @@ typedef struct TableAmRoutine
 	NodeTag		type;
 
 
+	/* ------------------------------------------------------------------------
+	 * Reloption parsing.
+	 * ------------------------------------------------------------------------
+	 */
+
+	/*
+	 * Parse and validate AM-specific reloptions.  Optional: when NULL, the
+	 * caller falls back to the standard heap reloption parser
+	 * (default_reloptions with RELOPT_KIND_HEAP) and the result is laid out
+	 * as StdRdOptions.
+	 *
+	 * When non-NULL, the AM owns the option set entirely.  It is free to
+	 * accept all standard heap options, only a subset, or to add its own. The
+	 * returned bytea must begin with a VARSIZE header and is stored in
+	 * Relation->rd_options, so the AM dictates the in-memory layout that its
+	 * other callbacks read.  Core code that reads StdRdOptions fields out of
+	 * rd_options (RelationGetFillFactor, RelationIsUsedAsCatalogTable, ...)
+	 * checks RelationHasStdRdOptions() first, so a custom layout will not be
+	 * misinterpreted -- unless the AM sets has_std_options_prefix below to
+	 * declare that its struct is a StdRdOptions superset after all.
+	 *
+	 * The callback validates user-supplied values but must not silently
+	 * rewrite them: a user inspecting pg_class.reloptions must see exactly
+	 * what they passed in.  Out-of-range or unknown options should be
+	 * reported with ereport(ERROR) when validate is true.
+	 *
+	 * Signature matches the index AM's amoptions callback so the same helper
+	 * machinery (add_string_reloption, add_int_reloption, etc.) can be used.
+	 */
+	amoptions_function amoptions;
+
+	/*
+	 * Set to true when amoptions is non-NULL and the bytea it returns
+	 * begins with a full "StdRdOptions std;" as its first member (i.e. the
+	 * AM's own reloptions struct is a superset of StdRdOptions, not just a
+	 * layout that happens to share a prefix).  This tells core code that it
+	 * is safe to read StdRdOptions fields directly out of rd_options
+	 * (RelationGetFillFactor, RelationIsUsedAsCatalogTable, ...) for
+	 * relations of this AM, exactly as it would for plain heap.
+	 *
+	 * An AM that sets this must register every StdRdOptions field those
+	 * macros read (fillfactor, toast_tuple_target, user_catalog_table,
+	 * parallel_workers, vacuum_index_cleanup, vacuum_truncate,
+	 * vacuum_max_eager_freeze_failure_rate, autovacuum_enabled) with
+	 * add_reloption_to_kind(), even if it exposes none of them as options
+	 * the AM cares about itself: build_reloptions() only fills in fields
+	 * that are registered for the AM's relopt_kind, so an embedded
+	 * StdRdOptions field the AM never registers is left zeroed rather than
+	 * at that option's real default (0 is not a valid "unset" sentinel for
+	 * several of these fields, e.g. parallel_workers and
+	 * vacuum_max_eager_freeze_failure_rate both use -1).  Registering the
+	 * field via add_reloption_to_kind lets each one pick up its normal
+	 * catalog default instead.
+	 *
+	 * Ignored when amoptions is NULL.
+	 */
+	bool		has_std_options_prefix;
+
+
 	/* ------------------------------------------------------------------------
 	 * Slot related callbacks.
 	 * ------------------------------------------------------------------------
diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h
index cd1e92f2302..04caa15707a 100644
--- a/src/include/utils/rel.h
+++ b/src/include/utils/rel.h
@@ -361,12 +361,33 @@ typedef struct StdRdOptions
 #define HEAP_MIN_FILLFACTOR			10
 #define HEAP_DEFAULT_FILLFACTOR		100
 
+/*
+ * RelationHasStdRdOptions
+ *		Returns true when the relation's rd_options buffer is safe to read
+ *		as StdRdOptions: either it was produced by the standard heap
+ *		reloption parser (the AM has no amoptions callback), or the AM's
+ *		own amoptions callback returns a struct that embeds a full
+ *		StdRdOptions as its first member and says so via
+ *		TableAmRoutine.has_std_options_prefix.  A table access method that
+ *		supplies amoptions without setting that flag owns its rd_options
+ *		layout entirely and is not required to expose StdRdOptions fields;
+ *		macros that read those fields must check this first to avoid
+ *		reading garbage, or past the end of a smaller custom struct.  For
+ *		indexes and other relkinds rd_options is in an AM-specific layout,
+ *		so this returns false for them.
+ *
+ *		Defined as a function (in reloptions.c) rather than a macro
+ *		because the test needs the full TableAmRoutine struct definition,
+ *		which would create an #include cycle if pulled into rel.h.
+ */
+extern bool RelationHasStdRdOptions(Relation relation);
+
 /*
  * RelationGetToastTupleTarget
  *		Returns the relation's toast_tuple_target.  Note multiple eval of argument!
  */
 #define RelationGetToastTupleTarget(relation, defaulttarg) \
-	((relation)->rd_options ? \
+	(RelationHasStdRdOptions(relation) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->toast_tuple_target : (defaulttarg))
 
 /*
@@ -374,7 +395,7 @@ typedef struct StdRdOptions
  *		Returns the relation's fillfactor.  Note multiple eval of argument!
  */
 #define RelationGetFillFactor(relation, defaultff) \
-	((relation)->rd_options ? \
+	(RelationHasStdRdOptions(relation) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->fillfactor : (defaultff))
 
 /*
@@ -397,7 +418,7 @@ typedef struct StdRdOptions
  *		from the pov of logical decoding.  Note multiple eval of argument!
  */
 #define RelationIsUsedAsCatalogTable(relation)	\
-	((relation)->rd_options && \
+	(RelationHasStdRdOptions(relation) && \
 	 ((relation)->rd_rel->relkind == RELKIND_RELATION || \
 	  (relation)->rd_rel->relkind == RELKIND_MATVIEW) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->user_catalog_table : false)
@@ -408,7 +429,7 @@ typedef struct StdRdOptions
  *		Note multiple eval of argument!
  */
 #define RelationGetParallelWorkers(relation, defaultpw) \
-	((relation)->rd_options ? \
+	(RelationHasStdRdOptions(relation) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->parallel_workers : (defaultpw))
 
 /* ViewOptions->check_option values */
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 0a74ab5c86f..223005fdf98 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -10,6 +10,7 @@ SUBDIRS = \
 		  delay_execution \
 		  dummy_index_am \
 		  dummy_seclabel \
+		  dummy_table_am \
 		  index \
 		  libpq_pipeline \
 		  oauth_validator \
diff --git a/src/test/modules/dummy_table_am/Makefile b/src/test/modules/dummy_table_am/Makefile
new file mode 100644
index 00000000000..94837dff392
--- /dev/null
+++ b/src/test/modules/dummy_table_am/Makefile
@@ -0,0 +1,20 @@
+# src/test/modules/dummy_table_am/Makefile
+
+MODULES = dummy_table_am
+
+EXTENSION = dummy_table_am
+DATA = dummy_table_am--1.0.sql
+PGFILEDESC = "dummy_table_am - table access method template"
+
+REGRESS = reloptions
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/dummy_table_am
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/dummy_table_am/README b/src/test/modules/dummy_table_am/README
new file mode 100644
index 00000000000..a234a1f107f
--- /dev/null
+++ b/src/test/modules/dummy_table_am/README
@@ -0,0 +1,21 @@
+Dummy Table AM
+==============
+
+Dummy table AM is a module for testing the table access method
+amoptions callback and the add_reloption_to_kind() helper.  It
+delegates all storage and scan callbacks to the heap AM and only
+swaps in its own option parser, so a relation created with USING
+dummy_table_am behaves like a heap table but accepts a different
+set of reloptions:
+
+  - "fillfactor"     (inherited from the core heap registration via
+                      add_reloption_to_kind)
+  - "option_int"     (integer)
+  - "option_real"    (real)
+  - "option_bool"    (boolean)
+  - "option_enum"    (enum, one|two)
+
+Standard heap options such as parallel_workers, autovacuum_*, and
+toast_tuple_target are intentionally NOT accepted, to exercise the
+"AM rejects an unknown option" path in ALTER TABLE ... SET ACCESS
+METHOD revalidation.
diff --git a/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql b/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
new file mode 100644
index 00000000000..2e295b95845
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
@@ -0,0 +1,13 @@
+/* src/test/modules/dummy_table_am/dummy_table_am--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION dummy_table_am" to load this file. \quit
+
+CREATE FUNCTION dthandler(internal)
+RETURNS table_am_handler
+AS 'MODULE_PATHNAME'
+LANGUAGE C;
+
+-- Access method
+CREATE ACCESS METHOD dummy_table_am TYPE TABLE HANDLER dthandler;
+COMMENT ON ACCESS METHOD dummy_table_am IS 'dummy table access method';
diff --git a/src/test/modules/dummy_table_am/dummy_table_am.c b/src/test/modules/dummy_table_am/dummy_table_am.c
new file mode 100644
index 00000000000..1fba6057675
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am.c
@@ -0,0 +1,253 @@
+/*-------------------------------------------------------------------------
+ *
+ * dummy_table_am.c
+ *		Table AM template main file.
+ *
+ * This module exists primarily to demonstrate and exercise the table AM
+ * amoptions callback and the add_reloption_to_kind() helper.  Storage
+ * and scan callbacks are delegated to the heap AM, so a relation
+ * created with USING dummy_table_am behaves like a heap table; only the
+ * reloption surface differs.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/test/modules/dummy_table_am/dummy_table_am.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/reloptions.h"
+#include "access/tableam.h"
+#include "catalog/pg_am_d.h"
+#include "fmgr.h"
+#include "utils/rel.h"
+
+PG_MODULE_MAGIC;
+
+/* Parse table for build_reloptions: 8 inherited standard options + 4 of our own */
+static relopt_parse_elt dt_relopt_tab[12];
+
+/* Kind of relation options for dummy table */
+static relopt_kind dt_relopt_kind;
+
+typedef enum DummyTableEnum
+{
+	DUMMY_TABLE_ENUM_ONE,
+	DUMMY_TABLE_ENUM_TWO,
+}			DummyTableEnum;
+
+/*
+ * Dummy table options.
+ *
+ * "std" embeds the full standard heap layout: every option that heap's own
+ * default_reloptions() understands (fillfactor, toast_tuple_target,
+ * parallel_workers, vacuum_index_cleanup, vacuum_truncate,
+ * vacuum_max_eager_freeze_failure_rate, autovacuum_enabled,
+ * user_catalog_table) is inherited by calling add_reloption_to_kind() on
+ * the matching name, and each is registered here so that build_reloptions()
+ * fills it with the option's own catalog default when the user does not
+ * set it explicitly -- an embedded StdRdOptions field that is never
+ * registered stays zeroed, which is not the right "unset" value for
+ * several of these (e.g. parallel_workers and
+ * vacuum_max_eager_freeze_failure_rate both use -1).  Because "std" is a
+ * full StdRdOptions and every one of its fields is populated this way, we
+ * can tell core code it's safe to keep reading them directly by setting
+ * TableAmRoutine.has_std_options_prefix (see dthandler() below).
+ *
+ * The remaining four are AM-specific options that only dummy_table_am
+ * knows about.
+ */
+typedef struct DummyTableOptions
+{
+	StdRdOptions std;			/* must be first, see above */
+	int			option_int;
+	double		option_real;
+	bool		option_bool;
+	DummyTableEnum option_enum;
+}			DummyTableOptions;
+
+static relopt_enum_elt_def dummyTableEnumValues[] =
+{
+	{"one", DUMMY_TABLE_ENUM_ONE},
+	{"two", DUMMY_TABLE_ENUM_TWO},
+	{(const char *) NULL}		/* list terminator */
+};
+
+PG_FUNCTION_INFO_V1(dthandler);
+
+/*
+ * Register a relopt_kind for this AM and populate the parse table.
+ */
+static void
+create_reloptions_table(void)
+{
+	int			i = 0;
+
+	dt_relopt_kind = add_reloption_kind();
+
+	/*
+	 * Accept every standard option that core's default_reloptions()
+	 * understands (registered for RELOPT_KIND_HEAP and/or RELOPT_KIND_TOAST)
+	 * under our own kind.  This is the canonical use of
+	 * add_reloption_to_kind(): an AM that wants to honour existing
+	 * core-registered options without duplicating their definitions.  See
+	 * the comment on DummyTableOptions.std for why we register all of
+	 * them rather than just fillfactor.
+	 */
+	add_reloption_to_kind("fillfactor", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "fillfactor";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.fillfactor);
+	i++;
+
+	add_reloption_to_kind("toast_tuple_target", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "toast_tuple_target";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.toast_tuple_target);
+	i++;
+
+	add_reloption_to_kind("parallel_workers", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "parallel_workers";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.parallel_workers);
+	i++;
+
+	add_reloption_to_kind("vacuum_index_cleanup", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "vacuum_index_cleanup";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_ENUM;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.vacuum_index_cleanup);
+	i++;
+
+	add_reloption_to_kind("vacuum_truncate", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "vacuum_truncate";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_TERNARY;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.vacuum_truncate);
+	i++;
+
+	add_reloption_to_kind("vacuum_max_eager_freeze_failure_rate", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "vacuum_max_eager_freeze_failure_rate";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_REAL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.vacuum_max_eager_freeze_failure_rate);
+	i++;
+
+	add_reloption_to_kind("autovacuum_enabled", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "autovacuum_enabled";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_BOOL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.autovacuum.enabled);
+	i++;
+
+	add_reloption_to_kind("user_catalog_table", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "user_catalog_table";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_BOOL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.user_catalog_table);
+	i++;
+
+	add_int_reloption(dt_relopt_kind, "option_int",
+					  "Integer option for dummy_table_am",
+					  10, -10, 100, AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_int";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_int);
+	i++;
+
+	add_real_reloption(dt_relopt_kind, "option_real",
+					   "Real option for dummy_table_am",
+					   3.1415, -10, 100, AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_real";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_REAL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_real);
+	i++;
+
+	add_bool_reloption(dt_relopt_kind, "option_bool",
+					   "Boolean option for dummy_table_am",
+					   true, AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_bool";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_BOOL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_bool);
+	i++;
+
+	add_enum_reloption(dt_relopt_kind, "option_enum",
+					   "Enum option for dummy_table_am",
+					   dummyTableEnumValues,
+					   DUMMY_TABLE_ENUM_ONE,
+					   "Valid values are \"one\" and \"two\".",
+					   AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_enum";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_ENUM;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_enum);
+	i++;
+}
+
+/*
+ * Parse reloptions for dummy_table_am.
+ *
+ * Returning DummyTableOptions tells the caller (relcache.c) to store
+ * exactly that layout in Relation->rd_options.
+ */
+static bytea *
+dtoptions(Datum reloptions, bool validate)
+{
+	return (bytea *) build_reloptions(reloptions, validate,
+									  dt_relopt_kind,
+									  sizeof(DummyTableOptions),
+									  dt_relopt_tab, lengthof(dt_relopt_tab));
+}
+
+/*
+ * heapam_relation_toast_am() (heap's own relation_toast_am callback, which
+ * we would otherwise inherit unchanged along with the rest of heap's
+ * routine) returns rel->rd_rel->relam -- correct for a real heap table, but
+ * for dummy_table_am that's dummy_table_am's own oid, not heap's.  That
+ * would make this AM's TOAST tables dummy_table_am relations too, and
+ * building their chunk_id/chunk_seq index fails as soon as it's scanned,
+ * since that scan goes through heap_getnext() directly.  Override it to
+ * return the literal heap AM oid: this AM's TOAST tables are always plain
+ * heap, regardless of what created the owning table.
+ */
+static Oid
+dummy_table_relation_toast_am(Relation rel)
+{
+	return HEAP_TABLE_AM_OID;
+}
+
+/*
+ * Handler for table AM.
+ *
+ * All storage-side callbacks are inherited from heap; we swap in our own
+ * amoptions so that the AM owns its reloption set, and our own
+ * relation_toast_am (see dummy_table_relation_toast_am() above).  This
+ * keeps the example focused on the new API without duplicating the heap
+ * AM.
+ *
+ * has_std_options_prefix is set because DummyTableOptions embeds a full
+ * StdRdOptions as its first member with every field populated (see the
+ * comment on DummyTableOptions): that makes it safe for core code to keep
+ * reading fillfactor and friends directly out of rd_options, exactly as
+ * it would for a plain heap table.
+ */
+Datum
+dthandler(PG_FUNCTION_ARGS)
+{
+	static TableAmRoutine routine;
+	static bool initialized = false;
+
+	if (!initialized)
+	{
+		memcpy(&routine, GetHeapamTableAmRoutine(), sizeof(routine));
+		routine.amoptions = dtoptions;
+		routine.has_std_options_prefix = true;
+		routine.relation_toast_am = dummy_table_relation_toast_am;
+		initialized = true;
+	}
+
+	PG_RETURN_POINTER(&routine);
+}
+
+void
+_PG_init(void)
+{
+	create_reloptions_table();
+}
diff --git a/src/test/modules/dummy_table_am/dummy_table_am.control b/src/test/modules/dummy_table_am/dummy_table_am.control
new file mode 100644
index 00000000000..08f2f868d49
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am.control
@@ -0,0 +1,5 @@
+# dummy_table_am extension
+comment = 'dummy_table_am - table access method template'
+default_version = '1.0'
+module_pathname = '$libdir/dummy_table_am'
+relocatable = true
diff --git a/src/test/modules/dummy_table_am/expected/reloptions.out b/src/test/modules/dummy_table_am/expected/reloptions.out
new file mode 100644
index 00000000000..5d38767c528
--- /dev/null
+++ b/src/test/modules/dummy_table_am/expected/reloptions.out
@@ -0,0 +1,184 @@
+-- Tests for the table AM amoptions callback and add_reloption_to_kind()
+CREATE EXTENSION dummy_table_am;
+-- Sanity: CREATE TABLE with AM-specific options succeeds and round-trips
+CREATE TABLE dummy_t (a int) USING dummy_table_am
+    WITH (option_int = 17, option_real = 2.5, option_bool = false,
+          option_enum = 'two', fillfactor = 60);
+SELECT reloptions FROM pg_class
+    WHERE oid = 'dummy_t'::regclass ORDER BY reloptions;
+                                   reloptions                                    
+---------------------------------------------------------------------------------
+ {option_int=17,option_real=2.5,option_bool=false,option_enum=two,fillfactor=60}
+(1 row)
+
+-- AM-specific option ranges are enforced (option_int allows -10..100)
+CREATE TABLE dummy_oor (a int) USING dummy_table_am WITH (option_int = 9999);
+ERROR:  value 9999 out of bounds for option "option_int"
+DETAIL:  Valid values are between "-10" and "100".
+-- Unknown options are rejected at CREATE TABLE time
+CREATE TABLE dummy_bad (a int) USING dummy_table_am WITH (autovacuum_vacuum_threshold = 4);
+ERROR:  unrecognized parameter "autovacuum_vacuum_threshold"
+-- Default values land in pg_class only when the user did not set them
+CREATE TABLE dummy_defaults (a int) USING dummy_table_am;
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_defaults'::regclass;
+ reloptions 
+------------
+ 
+(1 row)
+
+DROP TABLE dummy_defaults;
+-- ALTER TABLE ... SET (...) with AM-specific option
+ALTER TABLE dummy_t SET (option_int = 42);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+                                   reloptions                                    
+---------------------------------------------------------------------------------
+ {option_real=2.5,option_bool=false,option_enum=two,fillfactor=60,option_int=42}
+(1 row)
+
+-- ALTER TABLE ... SET (...) with an unknown option errors
+ALTER TABLE dummy_t SET (autovacuum_vacuum_threshold = 4);
+ERROR:  unrecognized parameter "autovacuum_vacuum_threshold"
+-- ALTER TABLE ... RESET (option) round-trips
+ALTER TABLE dummy_t RESET (option_int);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+                            reloptions                             
+-------------------------------------------------------------------
+ {option_real=2.5,option_bool=false,option_enum=two,fillfactor=60}
+(1 row)
+
+-- SET ACCESS METHOD revalidation:
+--   moving a heap table that has standard heap options not accepted by the
+--   new AM (autovacuum_vacuum_threshold; dummy_table_am inherits
+--   autovacuum_enabled but not the rest of the autovacuum_* family) into
+--   dummy_table_am must fail with a clear message and must NOT silently
+--   drop the option.
+CREATE TABLE heap_t (a int) WITH (fillfactor = 70, autovacuum_vacuum_threshold = 4);
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+                  reloptions                   
+-----------------------------------------------
+ {fillfactor=70,autovacuum_vacuum_threshold=4}
+(1 row)
+
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am;
+ERROR:  unrecognized parameter "autovacuum_vacuum_threshold"
+-- Confirm nothing changed
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+ amname 
+--------
+ heap
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+                  reloptions                   
+-----------------------------------------------
+ {fillfactor=70,autovacuum_vacuum_threshold=4}
+(1 row)
+
+-- After RESETing the offending option in the same statement the swap
+-- succeeds; fillfactor survives because dummy_table_am inherits it via
+-- add_reloption_to_kind().
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am, RESET (autovacuum_vacuum_threshold);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+     amname     
+----------------
+ dummy_table_am
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+   reloptions    
+-----------------
+ {fillfactor=70}
+(1 row)
+
+-- Going back to heap still works: heap accepts fillfactor.
+ALTER TABLE heap_t SET ACCESS METHOD heap;
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+ amname 
+--------
+ heap
+(1 row)
+
+-- SET ACCESS METHOD + SET (...) of an option that only the new AM accepts.
+CREATE TABLE heap_to_dt (a int);
+ALTER TABLE heap_to_dt SET ACCESS METHOD dummy_table_am, SET (option_int = 25);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+     amname     
+----------------
+ dummy_table_am
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+   reloptions    
+-----------------
+ {option_int=25}
+(1 row)
+
+-- fillfactor genuinely reaches heap's own page-packing logic now, not just
+-- pg_class.reloptions: dummy_table_am embeds a full StdRdOptions as the
+-- first member of its own options struct and sets
+-- TableAmRoutine.has_std_options_prefix, so RelationGetFillFactor() can read
+-- it directly instead of always seeing the hardcoded default.
+CREATE TABLE dummy_ff10 (a int) USING dummy_table_am WITH (fillfactor = 10);
+CREATE TABLE dummy_ff100 (a int) USING dummy_table_am WITH (fillfactor = 100);
+INSERT INTO dummy_ff10 SELECT generate_series(1, 5000);
+INSERT INTO dummy_ff100 SELECT generate_series(1, 5000);
+VACUUM dummy_ff10;
+VACUUM dummy_ff100;
+SELECT (SELECT relpages FROM pg_class WHERE oid = 'dummy_ff10'::regclass) >
+       (SELECT relpages FROM pg_class WHERE oid = 'dummy_ff100'::regclass)
+       AS low_fillfactor_uses_more_pages;
+ low_fillfactor_uses_more_pages 
+--------------------------------
+ t
+(1 row)
+
+DROP TABLE dummy_ff10;
+DROP TABLE dummy_ff100;
+-- A table with a toastable column works: dummy_table_am overrides
+-- relation_toast_am rather than inheriting heap's, which would return
+-- this AM's own oid instead of heap's for its TOAST table, making that
+-- TOAST table itself a dummy_table_am relation and failing as soon as
+-- its chunk_id/chunk_seq index was built (that scan goes through
+-- heap_getnext() directly, which requires a real heap relation).
+CREATE TABLE dummy_txt (a int, b text) USING dummy_table_am;
+INSERT INTO dummy_txt VALUES (1, repeat('x', 10000));
+SELECT a, length(b) FROM dummy_txt;
+ a | length 
+---+--------
+ 1 |  10000
+(1 row)
+
+DROP TABLE dummy_txt;
+-- Partitioned-table inheritance: AM declared on the parent partition flows
+-- to partitions that don't override it.  Partitioned tables themselves
+-- cannot carry reloptions; the test verifies the AM lookup that
+-- DefineRelation does for partitions.
+CREATE TABLE parted (a int) PARTITION BY RANGE (a) USING dummy_table_am;
+CREATE TABLE parted_p1 PARTITION OF parted FOR VALUES FROM (0) TO (100)
+    WITH (option_int = 11);
+SELECT c.relname,
+       (SELECT amname FROM pg_am WHERE oid = c.relam) AS amname,
+       c.reloptions
+    FROM pg_class c
+    WHERE c.oid IN ('parted'::regclass, 'parted_p1'::regclass)
+    ORDER BY c.relname;
+  relname  |     amname     |   reloptions    
+-----------+----------------+-----------------
+ parted    | dummy_table_am | 
+ parted_p1 | dummy_table_am | {option_int=11}
+(2 rows)
+
+-- A partition that explicitly chooses heap must reject options that are
+-- only known to the parent's AM.
+CREATE TABLE parted_p2 PARTITION OF parted FOR VALUES FROM (100) TO (200)
+    USING heap WITH (option_int = 9);
+ERROR:  unrecognized parameter "option_int"
+DROP TABLE parted;
+DROP TABLE heap_to_dt;
+DROP TABLE heap_t;
+DROP TABLE dummy_t;
+DROP EXTENSION dummy_table_am;
diff --git a/src/test/modules/dummy_table_am/meson.build b/src/test/modules/dummy_table_am/meson.build
new file mode 100644
index 00000000000..ad3fa2410cc
--- /dev/null
+++ b/src/test/modules/dummy_table_am/meson.build
@@ -0,0 +1,33 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+dummy_table_am_sources = files(
+  'dummy_table_am.c',
+)
+
+if host_system == 'windows'
+  dummy_table_am_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'dummy_table_am',
+    '--FILEDESC', 'dummy_table_am - table access method template',])
+endif
+
+dummy_table_am = shared_module('dummy_table_am',
+  dummy_table_am_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += dummy_table_am
+
+test_install_data += files(
+  'dummy_table_am.control',
+  'dummy_table_am--1.0.sql',
+)
+
+tests += {
+  'name': 'dummy_table_am',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'reloptions',
+    ],
+  },
+}
diff --git a/src/test/modules/dummy_table_am/sql/reloptions.sql b/src/test/modules/dummy_table_am/sql/reloptions.sql
new file mode 100644
index 00000000000..97bc8479527
--- /dev/null
+++ b/src/test/modules/dummy_table_am/sql/reloptions.sql
@@ -0,0 +1,119 @@
+-- Tests for the table AM amoptions callback and add_reloption_to_kind()
+CREATE EXTENSION dummy_table_am;
+
+-- Sanity: CREATE TABLE with AM-specific options succeeds and round-trips
+CREATE TABLE dummy_t (a int) USING dummy_table_am
+    WITH (option_int = 17, option_real = 2.5, option_bool = false,
+          option_enum = 'two', fillfactor = 60);
+SELECT reloptions FROM pg_class
+    WHERE oid = 'dummy_t'::regclass ORDER BY reloptions;
+
+-- AM-specific option ranges are enforced (option_int allows -10..100)
+CREATE TABLE dummy_oor (a int) USING dummy_table_am WITH (option_int = 9999);
+
+-- Unknown options are rejected at CREATE TABLE time
+CREATE TABLE dummy_bad (a int) USING dummy_table_am WITH (autovacuum_vacuum_threshold = 4);
+
+-- Default values land in pg_class only when the user did not set them
+CREATE TABLE dummy_defaults (a int) USING dummy_table_am;
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_defaults'::regclass;
+DROP TABLE dummy_defaults;
+
+-- ALTER TABLE ... SET (...) with AM-specific option
+ALTER TABLE dummy_t SET (option_int = 42);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+
+-- ALTER TABLE ... SET (...) with an unknown option errors
+ALTER TABLE dummy_t SET (autovacuum_vacuum_threshold = 4);
+
+-- ALTER TABLE ... RESET (option) round-trips
+ALTER TABLE dummy_t RESET (option_int);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+
+-- SET ACCESS METHOD revalidation:
+--   moving a heap table that has standard heap options not accepted by the
+--   new AM (autovacuum_vacuum_threshold; dummy_table_am inherits
+--   autovacuum_enabled but not the rest of the autovacuum_* family) into
+--   dummy_table_am must fail with a clear message and must NOT silently
+--   drop the option.
+CREATE TABLE heap_t (a int) WITH (fillfactor = 70, autovacuum_vacuum_threshold = 4);
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am;
+-- Confirm nothing changed
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+
+-- After RESETing the offending option in the same statement the swap
+-- succeeds; fillfactor survives because dummy_table_am inherits it via
+-- add_reloption_to_kind().
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am, RESET (autovacuum_vacuum_threshold);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+
+-- Going back to heap still works: heap accepts fillfactor.
+ALTER TABLE heap_t SET ACCESS METHOD heap;
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+
+-- SET ACCESS METHOD + SET (...) of an option that only the new AM accepts.
+CREATE TABLE heap_to_dt (a int);
+ALTER TABLE heap_to_dt SET ACCESS METHOD dummy_table_am, SET (option_int = 25);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+
+-- fillfactor genuinely reaches heap's own page-packing logic now, not just
+-- pg_class.reloptions: dummy_table_am embeds a full StdRdOptions as the
+-- first member of its own options struct and sets
+-- TableAmRoutine.has_std_options_prefix, so RelationGetFillFactor() can read
+-- it directly instead of always seeing the hardcoded default.
+CREATE TABLE dummy_ff10 (a int) USING dummy_table_am WITH (fillfactor = 10);
+CREATE TABLE dummy_ff100 (a int) USING dummy_table_am WITH (fillfactor = 100);
+INSERT INTO dummy_ff10 SELECT generate_series(1, 5000);
+INSERT INTO dummy_ff100 SELECT generate_series(1, 5000);
+VACUUM dummy_ff10;
+VACUUM dummy_ff100;
+SELECT (SELECT relpages FROM pg_class WHERE oid = 'dummy_ff10'::regclass) >
+       (SELECT relpages FROM pg_class WHERE oid = 'dummy_ff100'::regclass)
+       AS low_fillfactor_uses_more_pages;
+DROP TABLE dummy_ff10;
+DROP TABLE dummy_ff100;
+
+-- A table with a toastable column works: dummy_table_am overrides
+-- relation_toast_am rather than inheriting heap's, which would return
+-- this AM's own oid instead of heap's for its TOAST table, making that
+-- TOAST table itself a dummy_table_am relation and failing as soon as
+-- its chunk_id/chunk_seq index was built (that scan goes through
+-- heap_getnext() directly, which requires a real heap relation).
+CREATE TABLE dummy_txt (a int, b text) USING dummy_table_am;
+INSERT INTO dummy_txt VALUES (1, repeat('x', 10000));
+SELECT a, length(b) FROM dummy_txt;
+DROP TABLE dummy_txt;
+
+-- Partitioned-table inheritance: AM declared on the parent partition flows
+-- to partitions that don't override it.  Partitioned tables themselves
+-- cannot carry reloptions; the test verifies the AM lookup that
+-- DefineRelation does for partitions.
+CREATE TABLE parted (a int) PARTITION BY RANGE (a) USING dummy_table_am;
+CREATE TABLE parted_p1 PARTITION OF parted FOR VALUES FROM (0) TO (100)
+    WITH (option_int = 11);
+SELECT c.relname,
+       (SELECT amname FROM pg_am WHERE oid = c.relam) AS amname,
+       c.reloptions
+    FROM pg_class c
+    WHERE c.oid IN ('parted'::regclass, 'parted_p1'::regclass)
+    ORDER BY c.relname;
+
+-- A partition that explicitly chooses heap must reject options that are
+-- only known to the parent's AM.
+CREATE TABLE parted_p2 PARTITION OF parted FOR VALUES FROM (100) TO (200)
+    USING heap WITH (option_int = 9);
+
+DROP TABLE parted;
+DROP TABLE heap_to_dt;
+DROP TABLE heap_t;
+DROP TABLE dummy_t;
+
+DROP EXTENSION dummy_table_am;
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 4bca42bb370..07b6b24a5ab 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -5,6 +5,7 @@ subdir('commit_ts')
 subdir('delay_execution')
 subdir('dummy_index_am')
 subdir('dummy_seclabel')
+subdir('dummy_table_am')
 subdir('gin')
 subdir('index')
 subdir('injection_points')
-- 
2.43.0



^ permalink  raw  reply  [nested|flat] 25+ messages in thread

* Re: Allow table AMs to define their own reloptions
@ 2026-08-26 15:53  Andrew Dunstan <andrew@dunslane.net>
  parent: Junwang Zhao <zhjwpku@gmail.com>
  0 siblings, 1 reply; 25+ messages in thread

From: Andrew Dunstan @ 2026-08-26 15:53 UTC (permalink / raw)
  To: Junwang Zhao <zhjwpku@gmail.com>; Zsolt Parragi <zsolt.parragi@percona.com>; +Cc: pgsql-hackers@lists.postgresql.org, Rafia Sabih <rafia.pghackers@gmail.com>; Julien Tachoires <julien@tachoires.me>

Hi,

Both of Zsolt's reports are real bugs, fixed in v5. Replying to both
threads together.

 > [Zsolt] dummy_table_am seems to accept but ignore fillfactor
 > options [...]
 > heap_ff10 | 9091 | 71 MB
 > dummy_ff10 | 885 | 7080 kB

Confirmed, and not just fillfactor. Four macros/call sites read
StdRdOptions fields straight out of rd_options behind a check,
RelationHasStdRdOptions(), that only asked "does this AM have a
custom amoptions at all" rather than "is this field actually there"
-- blocking exactly the case add_reloption_to_kind() exists for.
Same problem for toast_tuple_target, parallel_workers,
vacuum_index_cleanup, vacuum_truncate,
vacuum_max_eager_freeze_failure_rate, and autovacuum_enabled.

Fixed with TableAmRoutine.has_std_options_prefix: an AM sets it when
its amoptions struct embeds a full StdRdOptions as its first member,
and RelationHasStdRdOptions() trusts that. dummy_table_am now embeds
StdRdOptions and registers all seven other fields too, not just
fillfactor, so each gets its real default instead of zero. Re-ran
your repro:

     dummy_ff10 | 9091 | 71 MB
     heap_ff10  | 9091 | 71 MB

Matches now. Added a regression case that fails on v4, passes on v5.

 > [Zsolt] doesn't seem to support text columns [...]
 > ERROR: only heap AM is supported
 > [Junwang] I think it's ok, it's just a test module

Also a real bug, not a limitation: heap's own relation_toast_am
callback, heapam_relation_toast_am(), returns rel->rd_rel->relam,
which is dummy_table_am's own oid rather than heap's since it
inherited that callback unchanged along with the rest of heap's
routine. That made its TOAST table a dummy_table_am relation too,
and building its index failed in heap_getnext(), which requires a
real heap relation. Fixed by having dummy_table_am override
relation_toast_am directly. Text columns work now.

 > [Junwang] extract_autovac_opts... unconditionally interprets the
 > result as StdRdOptions, is that intentional?

Yes, that call passes amoptions = NULL
explicitly, which forces heap_reloptions() regardless of the
relation's actual access method, so the result really is
StdRdOptions.

I think the behaviour is right, though: it means autovacuum's own
scheduling only ever sees the standard autovacuum_* names, never an
AM's own amoptions. Making it AM-aware would cost a catalog lookup
on every relation in autovacuum's periodic scan, for a capability
nothing needs. Documented that constraint in tableam.sgml rather
than leave it to be rediscovered.

cheers

andrew

--
Andrew Dunstan
EDB: https://www.enterprisedb.com

Attachments:

  [text/x-patch] v5-0001-Add-amoptions-callback-to-table-access-methods.patch (60.5K, ../../df0ed837-4658-44bc-9d81-c00432a82625@dunslane.net/2-v5-0001-Add-amoptions-callback-to-table-access-methods.patch)
  download | inline diff:
From eb7fa4ae07f7bcb67b22266580392e1585a1d013 Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <andrew@dunslane.net>
Date: Tue, 12 May 2026 09:03:33 -0400
Subject: [PATCH v5] Add amoptions callback to table access methods

Table access methods have no way to define their own storage
parameters: CREATE/ALTER TABLE always parse the WITH clause with the
standard heap parser, regardless of the table's actual AM. Index AMs
have had this ability via IndexAmRoutine.amoptions for a long time;
give table AMs the same option.

TableAmRoutine gets an optional amoptions field, same signature as
the index AM version. table_reloptions() dispatches to it when set,
else falls back to heap_reloptions() as today. An AM that supplies
amoptions owns the option set entirely, and the bytea it returns,
stored verbatim in Relation->rd_options, dictates the layout its
other callbacks read.

DefineRelation, ATExecSetRelOptions, RelationParseRelOptions and
extractRelOptions all route through table_reloptions() for
RELKIND_RELATION/RELKIND_MATVIEW. The AM is resolved from the USING
clause, the partition parent, or default_table_access_method at
CREATE TABLE, and from rel->rd_tableam at ALTER TABLE SET -- except
a SET ACCESS METHOD queued in the same statement uses the new AM's
parser instead, so "SET ACCESS METHOD x, SET (foo = bar)" works when
foo belongs to x. SET ACCESS METHOD also revalidates the relation's
final reloptions against the new AM once other subcommands in the
statement have run, turning a reloption the new AM doesn't accept
into an immediate error instead of a silent drop at the next
relcache load; RESET in the same statement clears it.

Core code that reads StdRdOptions fields straight out of rd_options
(RelationGetFillFactor and friends, plus direct casts in
vacuum.c/index.c) now checks RelationHasStdRdOptions() first, since
an AM-owned bytea isn't guaranteed to be StdRdOptions-shaped. An AM
can opt back into all of these at once via
TableAmRoutine.has_std_options_prefix, set when its own struct embeds
a full StdRdOptions as its first member -- and it must register every
field these call sites read, not just the ones it cares about, since
an embedded field it never registers is left zeroed rather than at
its real default (wrong for e.g. parallel_workers, default -1).

add_reloption_to_kind(name, kind) extends an existing reloption's
kind bitmask, so an AM can accept a core-registered option
(fillfactor, autovacuum_*, ...) without redeclaring it; this is how
an AM fills in the embedded StdRdOptions fields above. One exception:
autovacuum's own scheduling always parses via the standard heap
parser regardless of AM, so it only ever sees the standard
autovacuum_* names, never an AM's own amoptions -- consulting each
relation's AM during autovacuum's periodic scan isn't worth a
catalog lookup per relation for a capability nothing needs.

src/test/modules/dummy_table_am demonstrates the API: it copies
heap's TableAmRoutine wholesale and overrides two callbacks.
amoptions parses a mix of inherited standard options and its own
into a struct embedding StdRdOptions. relation_toast_am also needs
overriding, since heap's default returns the calling relation's own
AM oid -- correct for heap, wrong for an AM that just inherits it,
whose TOAST tables would then belong to that AM instead of heap.
---
 doc/src/sgml/ref/alter_table.sgml             |  19 ++
 doc/src/sgml/tableam.sgml                     |  92 +++++++
 src/backend/access/common/reloptions.c        |  88 +++++-
 src/backend/catalog/index.c                   |   3 +-
 src/backend/commands/tablecmds.c              | 170 +++++++++++-
 src/backend/commands/vacuum.c                 |   7 +-
 src/backend/utils/cache/relcache.c            |   4 +-
 src/include/access/reloptions.h               |   3 +
 src/include/access/tableam.h                  |  60 +++++
 src/include/utils/rel.h                       |  29 +-
 src/test/modules/Makefile                     |   1 +
 src/test/modules/dummy_table_am/Makefile      |  20 ++
 src/test/modules/dummy_table_am/README        |  21 ++
 .../dummy_table_am/dummy_table_am--1.0.sql    |  13 +
 .../modules/dummy_table_am/dummy_table_am.c   | 253 ++++++++++++++++++
 .../dummy_table_am/dummy_table_am.control     |   5 +
 .../dummy_table_am/expected/reloptions.out    | 184 +++++++++++++
 src/test/modules/dummy_table_am/meson.build   |  33 +++
 .../modules/dummy_table_am/sql/reloptions.sql | 119 ++++++++
 src/test/modules/meson.build                  |   1 +
 20 files changed, 1111 insertions(+), 14 deletions(-)
 create mode 100644 src/test/modules/dummy_table_am/Makefile
 create mode 100644 src/test/modules/dummy_table_am/README
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am.c
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am.control
 create mode 100644 src/test/modules/dummy_table_am/expected/reloptions.out
 create mode 100644 src/test/modules/dummy_table_am/meson.build
 create mode 100644 src/test/modules/dummy_table_am/sql/reloptions.sql

diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index 453395c5c73..3d123dc17c8 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -803,6 +803,25 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
       causing future partitions to default to
       <varname>default_table_access_method</varname>.
      </para>
+     <para>
+      The new access method must accept every storage parameter
+      currently set on the table.  An access method may define its own
+      set of parameters, so a parameter that was legal under the old
+      access method is not necessarily recognized by the new one; if any
+      such parameter remains, <command>ALTER TABLE</command> raises an
+      error rather than silently dropping the value.  The unwanted
+      parameters can be cleared in the same statement, for example:
+<programlisting>
+ALTER TABLE measurement
+    SET ACCESS METHOD columnar,
+    RESET (fillfactor);
+</programlisting>
+      Validation is performed once, after all storage-parameter
+      sub-commands in the statement have been applied, so the order of
+      <literal>SET</literal>, <literal>RESET</literal>, and
+      <literal>SET ACCESS METHOD</literal> within the same
+      <command>ALTER TABLE</command> does not matter.
+     </para>
     </listitem>
    </varlistentry>
 
diff --git a/doc/src/sgml/tableam.sgml b/doc/src/sgml/tableam.sgml
index 9ccf5b739ed..68d8fd62f44 100644
--- a/doc/src/sgml/tableam.sgml
+++ b/doc/src/sgml/tableam.sgml
@@ -152,4 +152,96 @@ my_tableam_handler(PG_FUNCTION_ARGS)
   its implementation.
  </para>
 
+ <sect1 id="tableam-reloptions">
+  <title>Table Access Method Storage Parameters</title>
+
+  <para>
+   A table access method may define its own set of storage parameters
+   (reloptions) by supplying an <structfield>amoptions</structfield>
+   callback in its <structname>TableAmRoutine</structname>.  The callback
+   has the same signature as the corresponding index AM callback; it is
+   invoked at <command>CREATE TABLE</command> and
+   <command>ALTER TABLE</command> time to parse and validate the option
+   set, and at relation open time (with <literal>validate = false</literal>)
+   to build the in-memory representation stored in
+   <structfield>Relation-&gt;rd_options</structfield>.  An AM that does not
+   supply an <structfield>amoptions</structfield> callback inherits the
+   standard heap parser and the <structname>StdRdOptions</structname>
+   layout.
+  </para>
+
+  <para>
+   When the AM provides its own parser it owns the option set entirely:
+   it may accept all standard heap options, only a subset, or define
+   parameters of its own.  The bytea returned from the callback is
+   stored verbatim in <structfield>rd_options</structfield>, so the AM
+   also dictates the in-memory layout that its other callbacks read.
+  </para>
+
+  <para>
+   The parser is expected to validate user-supplied values, but
+   <emphasis>must not silently rewrite them</emphasis>.  In particular
+   it must not coerce out-of-range values to a default, drop unknown
+   options when <literal>validate = true</literal>, or substitute a
+   different unit; the user must be able to verify with
+   <command>SELECT reloptions FROM pg_class</command> that the values
+   they supplied are what the relation will use.  Out-of-range or
+   unknown options should be reported with
+   <function>ereport(ERROR)</function>.
+  </para>
+
+  <para>
+   To honour an option that the core code already registers for
+   <literal>RELOPT_KIND_HEAP</literal> (for example
+   <literal>fillfactor</literal> or the <literal>autovacuum_*</literal>
+   family), call <function>add_reloption_to_kind()</function> once per
+   option in the module's <function>_PG_init</function>.  This extends
+   the existing registration with the AM's own kind without forcing
+   the AM to re-declare each option.
+  </para>
+
+  <para>
+   The <literal>autovacuum_*</literal> family is a special case, and
+   reusing the standard names is the only way to reach autovacuum's own
+   scheduling logic with them at all: every field of
+   <structname>AutoVacOpts</structname> (whether autovacuum runs on the
+   table, its vacuum/analyze thresholds and scale factors, freeze ages,
+   cost delay and limit, log-duration settings, and so on) is extracted
+   by autovacuum's periodic scan of <structname>pg_class</structname>
+   using the standard heap parser unconditionally, never the relation's
+   own <structfield>amoptions</structfield> callback -- looking up and
+   calling the AM's own parser for every relation on every autovacuum
+   cycle would add a catalog lookup to a hot path for no AM that
+   currently needs it.  This is independent of
+   <structfield>has_std_options_prefix</structfield>: it applies even to an
+   AM whose reloptions struct does not embed
+   <structname>StdRdOptions</structname> at all.  A table AM can only
+   affect autovacuum's own scheduling by exposing these standard
+   <literal>autovacuum_*</literal> names via
+   <function>add_reloption_to_kind()</function>; a differently-named
+   option of its own is stored and readable from
+   <structfield>Relation-&gt;rd_options</structfield> like any other
+   AM-specific option, but autovacuum's scheduling logic will never see
+   it.
+  </para>
+
+  <para>
+   <command>ALTER TABLE ... SET ACCESS METHOD</command> revalidates the
+   relation's current storage parameters against the new access
+   method's parser after all <literal>SET</literal>,
+   <literal>RESET</literal>, and <literal>REPLACE</literal>
+   sub-commands in the same statement have been applied.  A parameter
+   that is not accepted by the new AM raises an error; the user can
+   clear such parameters in the same statement (see <xref
+   linkend="sql-altertable"/>).
+  </para>
+
+  <para>
+   See <filename>src/test/modules/dummy_table_am</filename> for a
+   minimal example that exercises both
+   <structfield>amoptions</structfield> and
+   <function>add_reloption_to_kind()</function>.
+  </para>
+ </sect1>
+
 </chapter>
diff --git a/src/backend/access/common/reloptions.c b/src/backend/access/common/reloptions.c
index 3e832c3797e..ccf58f53110 100644
--- a/src/backend/access/common/reloptions.c
+++ b/src/backend/access/common/reloptions.c
@@ -24,6 +24,7 @@
 #include "access/nbtree.h"
 #include "access/reloptions.h"
 #include "access/spgist_private.h"
+#include "access/tableam.h"
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "commands/tablespace.h"
@@ -749,6 +750,44 @@ add_reloption_kind(void)
 	return (relopt_kind) last_assigned_kind;
 }
 
+/*
+ * add_reloption_to_kind
+ *		Extend an already-registered reloption so it is also accepted for
+ *		the given kind.
+ *
+ * Useful for table access methods that want their own RELOPT_KIND_*
+ * parser to accept standard options (fillfactor, parallel_workers,
+ * autovacuum_*, etc.) that core registers only for RELOPT_KIND_HEAP.
+ * Without this, every AM that wants the standard option set would
+ * have to re-register each option under its own kind.
+ *
+ * 'name' must match an existing option; 'kind' is OR'ed into that
+ * option's kinds mask.  Errors if no option with that name exists.
+ */
+void
+add_reloption_to_kind(const char *name, relopt_kind kind)
+{
+	int			namelen = strlen(name);
+	int			i;
+
+	if (need_initialization)
+		initialize_reloptions();
+
+	for (i = 0; relOpts[i]; i++)
+	{
+		if (relOpts[i]->namelen == namelen &&
+			strncmp(relOpts[i]->name, name, namelen) == 0)
+		{
+			relOpts[i]->kinds |= kind;
+			return;
+		}
+	}
+
+	ereport(ERROR,
+			(errcode(ERRCODE_UNDEFINED_OBJECT),
+			 errmsg("reloption \"%s\" does not exist", name)));
+}
+
 /*
  * add_reloption
  *		Add an already-created custom reloption to the list, and recompute the
@@ -1516,8 +1555,11 @@ extractRelOptions(HeapTuple tuple, TupleDesc tupdesc,
 	switch (classForm->relkind)
 	{
 		case RELKIND_RELATION:
-		case RELKIND_TOASTVALUE:
 		case RELKIND_MATVIEW:
+			options = table_reloptions(amoptions, classForm->relkind,
+									   datum, false);
+			break;
+		case RELKIND_TOASTVALUE:
 			options = heap_reloptions(classForm->relkind, datum, false);
 			break;
 		case RELKIND_PARTITIONED_TABLE:
@@ -2187,6 +2229,50 @@ heap_reloptions(char relkind, Datum reloptions, bool validate)
 	}
 }
 
+/*
+ * Parse options for a table relation, dispatching to the access method's
+ * own option parser when it supplies one.
+ *
+ *	amoptions	the table AM's option parser, or NULL to fall back to the
+ *				standard heap parser for this relkind.
+ *	relkind		the relation's kind.
+ *	reloptions	options as a text[] datum.
+ *	validate	error flag for unknown options or bad values.
+ *
+ * When amoptions is non-NULL the AM owns the option set: it may accept
+ * all standard heap options, only a subset, or define its own.  The
+ * returned bytea is laid out as the AM dictates (it is stored verbatim
+ * in Relation->rd_options).  When amoptions is NULL the result is the
+ * standard StdRdOptions layout.
+ */
+bytea *
+table_reloptions(amoptions_function amoptions, char relkind,
+				 Datum reloptions, bool validate)
+{
+	if (amoptions != NULL)
+		return amoptions(reloptions, validate);
+	return heap_reloptions(relkind, reloptions, validate);
+}
+
+/*
+ * Returns true when the relation's rd_options buffer is laid out as
+ * StdRdOptions.  The rel.h accessor macros (RelationGetFillFactor,
+ * RelationIsUsedAsCatalogTable, ...) check this first before casting
+ * rd_options to StdRdOptions, so that a table access method which supplies
+ * its own amoptions callback (and therefore owns the rd_options layout)
+ * does not have its bytes misinterpreted.
+ */
+bool
+RelationHasStdRdOptions(Relation relation)
+{
+	if (relation->rd_options == NULL)
+		return false;
+	if (relation->rd_tableam == NULL)
+		return false;
+	return relation->rd_tableam->amoptions == NULL ||
+		relation->rd_tableam->has_std_options_prefix;
+}
+
 
 /*
  * Parse options for indexes.
diff --git a/src/backend/catalog/index.c b/src/backend/catalog/index.c
index 9407c357f27..b08aa11b206 100644
--- a/src/backend/catalog/index.c
+++ b/src/backend/catalog/index.c
@@ -2871,7 +2871,8 @@ index_update_stats(Relation rel,
 	{
 		if (AutoVacuumingActive())
 		{
-			StdRdOptions *options = (StdRdOptions *) rel->rd_options;
+			StdRdOptions *options = RelationHasStdRdOptions(rel) ?
+				(StdRdOptions *) rel->rd_options : NULL;
 
 			if (options != NULL && !options->autovacuum.enabled)
 				update_stats = false;
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index eec09ba1ded..24e8b787f0b 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -693,9 +693,11 @@ static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
 								const char *tablespacename, LOCKMODE lockmode);
 static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
 static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
+static void ATValidateAccessMethodOptions(List **wqueue);
 static void ATExecSetRelOptions(Relation rel, List *defList,
 								AlterTableType operation,
-								LOCKMODE lockmode);
+								LOCKMODE lockmode,
+								Oid newAccessMethodId);
 static void ATExecEnableDisableTrigger(Relation rel, const char *trigname,
 									   char fires_when, bool skip_system, bool recurse,
 									   LOCKMODE lockmode);
@@ -961,6 +963,41 @@ DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId,
 		case RELKIND_PARTITIONED_TABLE:
 			(void) partitioned_table_reloptions(reloptions, true);
 			break;
+		case RELKIND_RELATION:
+		case RELKIND_MATVIEW:
+			{
+				amoptions_function amoptions = NULL;
+				Oid			amoid = InvalidOid;
+
+				/*
+				 * Resolve the table AM so its option parser can validate
+				 * AM-specific reloptions.  An AM that does not register a
+				 * parser falls back to default_reloptions for
+				 * RELOPT_KIND_HEAP.
+				 */
+				if (stmt->accessMethod != NULL)
+					amoid = get_table_am_oid(stmt->accessMethod, false);
+				else if (stmt->partbound != NULL && inheritOids != NIL)
+					amoid = get_rel_relam(linitial_oid(inheritOids));
+				else
+					amoid = get_table_am_oid(default_table_access_method, false);
+
+				if (OidIsValid(amoid))
+				{
+					HeapTuple	tuple;
+
+					tuple = SearchSysCache1(AMOID, ObjectIdGetDatum(amoid));
+					if (HeapTupleIsValid(tuple))
+					{
+						Form_pg_am	amform = (Form_pg_am) GETSTRUCT(tuple);
+
+						amoptions = GetTableAmRoutine(amform->amhandler)->amoptions;
+						ReleaseSysCache(tuple);
+					}
+				}
+				(void) table_reloptions(amoptions, relkind, reloptions, true);
+			}
+			break;
 		default:
 			(void) heap_reloptions(relkind, reloptions, true);
 	}
@@ -4924,6 +4961,18 @@ ATController(AlterTableStmt *parsetree,
 	/* Phase 2: update system catalogs */
 	ATRewriteCatalogs(&wqueue, lockmode, context);
 
+	/*
+	 * After all phase-2 subcommands have committed any SET / RESET / REPLACE
+	 * option changes to pg_class, but before any rewrite, ensure the final
+	 * reloptions are accepted by the access method the relation will use once
+	 * the ALTER TABLE finishes.  This catches the case where SET ACCESS
+	 * METHOD changes the AM and leaves pre-existing reloptions in pg_class
+	 * that the new AM does not recognise; without this check the new AM's
+	 * option parser would be called with validate=false at relcache load time
+	 * and silently ignore them.
+	 */
+	ATValidateAccessMethodOptions(&wqueue);
+
 	/* Phase 3: scan/rewrite tables as needed, and run afterStmts */
 	ATRewriteTables(parsetree, &wqueue, lockmode, context);
 }
@@ -5595,7 +5644,17 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
 		case AT_SetRelOptions:	/* SET (...) */
 		case AT_ResetRelOptions:	/* RESET (...) */
 		case AT_ReplaceRelOptions:	/* replace entire option list */
-			ATExecSetRelOptions(rel, (List *) cmd->def, cmd->subtype, lockmode);
+
+			/*
+			 * If SET ACCESS METHOD is queued in the same ALTER TABLE, the
+			 * reloptions in pg_class will be parsed by the new AM after the
+			 * statement finishes; tell ATExecSetRelOptions to validate
+			 * against that AM rather than the relation's current AM.  This
+			 * lets a user write ALTER TABLE t SET ACCESS METHOD x, SET (foo =
+			 * bar) where foo is recognised by x but not by the current AM.
+			 */
+			ATExecSetRelOptions(rel, (List *) cmd->def, cmd->subtype, lockmode,
+								tab->chgAccessMethod ? tab->newAccessMethod : InvalidOid);
 			break;
 		case AT_EnableTrig:		/* ENABLE TRIGGER name */
 			ATExecEnableDisableTrigger(rel, cmd->name,
@@ -16885,12 +16944,92 @@ ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel, const char *tablespacen
 	tab->newTableSpace = tablespaceId;
 }
 
+/*
+ * Re-validate pg_class.reloptions for every work-queue entry whose access
+ * method is being changed.  Called between phase 2 (catalog updates) and
+ * phase 3 (table rewrites): SET / RESET / REPLACE subcommands have already
+ * been committed to pg_class, and tab->newAccessMethod identifies the AM
+ * the relation will use once the ALTER TABLE finishes.
+ *
+ * The check exists because relcache.c calls the AM's option parser with
+ * validate=false at relation open: any pre-existing reloption that the
+ * new AM does not recognise would otherwise be silently dropped from the
+ * parsed StdRdOptions / AM-specific options struct, leaving the user
+ * unable to tell that the option is no longer in effect.  Failing the
+ * ALTER TABLE here with a clear message lets the user RESET the option
+ * in the same statement and re-run.
+ */
+static void
+ATValidateAccessMethodOptions(List **wqueue)
+{
+	ListCell   *ltab;
+
+	foreach(ltab, *wqueue)
+	{
+		AlteredTableInfo *tab = (AlteredTableInfo *) lfirst(ltab);
+		HeapTuple	amtup;
+		HeapTuple	reltup;
+		Form_pg_am	amform;
+		Form_pg_class relform;
+		amoptions_function amoptions;
+		Datum		reloptions;
+		bool		isnull;
+		Oid			amoid;
+
+		if (!tab->chgAccessMethod)
+			continue;
+
+		/*
+		 * Partitioned tables may reset the AM to "default" (InvalidOid); each
+		 * partition then chooses its own AM at create time, so there is no
+		 * per-relation AM whose parser to consult here.
+		 */
+		amoid = tab->newAccessMethod;
+		if (!OidIsValid(amoid))
+			continue;
+
+		amtup = SearchSysCache1(AMOID, ObjectIdGetDatum(amoid));
+		if (!HeapTupleIsValid(amtup))
+			elog(ERROR, "cache lookup failed for access method %u", amoid);
+		amform = (Form_pg_am) GETSTRUCT(amtup);
+		amoptions = GetTableAmRoutine(amform->amhandler)->amoptions;
+		ReleaseSysCache(amtup);
+
+		/*
+		 * If the new AM has no option parser of its own, table_reloptions
+		 * falls back to the standard heap parser, which accepts whatever the
+		 * old AM accepted (every other AM in core uses the same StdRdOptions
+		 * today), so there is nothing to re-check.
+		 */
+		if (amoptions == NULL)
+			continue;
+
+		reltup = SearchSysCache1(RELOID, ObjectIdGetDatum(tab->relid));
+		if (!HeapTupleIsValid(reltup))
+			elog(ERROR, "cache lookup failed for relation %u", tab->relid);
+		relform = (Form_pg_class) GETSTRUCT(reltup);
+		reloptions = SysCacheGetAttr(RELOID, reltup,
+									 Anum_pg_class_reloptions, &isnull);
+		if (!isnull)
+			(void) table_reloptions(amoptions, relform->relkind,
+									reloptions, true);
+		ReleaseSysCache(reltup);
+	}
+}
+
 /*
  * Set, reset, or replace reloptions.
+ *
+ * newAccessMethodId, if valid, names the table access method whose option
+ * parser should validate the resulting reloptions.  This is used when SET
+ * ACCESS METHOD is queued in the same ALTER TABLE so that the new options
+ * are checked against the AM the relation will use after the statement
+ * finishes, not the AM it has now.  Pass InvalidOid to use the relation's
+ * current access method.
  */
 static void
 ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
-					LOCKMODE lockmode)
+					LOCKMODE lockmode, Oid newAccessMethodId)
 {
 	Oid			relid;
 	Relation	pgclass;
@@ -16942,7 +17081,30 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
 	{
 		case RELKIND_RELATION:
 		case RELKIND_MATVIEW:
-			(void) heap_reloptions(rel->rd_rel->relkind, newOptions, true);
+			{
+				amoptions_function amoptions;
+
+				if (OidIsValid(newAccessMethodId))
+				{
+					HeapTuple	amtup;
+					Form_pg_am	amform;
+
+					amtup = SearchSysCache1(AMOID,
+											ObjectIdGetDatum(newAccessMethodId));
+					if (!HeapTupleIsValid(amtup))
+						elog(ERROR, "cache lookup failed for access method %u",
+							 newAccessMethodId);
+					amform = (Form_pg_am) GETSTRUCT(amtup);
+					amoptions = GetTableAmRoutine(amform->amhandler)->amoptions;
+					ReleaseSysCache(amtup);
+				}
+				else
+					amoptions = (rel->rd_tableam ?
+								 rel->rd_tableam->amoptions : NULL);
+
+				(void) table_reloptions(amoptions, rel->rd_rel->relkind,
+										newOptions, true);
+			}
 			break;
 		case RELKIND_PARTITIONED_TABLE:
 			(void) partitioned_table_reloptions(newOptions, true);
diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c
index 99d0db82ed7..68d1a5369fb 100644
--- a/src/backend/commands/vacuum.c
+++ b/src/backend/commands/vacuum.c
@@ -2185,7 +2185,7 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 	{
 		StdRdOptIndexCleanup vacuum_index_cleanup;
 
-		if (rel->rd_options == NULL)
+		if (!RelationHasStdRdOptions(rel))
 			vacuum_index_cleanup = STDRD_OPTION_VACUUM_INDEX_CLEANUP_AUTO;
 		else
 			vacuum_index_cleanup =
@@ -2216,7 +2216,7 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 	 * Check if the vacuum_max_eager_freeze_failure_rate table storage
 	 * parameter was specified. This overrides the GUC value.
 	 */
-	if (rel->rd_options != NULL &&
+	if (RelationHasStdRdOptions(rel) &&
 		((StdRdOptions *) rel->rd_options)->vacuum_max_eager_freeze_failure_rate >= 0)
 		params.max_eager_freeze_failure_rate =
 			((StdRdOptions *) rel->rd_options)->vacuum_max_eager_freeze_failure_rate;
@@ -2227,7 +2227,8 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 	 */
 	if (params.truncate == VACOPTVALUE_UNSPECIFIED)
 	{
-		StdRdOptions *opts = (StdRdOptions *) rel->rd_options;
+		StdRdOptions *opts = RelationHasStdRdOptions(rel) ?
+			(StdRdOptions *) rel->rd_options : NULL;
 
 		if (opts && opts->vacuum_truncate != PG_TERNARY_UNSET)
 		{
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index e19f0d3e51c..958c13f8338 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -481,9 +481,11 @@ RelationParseRelOptions(Relation relation, HeapTuple tuple)
 	switch (relation->rd_rel->relkind)
 	{
 		case RELKIND_RELATION:
+		case RELKIND_MATVIEW:
+			amoptsfn = relation->rd_tableam ? relation->rd_tableam->amoptions : NULL;
+			break;
 		case RELKIND_TOASTVALUE:
 		case RELKIND_VIEW:
-		case RELKIND_MATVIEW:
 		case RELKIND_PARTITIONED_TABLE:
 			amoptsfn = NULL;
 			break;
diff --git a/src/include/access/reloptions.h b/src/include/access/reloptions.h
index e8cb7f7a627..1282bccc77f 100644
--- a/src/include/access/reloptions.h
+++ b/src/include/access/reloptions.h
@@ -187,6 +187,7 @@ typedef struct local_relopts
 	 (char *)(optstruct) + (optstruct)->member)
 
 extern relopt_kind add_reloption_kind(void);
+extern void add_reloption_to_kind(const char *name, relopt_kind kind);
 extern void add_bool_reloption(uint32 kinds, const char *name, const char *desc,
 							   bool default_val, LOCKMODE lockmode);
 extern void add_ternary_reloption(uint32 kinds, const char *name,
@@ -248,6 +249,8 @@ extern void *build_local_reloptions(local_relopts *relopts, Datum options,
 extern bytea *default_reloptions(Datum reloptions, bool validate,
 								 relopt_kind kind);
 extern bytea *heap_reloptions(char relkind, Datum reloptions, bool validate);
+extern bytea *table_reloptions(amoptions_function amoptions, char relkind,
+							   Datum reloptions, bool validate);
 extern bytea *view_reloptions(Datum reloptions, bool validate);
 extern bytea *partitioned_table_reloptions(Datum reloptions, bool validate);
 extern bytea *index_reloptions(amoptions_function amoptions, Datum reloptions,
diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h
index c13f05d39db..332b70a1e11 100644
--- a/src/include/access/tableam.h
+++ b/src/include/access/tableam.h
@@ -17,6 +17,7 @@
 #ifndef TABLEAM_H
 #define TABLEAM_H
 
+#include "access/amapi.h"
 #include "access/relscan.h"
 #include "access/sdir.h"
 #include "access/xact.h"
@@ -324,6 +325,65 @@ typedef struct TableAmRoutine
 	NodeTag		type;
 
 
+	/* ------------------------------------------------------------------------
+	 * Reloption parsing.
+	 * ------------------------------------------------------------------------
+	 */
+
+	/*
+	 * Parse and validate AM-specific reloptions.  Optional: when NULL, the
+	 * caller falls back to the standard heap reloption parser
+	 * (default_reloptions with RELOPT_KIND_HEAP) and the result is laid out
+	 * as StdRdOptions.
+	 *
+	 * When non-NULL, the AM owns the option set entirely.  It is free to
+	 * accept all standard heap options, only a subset, or to add its own. The
+	 * returned bytea must begin with a VARSIZE header and is stored in
+	 * Relation->rd_options, so the AM dictates the in-memory layout that its
+	 * other callbacks read.  Core code that reads StdRdOptions fields out of
+	 * rd_options (RelationGetFillFactor, RelationIsUsedAsCatalogTable, ...)
+	 * checks RelationHasStdRdOptions() first, so a custom layout will not be
+	 * misinterpreted -- unless the AM sets has_std_options_prefix below to
+	 * declare that its struct is a StdRdOptions superset after all.
+	 *
+	 * The callback validates user-supplied values but must not silently
+	 * rewrite them: a user inspecting pg_class.reloptions must see exactly
+	 * what they passed in.  Out-of-range or unknown options should be
+	 * reported with ereport(ERROR) when validate is true.
+	 *
+	 * Signature matches the index AM's amoptions callback so the same helper
+	 * machinery (add_string_reloption, add_int_reloption, etc.) can be used.
+	 */
+	amoptions_function amoptions;
+
+	/*
+	 * Set to true when amoptions is non-NULL and the bytea it returns
+	 * begins with a full "StdRdOptions std;" as its first member (i.e. the
+	 * AM's own reloptions struct is a superset of StdRdOptions, not just a
+	 * layout that happens to share a prefix).  This tells core code that it
+	 * is safe to read StdRdOptions fields directly out of rd_options
+	 * (RelationGetFillFactor, RelationIsUsedAsCatalogTable, ...) for
+	 * relations of this AM, exactly as it would for plain heap.
+	 *
+	 * An AM that sets this must register every StdRdOptions field those
+	 * macros read (fillfactor, toast_tuple_target, user_catalog_table,
+	 * parallel_workers, vacuum_index_cleanup, vacuum_truncate,
+	 * vacuum_max_eager_freeze_failure_rate, autovacuum_enabled) with
+	 * add_reloption_to_kind(), even if it exposes none of them as options
+	 * the AM cares about itself: build_reloptions() only fills in fields
+	 * that are registered for the AM's relopt_kind, so an embedded
+	 * StdRdOptions field the AM never registers is left zeroed rather than
+	 * at that option's real default (0 is not a valid "unset" sentinel for
+	 * several of these fields, e.g. parallel_workers and
+	 * vacuum_max_eager_freeze_failure_rate both use -1).  Registering the
+	 * field via add_reloption_to_kind lets each one pick up its normal
+	 * catalog default instead.
+	 *
+	 * Ignored when amoptions is NULL.
+	 */
+	bool		has_std_options_prefix;
+
+
 	/* ------------------------------------------------------------------------
 	 * Slot related callbacks.
 	 * ------------------------------------------------------------------------
diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h
index cd1e92f2302..04caa15707a 100644
--- a/src/include/utils/rel.h
+++ b/src/include/utils/rel.h
@@ -361,12 +361,33 @@ typedef struct StdRdOptions
 #define HEAP_MIN_FILLFACTOR			10
 #define HEAP_DEFAULT_FILLFACTOR		100
 
+/*
+ * RelationHasStdRdOptions
+ *		Returns true when the relation's rd_options buffer is safe to read
+ *		as StdRdOptions: either it was produced by the standard heap
+ *		reloption parser (the AM has no amoptions callback), or the AM's
+ *		own amoptions callback returns a struct that embeds a full
+ *		StdRdOptions as its first member and says so via
+ *		TableAmRoutine.has_std_options_prefix.  A table access method that
+ *		supplies amoptions without setting that flag owns its rd_options
+ *		layout entirely and is not required to expose StdRdOptions fields;
+ *		macros that read those fields must check this first to avoid
+ *		reading garbage, or past the end of a smaller custom struct.  For
+ *		indexes and other relkinds rd_options is in an AM-specific layout,
+ *		so this returns false for them.
+ *
+ *		Defined as a function (in reloptions.c) rather than a macro
+ *		because the test needs the full TableAmRoutine struct definition,
+ *		which would create an #include cycle if pulled into rel.h.
+ */
+extern bool RelationHasStdRdOptions(Relation relation);
+
 /*
  * RelationGetToastTupleTarget
  *		Returns the relation's toast_tuple_target.  Note multiple eval of argument!
  */
 #define RelationGetToastTupleTarget(relation, defaulttarg) \
-	((relation)->rd_options ? \
+	(RelationHasStdRdOptions(relation) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->toast_tuple_target : (defaulttarg))
 
 /*
@@ -374,7 +395,7 @@ typedef struct StdRdOptions
  *		Returns the relation's fillfactor.  Note multiple eval of argument!
  */
 #define RelationGetFillFactor(relation, defaultff) \
-	((relation)->rd_options ? \
+	(RelationHasStdRdOptions(relation) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->fillfactor : (defaultff))
 
 /*
@@ -397,7 +418,7 @@ typedef struct StdRdOptions
  *		from the pov of logical decoding.  Note multiple eval of argument!
  */
 #define RelationIsUsedAsCatalogTable(relation)	\
-	((relation)->rd_options && \
+	(RelationHasStdRdOptions(relation) && \
 	 ((relation)->rd_rel->relkind == RELKIND_RELATION || \
 	  (relation)->rd_rel->relkind == RELKIND_MATVIEW) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->user_catalog_table : false)
@@ -408,7 +429,7 @@ typedef struct StdRdOptions
  *		Note multiple eval of argument!
  */
 #define RelationGetParallelWorkers(relation, defaultpw) \
-	((relation)->rd_options ? \
+	(RelationHasStdRdOptions(relation) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->parallel_workers : (defaultpw))
 
 /* ViewOptions->check_option values */
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 0a74ab5c86f..223005fdf98 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -10,6 +10,7 @@ SUBDIRS = \
 		  delay_execution \
 		  dummy_index_am \
 		  dummy_seclabel \
+		  dummy_table_am \
 		  index \
 		  libpq_pipeline \
 		  oauth_validator \
diff --git a/src/test/modules/dummy_table_am/Makefile b/src/test/modules/dummy_table_am/Makefile
new file mode 100644
index 00000000000..94837dff392
--- /dev/null
+++ b/src/test/modules/dummy_table_am/Makefile
@@ -0,0 +1,20 @@
+# src/test/modules/dummy_table_am/Makefile
+
+MODULES = dummy_table_am
+
+EXTENSION = dummy_table_am
+DATA = dummy_table_am--1.0.sql
+PGFILEDESC = "dummy_table_am - table access method template"
+
+REGRESS = reloptions
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/dummy_table_am
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/dummy_table_am/README b/src/test/modules/dummy_table_am/README
new file mode 100644
index 00000000000..a234a1f107f
--- /dev/null
+++ b/src/test/modules/dummy_table_am/README
@@ -0,0 +1,21 @@
+Dummy Table AM
+==============
+
+Dummy table AM is a module for testing the table access method
+amoptions callback and the add_reloption_to_kind() helper.  It
+delegates all storage and scan callbacks to the heap AM and only
+swaps in its own option parser, so a relation created with USING
+dummy_table_am behaves like a heap table but accepts a different
+set of reloptions:
+
+  - "fillfactor"     (inherited from the core heap registration via
+                      add_reloption_to_kind)
+  - "option_int"     (integer)
+  - "option_real"    (real)
+  - "option_bool"    (boolean)
+  - "option_enum"    (enum, one|two)
+
+Standard heap options such as parallel_workers, autovacuum_*, and
+toast_tuple_target are intentionally NOT accepted, to exercise the
+"AM rejects an unknown option" path in ALTER TABLE ... SET ACCESS
+METHOD revalidation.
diff --git a/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql b/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
new file mode 100644
index 00000000000..2e295b95845
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
@@ -0,0 +1,13 @@
+/* src/test/modules/dummy_table_am/dummy_table_am--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION dummy_table_am" to load this file. \quit
+
+CREATE FUNCTION dthandler(internal)
+RETURNS table_am_handler
+AS 'MODULE_PATHNAME'
+LANGUAGE C;
+
+-- Access method
+CREATE ACCESS METHOD dummy_table_am TYPE TABLE HANDLER dthandler;
+COMMENT ON ACCESS METHOD dummy_table_am IS 'dummy table access method';
diff --git a/src/test/modules/dummy_table_am/dummy_table_am.c b/src/test/modules/dummy_table_am/dummy_table_am.c
new file mode 100644
index 00000000000..1fba6057675
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am.c
@@ -0,0 +1,253 @@
+/*-------------------------------------------------------------------------
+ *
+ * dummy_table_am.c
+ *		Table AM template main file.
+ *
+ * This module exists primarily to demonstrate and exercise the table AM
+ * amoptions callback and the add_reloption_to_kind() helper.  Storage
+ * and scan callbacks are delegated to the heap AM, so a relation
+ * created with USING dummy_table_am behaves like a heap table; only the
+ * reloption surface differs.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/test/modules/dummy_table_am/dummy_table_am.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/reloptions.h"
+#include "access/tableam.h"
+#include "catalog/pg_am_d.h"
+#include "fmgr.h"
+#include "utils/rel.h"
+
+PG_MODULE_MAGIC;
+
+/* Parse table for build_reloptions: 8 inherited standard options + 4 of our own */
+static relopt_parse_elt dt_relopt_tab[12];
+
+/* Kind of relation options for dummy table */
+static relopt_kind dt_relopt_kind;
+
+typedef enum DummyTableEnum
+{
+	DUMMY_TABLE_ENUM_ONE,
+	DUMMY_TABLE_ENUM_TWO,
+}			DummyTableEnum;
+
+/*
+ * Dummy table options.
+ *
+ * "std" embeds the full standard heap layout: every option that heap's own
+ * default_reloptions() understands (fillfactor, toast_tuple_target,
+ * parallel_workers, vacuum_index_cleanup, vacuum_truncate,
+ * vacuum_max_eager_freeze_failure_rate, autovacuum_enabled,
+ * user_catalog_table) is inherited by calling add_reloption_to_kind() on
+ * the matching name, and each is registered here so that build_reloptions()
+ * fills it with the option's own catalog default when the user does not
+ * set it explicitly -- an embedded StdRdOptions field that is never
+ * registered stays zeroed, which is not the right "unset" value for
+ * several of these (e.g. parallel_workers and
+ * vacuum_max_eager_freeze_failure_rate both use -1).  Because "std" is a
+ * full StdRdOptions and every one of its fields is populated this way, we
+ * can tell core code it's safe to keep reading them directly by setting
+ * TableAmRoutine.has_std_options_prefix (see dthandler() below).
+ *
+ * The remaining four are AM-specific options that only dummy_table_am
+ * knows about.
+ */
+typedef struct DummyTableOptions
+{
+	StdRdOptions std;			/* must be first, see above */
+	int			option_int;
+	double		option_real;
+	bool		option_bool;
+	DummyTableEnum option_enum;
+}			DummyTableOptions;
+
+static relopt_enum_elt_def dummyTableEnumValues[] =
+{
+	{"one", DUMMY_TABLE_ENUM_ONE},
+	{"two", DUMMY_TABLE_ENUM_TWO},
+	{(const char *) NULL}		/* list terminator */
+};
+
+PG_FUNCTION_INFO_V1(dthandler);
+
+/*
+ * Register a relopt_kind for this AM and populate the parse table.
+ */
+static void
+create_reloptions_table(void)
+{
+	int			i = 0;
+
+	dt_relopt_kind = add_reloption_kind();
+
+	/*
+	 * Accept every standard option that core's default_reloptions()
+	 * understands (registered for RELOPT_KIND_HEAP and/or RELOPT_KIND_TOAST)
+	 * under our own kind.  This is the canonical use of
+	 * add_reloption_to_kind(): an AM that wants to honour existing
+	 * core-registered options without duplicating their definitions.  See
+	 * the comment on DummyTableOptions.std for why we register all of
+	 * them rather than just fillfactor.
+	 */
+	add_reloption_to_kind("fillfactor", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "fillfactor";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.fillfactor);
+	i++;
+
+	add_reloption_to_kind("toast_tuple_target", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "toast_tuple_target";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.toast_tuple_target);
+	i++;
+
+	add_reloption_to_kind("parallel_workers", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "parallel_workers";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.parallel_workers);
+	i++;
+
+	add_reloption_to_kind("vacuum_index_cleanup", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "vacuum_index_cleanup";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_ENUM;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.vacuum_index_cleanup);
+	i++;
+
+	add_reloption_to_kind("vacuum_truncate", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "vacuum_truncate";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_TERNARY;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.vacuum_truncate);
+	i++;
+
+	add_reloption_to_kind("vacuum_max_eager_freeze_failure_rate", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "vacuum_max_eager_freeze_failure_rate";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_REAL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.vacuum_max_eager_freeze_failure_rate);
+	i++;
+
+	add_reloption_to_kind("autovacuum_enabled", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "autovacuum_enabled";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_BOOL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.autovacuum.enabled);
+	i++;
+
+	add_reloption_to_kind("user_catalog_table", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "user_catalog_table";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_BOOL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.user_catalog_table);
+	i++;
+
+	add_int_reloption(dt_relopt_kind, "option_int",
+					  "Integer option for dummy_table_am",
+					  10, -10, 100, AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_int";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_int);
+	i++;
+
+	add_real_reloption(dt_relopt_kind, "option_real",
+					   "Real option for dummy_table_am",
+					   3.1415, -10, 100, AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_real";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_REAL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_real);
+	i++;
+
+	add_bool_reloption(dt_relopt_kind, "option_bool",
+					   "Boolean option for dummy_table_am",
+					   true, AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_bool";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_BOOL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_bool);
+	i++;
+
+	add_enum_reloption(dt_relopt_kind, "option_enum",
+					   "Enum option for dummy_table_am",
+					   dummyTableEnumValues,
+					   DUMMY_TABLE_ENUM_ONE,
+					   "Valid values are \"one\" and \"two\".",
+					   AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_enum";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_ENUM;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_enum);
+	i++;
+}
+
+/*
+ * Parse reloptions for dummy_table_am.
+ *
+ * Returning DummyTableOptions tells the caller (relcache.c) to store
+ * exactly that layout in Relation->rd_options.
+ */
+static bytea *
+dtoptions(Datum reloptions, bool validate)
+{
+	return (bytea *) build_reloptions(reloptions, validate,
+									  dt_relopt_kind,
+									  sizeof(DummyTableOptions),
+									  dt_relopt_tab, lengthof(dt_relopt_tab));
+}
+
+/*
+ * heapam_relation_toast_am() (heap's own relation_toast_am callback, which
+ * we would otherwise inherit unchanged along with the rest of heap's
+ * routine) returns rel->rd_rel->relam -- correct for a real heap table, but
+ * for dummy_table_am that's dummy_table_am's own oid, not heap's.  That
+ * would make this AM's TOAST tables dummy_table_am relations too, and
+ * building their chunk_id/chunk_seq index fails as soon as it's scanned,
+ * since that scan goes through heap_getnext() directly.  Override it to
+ * return the literal heap AM oid: this AM's TOAST tables are always plain
+ * heap, regardless of what created the owning table.
+ */
+static Oid
+dummy_table_relation_toast_am(Relation rel)
+{
+	return HEAP_TABLE_AM_OID;
+}
+
+/*
+ * Handler for table AM.
+ *
+ * All storage-side callbacks are inherited from heap; we swap in our own
+ * amoptions so that the AM owns its reloption set, and our own
+ * relation_toast_am (see dummy_table_relation_toast_am() above).  This
+ * keeps the example focused on the new API without duplicating the heap
+ * AM.
+ *
+ * has_std_options_prefix is set because DummyTableOptions embeds a full
+ * StdRdOptions as its first member with every field populated (see the
+ * comment on DummyTableOptions): that makes it safe for core code to keep
+ * reading fillfactor and friends directly out of rd_options, exactly as
+ * it would for a plain heap table.
+ */
+Datum
+dthandler(PG_FUNCTION_ARGS)
+{
+	static TableAmRoutine routine;
+	static bool initialized = false;
+
+	if (!initialized)
+	{
+		memcpy(&routine, GetHeapamTableAmRoutine(), sizeof(routine));
+		routine.amoptions = dtoptions;
+		routine.has_std_options_prefix = true;
+		routine.relation_toast_am = dummy_table_relation_toast_am;
+		initialized = true;
+	}
+
+	PG_RETURN_POINTER(&routine);
+}
+
+void
+_PG_init(void)
+{
+	create_reloptions_table();
+}
diff --git a/src/test/modules/dummy_table_am/dummy_table_am.control b/src/test/modules/dummy_table_am/dummy_table_am.control
new file mode 100644
index 00000000000..08f2f868d49
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am.control
@@ -0,0 +1,5 @@
+# dummy_table_am extension
+comment = 'dummy_table_am - table access method template'
+default_version = '1.0'
+module_pathname = '$libdir/dummy_table_am'
+relocatable = true
diff --git a/src/test/modules/dummy_table_am/expected/reloptions.out b/src/test/modules/dummy_table_am/expected/reloptions.out
new file mode 100644
index 00000000000..5d38767c528
--- /dev/null
+++ b/src/test/modules/dummy_table_am/expected/reloptions.out
@@ -0,0 +1,184 @@
+-- Tests for the table AM amoptions callback and add_reloption_to_kind()
+CREATE EXTENSION dummy_table_am;
+-- Sanity: CREATE TABLE with AM-specific options succeeds and round-trips
+CREATE TABLE dummy_t (a int) USING dummy_table_am
+    WITH (option_int = 17, option_real = 2.5, option_bool = false,
+          option_enum = 'two', fillfactor = 60);
+SELECT reloptions FROM pg_class
+    WHERE oid = 'dummy_t'::regclass ORDER BY reloptions;
+                                   reloptions                                    
+---------------------------------------------------------------------------------
+ {option_int=17,option_real=2.5,option_bool=false,option_enum=two,fillfactor=60}
+(1 row)
+
+-- AM-specific option ranges are enforced (option_int allows -10..100)
+CREATE TABLE dummy_oor (a int) USING dummy_table_am WITH (option_int = 9999);
+ERROR:  value 9999 out of bounds for option "option_int"
+DETAIL:  Valid values are between "-10" and "100".
+-- Unknown options are rejected at CREATE TABLE time
+CREATE TABLE dummy_bad (a int) USING dummy_table_am WITH (autovacuum_vacuum_threshold = 4);
+ERROR:  unrecognized parameter "autovacuum_vacuum_threshold"
+-- Default values land in pg_class only when the user did not set them
+CREATE TABLE dummy_defaults (a int) USING dummy_table_am;
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_defaults'::regclass;
+ reloptions 
+------------
+ 
+(1 row)
+
+DROP TABLE dummy_defaults;
+-- ALTER TABLE ... SET (...) with AM-specific option
+ALTER TABLE dummy_t SET (option_int = 42);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+                                   reloptions                                    
+---------------------------------------------------------------------------------
+ {option_real=2.5,option_bool=false,option_enum=two,fillfactor=60,option_int=42}
+(1 row)
+
+-- ALTER TABLE ... SET (...) with an unknown option errors
+ALTER TABLE dummy_t SET (autovacuum_vacuum_threshold = 4);
+ERROR:  unrecognized parameter "autovacuum_vacuum_threshold"
+-- ALTER TABLE ... RESET (option) round-trips
+ALTER TABLE dummy_t RESET (option_int);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+                            reloptions                             
+-------------------------------------------------------------------
+ {option_real=2.5,option_bool=false,option_enum=two,fillfactor=60}
+(1 row)
+
+-- SET ACCESS METHOD revalidation:
+--   moving a heap table that has standard heap options not accepted by the
+--   new AM (autovacuum_vacuum_threshold; dummy_table_am inherits
+--   autovacuum_enabled but not the rest of the autovacuum_* family) into
+--   dummy_table_am must fail with a clear message and must NOT silently
+--   drop the option.
+CREATE TABLE heap_t (a int) WITH (fillfactor = 70, autovacuum_vacuum_threshold = 4);
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+                  reloptions                   
+-----------------------------------------------
+ {fillfactor=70,autovacuum_vacuum_threshold=4}
+(1 row)
+
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am;
+ERROR:  unrecognized parameter "autovacuum_vacuum_threshold"
+-- Confirm nothing changed
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+ amname 
+--------
+ heap
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+                  reloptions                   
+-----------------------------------------------
+ {fillfactor=70,autovacuum_vacuum_threshold=4}
+(1 row)
+
+-- After RESETing the offending option in the same statement the swap
+-- succeeds; fillfactor survives because dummy_table_am inherits it via
+-- add_reloption_to_kind().
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am, RESET (autovacuum_vacuum_threshold);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+     amname     
+----------------
+ dummy_table_am
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+   reloptions    
+-----------------
+ {fillfactor=70}
+(1 row)
+
+-- Going back to heap still works: heap accepts fillfactor.
+ALTER TABLE heap_t SET ACCESS METHOD heap;
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+ amname 
+--------
+ heap
+(1 row)
+
+-- SET ACCESS METHOD + SET (...) of an option that only the new AM accepts.
+CREATE TABLE heap_to_dt (a int);
+ALTER TABLE heap_to_dt SET ACCESS METHOD dummy_table_am, SET (option_int = 25);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+     amname     
+----------------
+ dummy_table_am
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+   reloptions    
+-----------------
+ {option_int=25}
+(1 row)
+
+-- fillfactor genuinely reaches heap's own page-packing logic now, not just
+-- pg_class.reloptions: dummy_table_am embeds a full StdRdOptions as the
+-- first member of its own options struct and sets
+-- TableAmRoutine.has_std_options_prefix, so RelationGetFillFactor() can read
+-- it directly instead of always seeing the hardcoded default.
+CREATE TABLE dummy_ff10 (a int) USING dummy_table_am WITH (fillfactor = 10);
+CREATE TABLE dummy_ff100 (a int) USING dummy_table_am WITH (fillfactor = 100);
+INSERT INTO dummy_ff10 SELECT generate_series(1, 5000);
+INSERT INTO dummy_ff100 SELECT generate_series(1, 5000);
+VACUUM dummy_ff10;
+VACUUM dummy_ff100;
+SELECT (SELECT relpages FROM pg_class WHERE oid = 'dummy_ff10'::regclass) >
+       (SELECT relpages FROM pg_class WHERE oid = 'dummy_ff100'::regclass)
+       AS low_fillfactor_uses_more_pages;
+ low_fillfactor_uses_more_pages 
+--------------------------------
+ t
+(1 row)
+
+DROP TABLE dummy_ff10;
+DROP TABLE dummy_ff100;
+-- A table with a toastable column works: dummy_table_am overrides
+-- relation_toast_am rather than inheriting heap's, which would return
+-- this AM's own oid instead of heap's for its TOAST table, making that
+-- TOAST table itself a dummy_table_am relation and failing as soon as
+-- its chunk_id/chunk_seq index was built (that scan goes through
+-- heap_getnext() directly, which requires a real heap relation).
+CREATE TABLE dummy_txt (a int, b text) USING dummy_table_am;
+INSERT INTO dummy_txt VALUES (1, repeat('x', 10000));
+SELECT a, length(b) FROM dummy_txt;
+ a | length 
+---+--------
+ 1 |  10000
+(1 row)
+
+DROP TABLE dummy_txt;
+-- Partitioned-table inheritance: AM declared on the parent partition flows
+-- to partitions that don't override it.  Partitioned tables themselves
+-- cannot carry reloptions; the test verifies the AM lookup that
+-- DefineRelation does for partitions.
+CREATE TABLE parted (a int) PARTITION BY RANGE (a) USING dummy_table_am;
+CREATE TABLE parted_p1 PARTITION OF parted FOR VALUES FROM (0) TO (100)
+    WITH (option_int = 11);
+SELECT c.relname,
+       (SELECT amname FROM pg_am WHERE oid = c.relam) AS amname,
+       c.reloptions
+    FROM pg_class c
+    WHERE c.oid IN ('parted'::regclass, 'parted_p1'::regclass)
+    ORDER BY c.relname;
+  relname  |     amname     |   reloptions    
+-----------+----------------+-----------------
+ parted    | dummy_table_am | 
+ parted_p1 | dummy_table_am | {option_int=11}
+(2 rows)
+
+-- A partition that explicitly chooses heap must reject options that are
+-- only known to the parent's AM.
+CREATE TABLE parted_p2 PARTITION OF parted FOR VALUES FROM (100) TO (200)
+    USING heap WITH (option_int = 9);
+ERROR:  unrecognized parameter "option_int"
+DROP TABLE parted;
+DROP TABLE heap_to_dt;
+DROP TABLE heap_t;
+DROP TABLE dummy_t;
+DROP EXTENSION dummy_table_am;
diff --git a/src/test/modules/dummy_table_am/meson.build b/src/test/modules/dummy_table_am/meson.build
new file mode 100644
index 00000000000..ad3fa2410cc
--- /dev/null
+++ b/src/test/modules/dummy_table_am/meson.build
@@ -0,0 +1,33 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+dummy_table_am_sources = files(
+  'dummy_table_am.c',
+)
+
+if host_system == 'windows'
+  dummy_table_am_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'dummy_table_am',
+    '--FILEDESC', 'dummy_table_am - table access method template',])
+endif
+
+dummy_table_am = shared_module('dummy_table_am',
+  dummy_table_am_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += dummy_table_am
+
+test_install_data += files(
+  'dummy_table_am.control',
+  'dummy_table_am--1.0.sql',
+)
+
+tests += {
+  'name': 'dummy_table_am',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'reloptions',
+    ],
+  },
+}
diff --git a/src/test/modules/dummy_table_am/sql/reloptions.sql b/src/test/modules/dummy_table_am/sql/reloptions.sql
new file mode 100644
index 00000000000..97bc8479527
--- /dev/null
+++ b/src/test/modules/dummy_table_am/sql/reloptions.sql
@@ -0,0 +1,119 @@
+-- Tests for the table AM amoptions callback and add_reloption_to_kind()
+CREATE EXTENSION dummy_table_am;
+
+-- Sanity: CREATE TABLE with AM-specific options succeeds and round-trips
+CREATE TABLE dummy_t (a int) USING dummy_table_am
+    WITH (option_int = 17, option_real = 2.5, option_bool = false,
+          option_enum = 'two', fillfactor = 60);
+SELECT reloptions FROM pg_class
+    WHERE oid = 'dummy_t'::regclass ORDER BY reloptions;
+
+-- AM-specific option ranges are enforced (option_int allows -10..100)
+CREATE TABLE dummy_oor (a int) USING dummy_table_am WITH (option_int = 9999);
+
+-- Unknown options are rejected at CREATE TABLE time
+CREATE TABLE dummy_bad (a int) USING dummy_table_am WITH (autovacuum_vacuum_threshold = 4);
+
+-- Default values land in pg_class only when the user did not set them
+CREATE TABLE dummy_defaults (a int) USING dummy_table_am;
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_defaults'::regclass;
+DROP TABLE dummy_defaults;
+
+-- ALTER TABLE ... SET (...) with AM-specific option
+ALTER TABLE dummy_t SET (option_int = 42);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+
+-- ALTER TABLE ... SET (...) with an unknown option errors
+ALTER TABLE dummy_t SET (autovacuum_vacuum_threshold = 4);
+
+-- ALTER TABLE ... RESET (option) round-trips
+ALTER TABLE dummy_t RESET (option_int);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+
+-- SET ACCESS METHOD revalidation:
+--   moving a heap table that has standard heap options not accepted by the
+--   new AM (autovacuum_vacuum_threshold; dummy_table_am inherits
+--   autovacuum_enabled but not the rest of the autovacuum_* family) into
+--   dummy_table_am must fail with a clear message and must NOT silently
+--   drop the option.
+CREATE TABLE heap_t (a int) WITH (fillfactor = 70, autovacuum_vacuum_threshold = 4);
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am;
+-- Confirm nothing changed
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+
+-- After RESETing the offending option in the same statement the swap
+-- succeeds; fillfactor survives because dummy_table_am inherits it via
+-- add_reloption_to_kind().
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am, RESET (autovacuum_vacuum_threshold);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+
+-- Going back to heap still works: heap accepts fillfactor.
+ALTER TABLE heap_t SET ACCESS METHOD heap;
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+
+-- SET ACCESS METHOD + SET (...) of an option that only the new AM accepts.
+CREATE TABLE heap_to_dt (a int);
+ALTER TABLE heap_to_dt SET ACCESS METHOD dummy_table_am, SET (option_int = 25);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+
+-- fillfactor genuinely reaches heap's own page-packing logic now, not just
+-- pg_class.reloptions: dummy_table_am embeds a full StdRdOptions as the
+-- first member of its own options struct and sets
+-- TableAmRoutine.has_std_options_prefix, so RelationGetFillFactor() can read
+-- it directly instead of always seeing the hardcoded default.
+CREATE TABLE dummy_ff10 (a int) USING dummy_table_am WITH (fillfactor = 10);
+CREATE TABLE dummy_ff100 (a int) USING dummy_table_am WITH (fillfactor = 100);
+INSERT INTO dummy_ff10 SELECT generate_series(1, 5000);
+INSERT INTO dummy_ff100 SELECT generate_series(1, 5000);
+VACUUM dummy_ff10;
+VACUUM dummy_ff100;
+SELECT (SELECT relpages FROM pg_class WHERE oid = 'dummy_ff10'::regclass) >
+       (SELECT relpages FROM pg_class WHERE oid = 'dummy_ff100'::regclass)
+       AS low_fillfactor_uses_more_pages;
+DROP TABLE dummy_ff10;
+DROP TABLE dummy_ff100;
+
+-- A table with a toastable column works: dummy_table_am overrides
+-- relation_toast_am rather than inheriting heap's, which would return
+-- this AM's own oid instead of heap's for its TOAST table, making that
+-- TOAST table itself a dummy_table_am relation and failing as soon as
+-- its chunk_id/chunk_seq index was built (that scan goes through
+-- heap_getnext() directly, which requires a real heap relation).
+CREATE TABLE dummy_txt (a int, b text) USING dummy_table_am;
+INSERT INTO dummy_txt VALUES (1, repeat('x', 10000));
+SELECT a, length(b) FROM dummy_txt;
+DROP TABLE dummy_txt;
+
+-- Partitioned-table inheritance: AM declared on the parent partition flows
+-- to partitions that don't override it.  Partitioned tables themselves
+-- cannot carry reloptions; the test verifies the AM lookup that
+-- DefineRelation does for partitions.
+CREATE TABLE parted (a int) PARTITION BY RANGE (a) USING dummy_table_am;
+CREATE TABLE parted_p1 PARTITION OF parted FOR VALUES FROM (0) TO (100)
+    WITH (option_int = 11);
+SELECT c.relname,
+       (SELECT amname FROM pg_am WHERE oid = c.relam) AS amname,
+       c.reloptions
+    FROM pg_class c
+    WHERE c.oid IN ('parted'::regclass, 'parted_p1'::regclass)
+    ORDER BY c.relname;
+
+-- A partition that explicitly chooses heap must reject options that are
+-- only known to the parent's AM.
+CREATE TABLE parted_p2 PARTITION OF parted FOR VALUES FROM (100) TO (200)
+    USING heap WITH (option_int = 9);
+
+DROP TABLE parted;
+DROP TABLE heap_to_dt;
+DROP TABLE heap_t;
+DROP TABLE dummy_t;
+
+DROP EXTENSION dummy_table_am;
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 4bca42bb370..07b6b24a5ab 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -5,6 +5,7 @@ subdir('commit_ts')
 subdir('delay_execution')
 subdir('dummy_index_am')
 subdir('dummy_seclabel')
+subdir('dummy_table_am')
 subdir('gin')
 subdir('index')
 subdir('injection_points')
-- 
2.43.0



^ permalink  raw  reply  [nested|flat] 25+ messages in thread

* Re: Allow table AMs to define their own reloptions
@ 2026-08-28 19:43  Andrew Dunstan <andrew@dunslane.net>
  parent: Andrew Dunstan <andrew@dunslane.net>
  0 siblings, 1 reply; 25+ messages in thread

From: Andrew Dunstan @ 2026-08-28 19:43 UTC (permalink / raw)
  To: Junwang Zhao <zhjwpku@gmail.com>; Zsolt Parragi <zsolt.parragi@percona.com>; +Cc: pgsql-hackers@lists.postgresql.org, Rafia Sabih <rafia.pghackers@gmail.com>; Julien Tachoires <julien@tachoires.me>


rebased patch attached.


--
Andrew Dunstan
EDB: https://www.enterprisedb.com

Attachments:

  [text/x-patch] v6-0001-Add-amoptions-callback-to-table-access-methods.patch (59.8K, ../../974f3342-70e7-4770-8580-ba28f86e1892@dunslane.net/2-v6-0001-Add-amoptions-callback-to-table-access-methods.patch)
  download | inline diff:
From fd86567e79908221a82b735302c76fbfd2ff3e99 Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <andrew@dunslane.net>
Date: Tue, 12 May 2026 09:03:33 -0400
Subject: [PATCH v6 1/1] Add amoptions callback to table access methods

Table access methods have no way to define their own storage
parameters: CREATE/ALTER TABLE always parse the WITH clause with the
standard heap parser, regardless of the table's actual AM. Index AMs
have had this ability via IndexAmRoutine.amoptions for a long time;
give table AMs the same option.

TableAmRoutine gets an optional amoptions field, same signature as
the index AM version. table_reloptions() dispatches to it when set,
else falls back to heap_reloptions() as today. An AM that supplies
amoptions owns the option set entirely, and the bytea it returns,
stored verbatim in Relation->rd_options, dictates the layout its
other callbacks read.

DefineRelation, ATExecSetRelOptions, RelationParseRelOptions and
extractRelOptions all route through table_reloptions() for
RELKIND_RELATION/RELKIND_MATVIEW. The AM is resolved from the USING
clause, the partition parent, or default_table_access_method at
CREATE TABLE, and from rel->rd_tableam at ALTER TABLE SET -- except
a SET ACCESS METHOD queued in the same statement uses the new AM's
parser instead, so "SET ACCESS METHOD x, SET (foo = bar)" works when
foo belongs to x. SET ACCESS METHOD also revalidates the relation's
final reloptions against the new AM once other subcommands in the
statement have run, turning a reloption the new AM doesn't accept
into an immediate error instead of a silent drop at the next
relcache load; RESET in the same statement clears it.

Core code that reads StdRdOptions fields straight out of rd_options
(RelationGetFillFactor and friends, plus direct casts in
vacuum.c/index.c) now checks RelationHasStdRdOptions() first, since
an AM-owned bytea isn't guaranteed to be StdRdOptions-shaped. An AM
can opt back into all of these at once via
TableAmRoutine.has_std_options_prefix, set when its own struct embeds
a full StdRdOptions as its first member -- and it must register every
field these call sites read, not just the ones it cares about, since
an embedded field it never registers is left zeroed rather than at
its real default (wrong for e.g. parallel_workers, default -1).

add_reloption_to_kind(name, kind) extends an existing reloption's
kind bitmask, so an AM can accept a core-registered option
(fillfactor, autovacuum_*, ...) without redeclaring it; this is how
an AM fills in the embedded StdRdOptions fields above. One exception:
autovacuum's own scheduling always parses via the standard heap
parser regardless of AM, so it only ever sees the standard
autovacuum_* names, never an AM's own amoptions -- consulting each
relation's AM during autovacuum's periodic scan isn't worth a
catalog lookup per relation for a capability nothing needs.

src/test/modules/dummy_table_am demonstrates the API: it copies
heap's TableAmRoutine wholesale and overrides two callbacks.
amoptions parses a mix of inherited standard options and its own
into a struct embedding StdRdOptions. relation_toast_am also needs
overriding, since heap's default returns the calling relation's own
AM oid -- correct for heap, wrong for an AM that just inherits it,
whose TOAST tables would then belong to that AM instead of heap.
---
 doc/src/sgml/ref/alter_table.sgml             |  19 ++
 doc/src/sgml/tableam.sgml                     |  92 +++++++
 src/backend/access/common/reloptions.c        |  88 +++++-
 src/backend/catalog/index.c                   |   3 +-
 src/backend/commands/tablecmds.c              | 170 +++++++++++-
 src/backend/commands/vacuum.c                 |   3 +-
 src/backend/utils/cache/relcache.c            |   4 +-
 src/include/access/reloptions.h               |   3 +
 src/include/access/tableam.h                  |  60 +++++
 src/include/utils/rel.h                       |  29 +-
 src/test/modules/Makefile                     |   1 +
 src/test/modules/dummy_table_am/Makefile      |  20 ++
 src/test/modules/dummy_table_am/README        |  21 ++
 .../dummy_table_am/dummy_table_am--1.0.sql    |  13 +
 .../modules/dummy_table_am/dummy_table_am.c   | 253 ++++++++++++++++++
 .../dummy_table_am/dummy_table_am.control     |   5 +
 .../dummy_table_am/expected/reloptions.out    | 184 +++++++++++++
 src/test/modules/dummy_table_am/meson.build   |  33 +++
 .../modules/dummy_table_am/sql/reloptions.sql | 119 ++++++++
 src/test/modules/meson.build                  |   1 +
 20 files changed, 1109 insertions(+), 12 deletions(-)
 create mode 100644 src/test/modules/dummy_table_am/Makefile
 create mode 100644 src/test/modules/dummy_table_am/README
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am.c
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am.control
 create mode 100644 src/test/modules/dummy_table_am/expected/reloptions.out
 create mode 100644 src/test/modules/dummy_table_am/meson.build
 create mode 100644 src/test/modules/dummy_table_am/sql/reloptions.sql

diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index 0f9d698d170..4f19fb8f656 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -807,6 +807,25 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
       causing future partitions to default to
       <varname>default_table_access_method</varname>.
      </para>
+     <para>
+      The new access method must accept every storage parameter
+      currently set on the table.  An access method may define its own
+      set of parameters, so a parameter that was legal under the old
+      access method is not necessarily recognized by the new one; if any
+      such parameter remains, <command>ALTER TABLE</command> raises an
+      error rather than silently dropping the value.  The unwanted
+      parameters can be cleared in the same statement, for example:
+<programlisting>
+ALTER TABLE measurement
+    SET ACCESS METHOD columnar,
+    RESET (fillfactor);
+</programlisting>
+      Validation is performed once, after all storage-parameter
+      sub-commands in the statement have been applied, so the order of
+      <literal>SET</literal>, <literal>RESET</literal>, and
+      <literal>SET ACCESS METHOD</literal> within the same
+      <command>ALTER TABLE</command> does not matter.
+     </para>
     </listitem>
    </varlistentry>
 
diff --git a/doc/src/sgml/tableam.sgml b/doc/src/sgml/tableam.sgml
index 9ccf5b739ed..68d8fd62f44 100644
--- a/doc/src/sgml/tableam.sgml
+++ b/doc/src/sgml/tableam.sgml
@@ -152,4 +152,96 @@ my_tableam_handler(PG_FUNCTION_ARGS)
   its implementation.
  </para>
 
+ <sect1 id="tableam-reloptions">
+  <title>Table Access Method Storage Parameters</title>
+
+  <para>
+   A table access method may define its own set of storage parameters
+   (reloptions) by supplying an <structfield>amoptions</structfield>
+   callback in its <structname>TableAmRoutine</structname>.  The callback
+   has the same signature as the corresponding index AM callback; it is
+   invoked at <command>CREATE TABLE</command> and
+   <command>ALTER TABLE</command> time to parse and validate the option
+   set, and at relation open time (with <literal>validate = false</literal>)
+   to build the in-memory representation stored in
+   <structfield>Relation-&gt;rd_options</structfield>.  An AM that does not
+   supply an <structfield>amoptions</structfield> callback inherits the
+   standard heap parser and the <structname>StdRdOptions</structname>
+   layout.
+  </para>
+
+  <para>
+   When the AM provides its own parser it owns the option set entirely:
+   it may accept all standard heap options, only a subset, or define
+   parameters of its own.  The bytea returned from the callback is
+   stored verbatim in <structfield>rd_options</structfield>, so the AM
+   also dictates the in-memory layout that its other callbacks read.
+  </para>
+
+  <para>
+   The parser is expected to validate user-supplied values, but
+   <emphasis>must not silently rewrite them</emphasis>.  In particular
+   it must not coerce out-of-range values to a default, drop unknown
+   options when <literal>validate = true</literal>, or substitute a
+   different unit; the user must be able to verify with
+   <command>SELECT reloptions FROM pg_class</command> that the values
+   they supplied are what the relation will use.  Out-of-range or
+   unknown options should be reported with
+   <function>ereport(ERROR)</function>.
+  </para>
+
+  <para>
+   To honour an option that the core code already registers for
+   <literal>RELOPT_KIND_HEAP</literal> (for example
+   <literal>fillfactor</literal> or the <literal>autovacuum_*</literal>
+   family), call <function>add_reloption_to_kind()</function> once per
+   option in the module's <function>_PG_init</function>.  This extends
+   the existing registration with the AM's own kind without forcing
+   the AM to re-declare each option.
+  </para>
+
+  <para>
+   The <literal>autovacuum_*</literal> family is a special case, and
+   reusing the standard names is the only way to reach autovacuum's own
+   scheduling logic with them at all: every field of
+   <structname>AutoVacOpts</structname> (whether autovacuum runs on the
+   table, its vacuum/analyze thresholds and scale factors, freeze ages,
+   cost delay and limit, log-duration settings, and so on) is extracted
+   by autovacuum's periodic scan of <structname>pg_class</structname>
+   using the standard heap parser unconditionally, never the relation's
+   own <structfield>amoptions</structfield> callback -- looking up and
+   calling the AM's own parser for every relation on every autovacuum
+   cycle would add a catalog lookup to a hot path for no AM that
+   currently needs it.  This is independent of
+   <structfield>has_std_options_prefix</structfield>: it applies even to an
+   AM whose reloptions struct does not embed
+   <structname>StdRdOptions</structname> at all.  A table AM can only
+   affect autovacuum's own scheduling by exposing these standard
+   <literal>autovacuum_*</literal> names via
+   <function>add_reloption_to_kind()</function>; a differently-named
+   option of its own is stored and readable from
+   <structfield>Relation-&gt;rd_options</structfield> like any other
+   AM-specific option, but autovacuum's scheduling logic will never see
+   it.
+  </para>
+
+  <para>
+   <command>ALTER TABLE ... SET ACCESS METHOD</command> revalidates the
+   relation's current storage parameters against the new access
+   method's parser after all <literal>SET</literal>,
+   <literal>RESET</literal>, and <literal>REPLACE</literal>
+   sub-commands in the same statement have been applied.  A parameter
+   that is not accepted by the new AM raises an error; the user can
+   clear such parameters in the same statement (see <xref
+   linkend="sql-altertable"/>).
+  </para>
+
+  <para>
+   See <filename>src/test/modules/dummy_table_am</filename> for a
+   minimal example that exercises both
+   <structfield>amoptions</structfield> and
+   <function>add_reloption_to_kind()</function>.
+  </para>
+ </sect1>
+
 </chapter>
diff --git a/src/backend/access/common/reloptions.c b/src/backend/access/common/reloptions.c
index c3f6f45f460..c062135dfac 100644
--- a/src/backend/access/common/reloptions.c
+++ b/src/backend/access/common/reloptions.c
@@ -24,6 +24,7 @@
 #include "access/nbtree.h"
 #include "access/reloptions.h"
 #include "access/spgist_private.h"
+#include "access/tableam.h"
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "commands/tablespace.h"
@@ -823,6 +824,44 @@ add_reloption_kind(void)
 	return (relopt_kind) last_assigned_kind;
 }
 
+/*
+ * add_reloption_to_kind
+ *		Extend an already-registered reloption so it is also accepted for
+ *		the given kind.
+ *
+ * Useful for table access methods that want their own RELOPT_KIND_*
+ * parser to accept standard options (fillfactor, parallel_workers,
+ * autovacuum_*, etc.) that core registers only for RELOPT_KIND_HEAP.
+ * Without this, every AM that wants the standard option set would
+ * have to re-register each option under its own kind.
+ *
+ * 'name' must match an existing option; 'kind' is OR'ed into that
+ * option's kinds mask.  Errors if no option with that name exists.
+ */
+void
+add_reloption_to_kind(const char *name, relopt_kind kind)
+{
+	int			namelen = strlen(name);
+	int			i;
+
+	if (need_initialization)
+		initialize_reloptions();
+
+	for (i = 0; relOpts[i]; i++)
+	{
+		if (relOpts[i]->namelen == namelen &&
+			strncmp(relOpts[i]->name, name, namelen) == 0)
+		{
+			relOpts[i]->kinds |= kind;
+			return;
+		}
+	}
+
+	ereport(ERROR,
+			(errcode(ERRCODE_UNDEFINED_OBJECT),
+			 errmsg("reloption \"%s\" does not exist", name)));
+}
+
 /*
  * add_reloption
  *		Add an already-created custom reloption to the list, and recompute the
@@ -1589,8 +1628,11 @@ extractRelOptions(HeapTuple tuple, TupleDesc tupdesc,
 	switch (classForm->relkind)
 	{
 		case RELKIND_RELATION:
-		case RELKIND_TOASTVALUE:
 		case RELKIND_MATVIEW:
+			options = table_reloptions(amoptions, classForm->relkind,
+									   datum, false);
+			break;
+		case RELKIND_TOASTVALUE:
 			options = heap_reloptions(classForm->relkind, datum, false);
 			break;
 		case RELKIND_PARTITIONED_TABLE:
@@ -2368,6 +2410,50 @@ heap_reloptions(char relkind, Datum reloptions, bool validate)
 	}
 }
 
+/*
+ * Parse options for a table relation, dispatching to the access method's
+ * own option parser when it supplies one.
+ *
+ *	amoptions	the table AM's option parser, or NULL to fall back to the
+ *				standard heap parser for this relkind.
+ *	relkind		the relation's kind.
+ *	reloptions	options as a text[] datum.
+ *	validate	error flag for unknown options or bad values.
+ *
+ * When amoptions is non-NULL the AM owns the option set: it may accept
+ * all standard heap options, only a subset, or define its own.  The
+ * returned bytea is laid out as the AM dictates (it is stored verbatim
+ * in Relation->rd_options).  When amoptions is NULL the result is the
+ * standard StdRdOptions layout.
+ */
+bytea *
+table_reloptions(amoptions_function amoptions, char relkind,
+				 Datum reloptions, bool validate)
+{
+	if (amoptions != NULL)
+		return amoptions(reloptions, validate);
+	return heap_reloptions(relkind, reloptions, validate);
+}
+
+/*
+ * Returns true when the relation's rd_options buffer is laid out as
+ * StdRdOptions.  The rel.h accessor macros (RelationGetFillFactor,
+ * RelationIsUsedAsCatalogTable, ...) check this first before casting
+ * rd_options to StdRdOptions, so that a table access method which supplies
+ * its own amoptions callback (and therefore owns the rd_options layout)
+ * does not have its bytes misinterpreted.
+ */
+bool
+RelationHasStdRdOptions(Relation relation)
+{
+	if (relation->rd_options == NULL)
+		return false;
+	if (relation->rd_tableam == NULL)
+		return false;
+	return relation->rd_tableam->amoptions == NULL ||
+		relation->rd_tableam->has_std_options_prefix;
+}
+
 
 /*
  * Parse options for indexes.
diff --git a/src/backend/catalog/index.c b/src/backend/catalog/index.c
index ec21b83b6b8..7bf5652b4a4 100644
--- a/src/backend/catalog/index.c
+++ b/src/backend/catalog/index.c
@@ -2898,7 +2898,8 @@ index_update_stats(Relation rel,
 	{
 		if (AutoVacuumingActive())
 		{
-			StdRdOptions *options = (StdRdOptions *) rel->rd_options;
+			StdRdOptions *options = RelationHasStdRdOptions(rel) ?
+				(StdRdOptions *) rel->rd_options : NULL;
 
 			if (options != NULL &&
 				options->autovacuum.enabled == PG_TERNARY_FALSE)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index fd144d783d9..d47588aa20b 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -702,9 +702,11 @@ static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
 								const char *tablespacename, LOCKMODE lockmode);
 static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
 static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
+static void ATValidateAccessMethodOptions(List **wqueue);
 static void ATExecSetRelOptions(Relation rel, List *defList,
 								AlterTableType operation,
-								LOCKMODE lockmode);
+								LOCKMODE lockmode,
+								Oid newAccessMethodId);
 static void ATExecEnableDisableTrigger(Relation rel, const char *trigname,
 									   char fires_when, bool skip_system, bool recurse,
 									   LOCKMODE lockmode);
@@ -965,6 +967,41 @@ DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId,
 		case RELKIND_PARTITIONED_TABLE:
 			(void) partitioned_table_reloptions(reloptions, true);
 			break;
+		case RELKIND_RELATION:
+		case RELKIND_MATVIEW:
+			{
+				amoptions_function amoptions = NULL;
+				Oid			amoid = InvalidOid;
+
+				/*
+				 * Resolve the table AM so its option parser can validate
+				 * AM-specific reloptions.  An AM that does not register a
+				 * parser falls back to default_reloptions for
+				 * RELOPT_KIND_HEAP.
+				 */
+				if (stmt->accessMethod != NULL)
+					amoid = get_table_am_oid(stmt->accessMethod, false);
+				else if (stmt->partbound != NULL && inheritOids != NIL)
+					amoid = get_rel_relam(linitial_oid(inheritOids));
+				else
+					amoid = get_table_am_oid(default_table_access_method, false);
+
+				if (OidIsValid(amoid))
+				{
+					HeapTuple	tuple;
+
+					tuple = SearchSysCache1(AMOID, ObjectIdGetDatum(amoid));
+					if (HeapTupleIsValid(tuple))
+					{
+						Form_pg_am	amform = (Form_pg_am) GETSTRUCT(tuple);
+
+						amoptions = GetTableAmRoutine(amform->amhandler)->amoptions;
+						ReleaseSysCache(tuple);
+					}
+				}
+				(void) table_reloptions(amoptions, relkind, reloptions, true);
+			}
+			break;
 		default:
 			(void) heap_reloptions(relkind, reloptions, true);
 	}
@@ -4957,6 +4994,18 @@ ATController(AlterTableStmt *parsetree,
 	/* Phase 2: update system catalogs */
 	ATRewriteCatalogs(&wqueue, lockmode, context);
 
+	/*
+	 * After all phase-2 subcommands have committed any SET / RESET / REPLACE
+	 * option changes to pg_class, but before any rewrite, ensure the final
+	 * reloptions are accepted by the access method the relation will use once
+	 * the ALTER TABLE finishes.  This catches the case where SET ACCESS
+	 * METHOD changes the AM and leaves pre-existing reloptions in pg_class
+	 * that the new AM does not recognise; without this check the new AM's
+	 * option parser would be called with validate=false at relcache load time
+	 * and silently ignore them.
+	 */
+	ATValidateAccessMethodOptions(&wqueue);
+
 	/* Phase 3: scan/rewrite tables as needed, and run afterStmts */
 	ATRewriteTables(parsetree, &wqueue, lockmode, context);
 }
@@ -5622,7 +5671,17 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
 		case AT_SetRelOptions:	/* SET (...) */
 		case AT_ResetRelOptions:	/* RESET (...) */
 		case AT_ReplaceRelOptions:	/* replace entire option list */
-			ATExecSetRelOptions(rel, (List *) cmd->def, cmd->subtype, lockmode);
+
+			/*
+			 * If SET ACCESS METHOD is queued in the same ALTER TABLE, the
+			 * reloptions in pg_class will be parsed by the new AM after the
+			 * statement finishes; tell ATExecSetRelOptions to validate
+			 * against that AM rather than the relation's current AM.  This
+			 * lets a user write ALTER TABLE t SET ACCESS METHOD x, SET (foo =
+			 * bar) where foo is recognised by x but not by the current AM.
+			 */
+			ATExecSetRelOptions(rel, (List *) cmd->def, cmd->subtype, lockmode,
+								tab->chgAccessMethod ? tab->newAccessMethod : InvalidOid);
 			break;
 		case AT_EnableTrig:		/* ENABLE TRIGGER name */
 			ATExecEnableDisableTrigger(rel, cmd->name,
@@ -17314,12 +17373,92 @@ ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel, const char *tablespacen
 	tab->newTableSpace = tablespaceId;
 }
 
+/*
+ * Re-validate pg_class.reloptions for every work-queue entry whose access
+ * method is being changed.  Called between phase 2 (catalog updates) and
+ * phase 3 (table rewrites): SET / RESET / REPLACE subcommands have already
+ * been committed to pg_class, and tab->newAccessMethod identifies the AM
+ * the relation will use once the ALTER TABLE finishes.
+ *
+ * The check exists because relcache.c calls the AM's option parser with
+ * validate=false at relation open: any pre-existing reloption that the
+ * new AM does not recognise would otherwise be silently dropped from the
+ * parsed StdRdOptions / AM-specific options struct, leaving the user
+ * unable to tell that the option is no longer in effect.  Failing the
+ * ALTER TABLE here with a clear message lets the user RESET the option
+ * in the same statement and re-run.
+ */
+static void
+ATValidateAccessMethodOptions(List **wqueue)
+{
+	ListCell   *ltab;
+
+	foreach(ltab, *wqueue)
+	{
+		AlteredTableInfo *tab = (AlteredTableInfo *) lfirst(ltab);
+		HeapTuple	amtup;
+		HeapTuple	reltup;
+		Form_pg_am	amform;
+		Form_pg_class relform;
+		amoptions_function amoptions;
+		Datum		reloptions;
+		bool		isnull;
+		Oid			amoid;
+
+		if (!tab->chgAccessMethod)
+			continue;
+
+		/*
+		 * Partitioned tables may reset the AM to "default" (InvalidOid); each
+		 * partition then chooses its own AM at create time, so there is no
+		 * per-relation AM whose parser to consult here.
+		 */
+		amoid = tab->newAccessMethod;
+		if (!OidIsValid(amoid))
+			continue;
+
+		amtup = SearchSysCache1(AMOID, ObjectIdGetDatum(amoid));
+		if (!HeapTupleIsValid(amtup))
+			elog(ERROR, "cache lookup failed for access method %u", amoid);
+		amform = (Form_pg_am) GETSTRUCT(amtup);
+		amoptions = GetTableAmRoutine(amform->amhandler)->amoptions;
+		ReleaseSysCache(amtup);
+
+		/*
+		 * If the new AM has no option parser of its own, table_reloptions
+		 * falls back to the standard heap parser, which accepts whatever the
+		 * old AM accepted (every other AM in core uses the same StdRdOptions
+		 * today), so there is nothing to re-check.
+		 */
+		if (amoptions == NULL)
+			continue;
+
+		reltup = SearchSysCache1(RELOID, ObjectIdGetDatum(tab->relid));
+		if (!HeapTupleIsValid(reltup))
+			elog(ERROR, "cache lookup failed for relation %u", tab->relid);
+		relform = (Form_pg_class) GETSTRUCT(reltup);
+		reloptions = SysCacheGetAttr(RELOID, reltup,
+									 Anum_pg_class_reloptions, &isnull);
+		if (!isnull)
+			(void) table_reloptions(amoptions, relform->relkind,
+									reloptions, true);
+		ReleaseSysCache(reltup);
+	}
+}
+
 /*
  * Set, reset, or replace reloptions.
+ *
+ * newAccessMethodId, if valid, names the table access method whose option
+ * parser should validate the resulting reloptions.  This is used when SET
+ * ACCESS METHOD is queued in the same ALTER TABLE so that the new options
+ * are checked against the AM the relation will use after the statement
+ * finishes, not the AM it has now.  Pass InvalidOid to use the relation's
+ * current access method.
  */
 static void
 ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
-					LOCKMODE lockmode)
+					LOCKMODE lockmode, Oid newAccessMethodId)
 {
 	Oid			relid;
 	Relation	pgclass;
@@ -17371,7 +17510,30 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
 	{
 		case RELKIND_RELATION:
 		case RELKIND_MATVIEW:
-			(void) heap_reloptions(rel->rd_rel->relkind, newOptions, true);
+			{
+				amoptions_function amoptions;
+
+				if (OidIsValid(newAccessMethodId))
+				{
+					HeapTuple	amtup;
+					Form_pg_am	amform;
+
+					amtup = SearchSysCache1(AMOID,
+											ObjectIdGetDatum(newAccessMethodId));
+					if (!HeapTupleIsValid(amtup))
+						elog(ERROR, "cache lookup failed for access method %u",
+							 newAccessMethodId);
+					amform = (Form_pg_am) GETSTRUCT(amtup);
+					amoptions = GetTableAmRoutine(amform->amhandler)->amoptions;
+					ReleaseSysCache(amtup);
+				}
+				else
+					amoptions = (rel->rd_tableam ?
+								 rel->rd_tableam->amoptions : NULL);
+
+				(void) table_reloptions(amoptions, rel->rd_rel->relkind,
+										newOptions, true);
+			}
 			break;
 		case RELKIND_PARTITIONED_TABLE:
 			(void) partitioned_table_reloptions(newOptions, true);
diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c
index 72c7f680a84..f80182a8809 100644
--- a/src/backend/commands/vacuum.c
+++ b/src/backend/commands/vacuum.c
@@ -2211,7 +2211,8 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 	 * whose parameters the caller handed down for that purpose.  For anything
 	 * else, params.main_relopts is NULL, and this just copies our own.
 	 */
-	relopts = merge_toast_reloptions((StdRdOptions *) rel->rd_options,
+	relopts = merge_toast_reloptions(RelationHasStdRdOptions(rel) ?
+									 (StdRdOptions *) rel->rd_options : NULL,
 									 params.main_relopts);
 
 	/*
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index f475d703977..b69d2a9ccc2 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -483,9 +483,11 @@ RelationParseRelOptions(Relation relation, HeapTuple tuple)
 	switch (relation->rd_rel->relkind)
 	{
 		case RELKIND_RELATION:
+		case RELKIND_MATVIEW:
+			amoptsfn = relation->rd_tableam ? relation->rd_tableam->amoptions : NULL;
+			break;
 		case RELKIND_TOASTVALUE:
 		case RELKIND_VIEW:
-		case RELKIND_MATVIEW:
 		case RELKIND_PARTITIONED_TABLE:
 			amoptsfn = NULL;
 			break;
diff --git a/src/include/access/reloptions.h b/src/include/access/reloptions.h
index ccff4717b62..dc07f78c470 100644
--- a/src/include/access/reloptions.h
+++ b/src/include/access/reloptions.h
@@ -187,6 +187,7 @@ typedef struct local_relopts
 	 (char *)(optstruct) + (optstruct)->member)
 
 extern relopt_kind add_reloption_kind(void);
+extern void add_reloption_to_kind(const char *name, relopt_kind kind);
 extern void add_bool_reloption(uint32 kinds, const char *name, const char *desc,
 							   bool default_val, LOCKMODE lockmode);
 extern void add_ternary_reloption(uint32 kinds, const char *name,
@@ -250,6 +251,8 @@ extern bytea *default_reloptions(Datum reloptions, bool validate,
 extern struct StdRdOptions *merge_toast_reloptions(const struct StdRdOptions *toast_opts,
 												   const struct StdRdOptions *main_opts);
 extern bytea *heap_reloptions(char relkind, Datum reloptions, bool validate);
+extern bytea *table_reloptions(amoptions_function amoptions, char relkind,
+							   Datum reloptions, bool validate);
 extern bytea *view_reloptions(Datum reloptions, bool validate);
 extern bytea *partitioned_table_reloptions(Datum reloptions, bool validate);
 extern bytea *index_reloptions(amoptions_function amoptions, Datum reloptions,
diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h
index ff03a2b816f..6dbc7156f18 100644
--- a/src/include/access/tableam.h
+++ b/src/include/access/tableam.h
@@ -17,6 +17,7 @@
 #ifndef TABLEAM_H
 #define TABLEAM_H
 
+#include "access/amapi.h"
 #include "access/relscan.h"
 #include "access/sdir.h"
 #include "access/xact.h"
@@ -324,6 +325,65 @@ typedef struct TableAmRoutine
 	NodeTag		type;
 
 
+	/* ------------------------------------------------------------------------
+	 * Reloption parsing.
+	 * ------------------------------------------------------------------------
+	 */
+
+	/*
+	 * Parse and validate AM-specific reloptions.  Optional: when NULL, the
+	 * caller falls back to the standard heap reloption parser
+	 * (default_reloptions with RELOPT_KIND_HEAP) and the result is laid out
+	 * as StdRdOptions.
+	 *
+	 * When non-NULL, the AM owns the option set entirely.  It is free to
+	 * accept all standard heap options, only a subset, or to add its own. The
+	 * returned bytea must begin with a VARSIZE header and is stored in
+	 * Relation->rd_options, so the AM dictates the in-memory layout that its
+	 * other callbacks read.  Core code that reads StdRdOptions fields out of
+	 * rd_options (RelationGetFillFactor, RelationIsUsedAsCatalogTable, ...)
+	 * checks RelationHasStdRdOptions() first, so a custom layout will not be
+	 * misinterpreted -- unless the AM sets has_std_options_prefix below to
+	 * declare that its struct is a StdRdOptions superset after all.
+	 *
+	 * The callback validates user-supplied values but must not silently
+	 * rewrite them: a user inspecting pg_class.reloptions must see exactly
+	 * what they passed in.  Out-of-range or unknown options should be
+	 * reported with ereport(ERROR) when validate is true.
+	 *
+	 * Signature matches the index AM's amoptions callback so the same helper
+	 * machinery (add_string_reloption, add_int_reloption, etc.) can be used.
+	 */
+	amoptions_function amoptions;
+
+	/*
+	 * Set to true when amoptions is non-NULL and the bytea it returns
+	 * begins with a full "StdRdOptions std;" as its first member (i.e. the
+	 * AM's own reloptions struct is a superset of StdRdOptions, not just a
+	 * layout that happens to share a prefix).  This tells core code that it
+	 * is safe to read StdRdOptions fields directly out of rd_options
+	 * (RelationGetFillFactor, RelationIsUsedAsCatalogTable, ...) for
+	 * relations of this AM, exactly as it would for plain heap.
+	 *
+	 * An AM that sets this must register every StdRdOptions field those
+	 * macros read (fillfactor, toast_tuple_target, user_catalog_table,
+	 * parallel_workers, vacuum_index_cleanup, vacuum_truncate,
+	 * vacuum_max_eager_freeze_failure_rate, autovacuum_enabled) with
+	 * add_reloption_to_kind(), even if it exposes none of them as options
+	 * the AM cares about itself: build_reloptions() only fills in fields
+	 * that are registered for the AM's relopt_kind, so an embedded
+	 * StdRdOptions field the AM never registers is left zeroed rather than
+	 * at that option's real default (0 is not a valid "unset" sentinel for
+	 * several of these fields, e.g. parallel_workers and
+	 * vacuum_max_eager_freeze_failure_rate both use -1).  Registering the
+	 * field via add_reloption_to_kind lets each one pick up its normal
+	 * catalog default instead.
+	 *
+	 * Ignored when amoptions is NULL.
+	 */
+	bool		has_std_options_prefix;
+
+
 	/* ------------------------------------------------------------------------
 	 * Slot related callbacks.
 	 * ------------------------------------------------------------------------
diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h
index 41ab4586c6b..73f9092f3d5 100644
--- a/src/include/utils/rel.h
+++ b/src/include/utils/rel.h
@@ -362,12 +362,33 @@ typedef struct StdRdOptions
 #define HEAP_MIN_FILLFACTOR			10
 #define HEAP_DEFAULT_FILLFACTOR		100
 
+/*
+ * RelationHasStdRdOptions
+ *		Returns true when the relation's rd_options buffer is safe to read
+ *		as StdRdOptions: either it was produced by the standard heap
+ *		reloption parser (the AM has no amoptions callback), or the AM's
+ *		own amoptions callback returns a struct that embeds a full
+ *		StdRdOptions as its first member and says so via
+ *		TableAmRoutine.has_std_options_prefix.  A table access method that
+ *		supplies amoptions without setting that flag owns its rd_options
+ *		layout entirely and is not required to expose StdRdOptions fields;
+ *		macros that read those fields must check this first to avoid
+ *		reading garbage, or past the end of a smaller custom struct.  For
+ *		indexes and other relkinds rd_options is in an AM-specific layout,
+ *		so this returns false for them.
+ *
+ *		Defined as a function (in reloptions.c) rather than a macro
+ *		because the test needs the full TableAmRoutine struct definition,
+ *		which would create an #include cycle if pulled into rel.h.
+ */
+extern bool RelationHasStdRdOptions(Relation relation);
+
 /*
  * RelationGetToastTupleTarget
  *		Returns the relation's toast_tuple_target.  Note multiple eval of argument!
  */
 #define RelationGetToastTupleTarget(relation, defaulttarg) \
-	((relation)->rd_options ? \
+	(RelationHasStdRdOptions(relation) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->toast_tuple_target : (defaulttarg))
 
 /*
@@ -375,7 +396,7 @@ typedef struct StdRdOptions
  *		Returns the relation's fillfactor.  Note multiple eval of argument!
  */
 #define RelationGetFillFactor(relation, defaultff) \
-	((relation)->rd_options ? \
+	(RelationHasStdRdOptions(relation) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->fillfactor : (defaultff))
 
 /*
@@ -398,7 +419,7 @@ typedef struct StdRdOptions
  *		from the pov of logical decoding.  Note multiple eval of argument!
  */
 #define RelationIsUsedAsCatalogTable(relation)	\
-	((relation)->rd_options && \
+	(RelationHasStdRdOptions(relation) && \
 	 ((relation)->rd_rel->relkind == RELKIND_RELATION || \
 	  (relation)->rd_rel->relkind == RELKIND_MATVIEW) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->user_catalog_table : false)
@@ -409,7 +430,7 @@ typedef struct StdRdOptions
  *		Note multiple eval of argument!
  */
 #define RelationGetParallelWorkers(relation, defaultpw) \
-	((relation)->rd_options ? \
+	(RelationHasStdRdOptions(relation) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->parallel_workers : (defaultpw))
 
 /* ViewOptions->check_option values */
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index bb88b3058ed..bfeeeba4ce1 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -10,6 +10,7 @@ SUBDIRS = \
 		  delay_execution \
 		  dummy_index_am \
 		  dummy_seclabel \
+		  dummy_table_am \
 		  index \
 		  libpq_pipeline \
 		  oauth_validator \
diff --git a/src/test/modules/dummy_table_am/Makefile b/src/test/modules/dummy_table_am/Makefile
new file mode 100644
index 00000000000..94837dff392
--- /dev/null
+++ b/src/test/modules/dummy_table_am/Makefile
@@ -0,0 +1,20 @@
+# src/test/modules/dummy_table_am/Makefile
+
+MODULES = dummy_table_am
+
+EXTENSION = dummy_table_am
+DATA = dummy_table_am--1.0.sql
+PGFILEDESC = "dummy_table_am - table access method template"
+
+REGRESS = reloptions
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/dummy_table_am
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/dummy_table_am/README b/src/test/modules/dummy_table_am/README
new file mode 100644
index 00000000000..a234a1f107f
--- /dev/null
+++ b/src/test/modules/dummy_table_am/README
@@ -0,0 +1,21 @@
+Dummy Table AM
+==============
+
+Dummy table AM is a module for testing the table access method
+amoptions callback and the add_reloption_to_kind() helper.  It
+delegates all storage and scan callbacks to the heap AM and only
+swaps in its own option parser, so a relation created with USING
+dummy_table_am behaves like a heap table but accepts a different
+set of reloptions:
+
+  - "fillfactor"     (inherited from the core heap registration via
+                      add_reloption_to_kind)
+  - "option_int"     (integer)
+  - "option_real"    (real)
+  - "option_bool"    (boolean)
+  - "option_enum"    (enum, one|two)
+
+Standard heap options such as parallel_workers, autovacuum_*, and
+toast_tuple_target are intentionally NOT accepted, to exercise the
+"AM rejects an unknown option" path in ALTER TABLE ... SET ACCESS
+METHOD revalidation.
diff --git a/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql b/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
new file mode 100644
index 00000000000..2e295b95845
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
@@ -0,0 +1,13 @@
+/* src/test/modules/dummy_table_am/dummy_table_am--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION dummy_table_am" to load this file. \quit
+
+CREATE FUNCTION dthandler(internal)
+RETURNS table_am_handler
+AS 'MODULE_PATHNAME'
+LANGUAGE C;
+
+-- Access method
+CREATE ACCESS METHOD dummy_table_am TYPE TABLE HANDLER dthandler;
+COMMENT ON ACCESS METHOD dummy_table_am IS 'dummy table access method';
diff --git a/src/test/modules/dummy_table_am/dummy_table_am.c b/src/test/modules/dummy_table_am/dummy_table_am.c
new file mode 100644
index 00000000000..1fba6057675
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am.c
@@ -0,0 +1,253 @@
+/*-------------------------------------------------------------------------
+ *
+ * dummy_table_am.c
+ *		Table AM template main file.
+ *
+ * This module exists primarily to demonstrate and exercise the table AM
+ * amoptions callback and the add_reloption_to_kind() helper.  Storage
+ * and scan callbacks are delegated to the heap AM, so a relation
+ * created with USING dummy_table_am behaves like a heap table; only the
+ * reloption surface differs.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/test/modules/dummy_table_am/dummy_table_am.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/reloptions.h"
+#include "access/tableam.h"
+#include "catalog/pg_am_d.h"
+#include "fmgr.h"
+#include "utils/rel.h"
+
+PG_MODULE_MAGIC;
+
+/* Parse table for build_reloptions: 8 inherited standard options + 4 of our own */
+static relopt_parse_elt dt_relopt_tab[12];
+
+/* Kind of relation options for dummy table */
+static relopt_kind dt_relopt_kind;
+
+typedef enum DummyTableEnum
+{
+	DUMMY_TABLE_ENUM_ONE,
+	DUMMY_TABLE_ENUM_TWO,
+}			DummyTableEnum;
+
+/*
+ * Dummy table options.
+ *
+ * "std" embeds the full standard heap layout: every option that heap's own
+ * default_reloptions() understands (fillfactor, toast_tuple_target,
+ * parallel_workers, vacuum_index_cleanup, vacuum_truncate,
+ * vacuum_max_eager_freeze_failure_rate, autovacuum_enabled,
+ * user_catalog_table) is inherited by calling add_reloption_to_kind() on
+ * the matching name, and each is registered here so that build_reloptions()
+ * fills it with the option's own catalog default when the user does not
+ * set it explicitly -- an embedded StdRdOptions field that is never
+ * registered stays zeroed, which is not the right "unset" value for
+ * several of these (e.g. parallel_workers and
+ * vacuum_max_eager_freeze_failure_rate both use -1).  Because "std" is a
+ * full StdRdOptions and every one of its fields is populated this way, we
+ * can tell core code it's safe to keep reading them directly by setting
+ * TableAmRoutine.has_std_options_prefix (see dthandler() below).
+ *
+ * The remaining four are AM-specific options that only dummy_table_am
+ * knows about.
+ */
+typedef struct DummyTableOptions
+{
+	StdRdOptions std;			/* must be first, see above */
+	int			option_int;
+	double		option_real;
+	bool		option_bool;
+	DummyTableEnum option_enum;
+}			DummyTableOptions;
+
+static relopt_enum_elt_def dummyTableEnumValues[] =
+{
+	{"one", DUMMY_TABLE_ENUM_ONE},
+	{"two", DUMMY_TABLE_ENUM_TWO},
+	{(const char *) NULL}		/* list terminator */
+};
+
+PG_FUNCTION_INFO_V1(dthandler);
+
+/*
+ * Register a relopt_kind for this AM and populate the parse table.
+ */
+static void
+create_reloptions_table(void)
+{
+	int			i = 0;
+
+	dt_relopt_kind = add_reloption_kind();
+
+	/*
+	 * Accept every standard option that core's default_reloptions()
+	 * understands (registered for RELOPT_KIND_HEAP and/or RELOPT_KIND_TOAST)
+	 * under our own kind.  This is the canonical use of
+	 * add_reloption_to_kind(): an AM that wants to honour existing
+	 * core-registered options without duplicating their definitions.  See
+	 * the comment on DummyTableOptions.std for why we register all of
+	 * them rather than just fillfactor.
+	 */
+	add_reloption_to_kind("fillfactor", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "fillfactor";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.fillfactor);
+	i++;
+
+	add_reloption_to_kind("toast_tuple_target", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "toast_tuple_target";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.toast_tuple_target);
+	i++;
+
+	add_reloption_to_kind("parallel_workers", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "parallel_workers";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.parallel_workers);
+	i++;
+
+	add_reloption_to_kind("vacuum_index_cleanup", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "vacuum_index_cleanup";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_ENUM;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.vacuum_index_cleanup);
+	i++;
+
+	add_reloption_to_kind("vacuum_truncate", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "vacuum_truncate";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_TERNARY;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.vacuum_truncate);
+	i++;
+
+	add_reloption_to_kind("vacuum_max_eager_freeze_failure_rate", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "vacuum_max_eager_freeze_failure_rate";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_REAL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.vacuum_max_eager_freeze_failure_rate);
+	i++;
+
+	add_reloption_to_kind("autovacuum_enabled", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "autovacuum_enabled";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_BOOL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.autovacuum.enabled);
+	i++;
+
+	add_reloption_to_kind("user_catalog_table", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "user_catalog_table";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_BOOL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.user_catalog_table);
+	i++;
+
+	add_int_reloption(dt_relopt_kind, "option_int",
+					  "Integer option for dummy_table_am",
+					  10, -10, 100, AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_int";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_int);
+	i++;
+
+	add_real_reloption(dt_relopt_kind, "option_real",
+					   "Real option for dummy_table_am",
+					   3.1415, -10, 100, AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_real";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_REAL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_real);
+	i++;
+
+	add_bool_reloption(dt_relopt_kind, "option_bool",
+					   "Boolean option for dummy_table_am",
+					   true, AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_bool";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_BOOL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_bool);
+	i++;
+
+	add_enum_reloption(dt_relopt_kind, "option_enum",
+					   "Enum option for dummy_table_am",
+					   dummyTableEnumValues,
+					   DUMMY_TABLE_ENUM_ONE,
+					   "Valid values are \"one\" and \"two\".",
+					   AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_enum";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_ENUM;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_enum);
+	i++;
+}
+
+/*
+ * Parse reloptions for dummy_table_am.
+ *
+ * Returning DummyTableOptions tells the caller (relcache.c) to store
+ * exactly that layout in Relation->rd_options.
+ */
+static bytea *
+dtoptions(Datum reloptions, bool validate)
+{
+	return (bytea *) build_reloptions(reloptions, validate,
+									  dt_relopt_kind,
+									  sizeof(DummyTableOptions),
+									  dt_relopt_tab, lengthof(dt_relopt_tab));
+}
+
+/*
+ * heapam_relation_toast_am() (heap's own relation_toast_am callback, which
+ * we would otherwise inherit unchanged along with the rest of heap's
+ * routine) returns rel->rd_rel->relam -- correct for a real heap table, but
+ * for dummy_table_am that's dummy_table_am's own oid, not heap's.  That
+ * would make this AM's TOAST tables dummy_table_am relations too, and
+ * building their chunk_id/chunk_seq index fails as soon as it's scanned,
+ * since that scan goes through heap_getnext() directly.  Override it to
+ * return the literal heap AM oid: this AM's TOAST tables are always plain
+ * heap, regardless of what created the owning table.
+ */
+static Oid
+dummy_table_relation_toast_am(Relation rel)
+{
+	return HEAP_TABLE_AM_OID;
+}
+
+/*
+ * Handler for table AM.
+ *
+ * All storage-side callbacks are inherited from heap; we swap in our own
+ * amoptions so that the AM owns its reloption set, and our own
+ * relation_toast_am (see dummy_table_relation_toast_am() above).  This
+ * keeps the example focused on the new API without duplicating the heap
+ * AM.
+ *
+ * has_std_options_prefix is set because DummyTableOptions embeds a full
+ * StdRdOptions as its first member with every field populated (see the
+ * comment on DummyTableOptions): that makes it safe for core code to keep
+ * reading fillfactor and friends directly out of rd_options, exactly as
+ * it would for a plain heap table.
+ */
+Datum
+dthandler(PG_FUNCTION_ARGS)
+{
+	static TableAmRoutine routine;
+	static bool initialized = false;
+
+	if (!initialized)
+	{
+		memcpy(&routine, GetHeapamTableAmRoutine(), sizeof(routine));
+		routine.amoptions = dtoptions;
+		routine.has_std_options_prefix = true;
+		routine.relation_toast_am = dummy_table_relation_toast_am;
+		initialized = true;
+	}
+
+	PG_RETURN_POINTER(&routine);
+}
+
+void
+_PG_init(void)
+{
+	create_reloptions_table();
+}
diff --git a/src/test/modules/dummy_table_am/dummy_table_am.control b/src/test/modules/dummy_table_am/dummy_table_am.control
new file mode 100644
index 00000000000..08f2f868d49
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am.control
@@ -0,0 +1,5 @@
+# dummy_table_am extension
+comment = 'dummy_table_am - table access method template'
+default_version = '1.0'
+module_pathname = '$libdir/dummy_table_am'
+relocatable = true
diff --git a/src/test/modules/dummy_table_am/expected/reloptions.out b/src/test/modules/dummy_table_am/expected/reloptions.out
new file mode 100644
index 00000000000..5d38767c528
--- /dev/null
+++ b/src/test/modules/dummy_table_am/expected/reloptions.out
@@ -0,0 +1,184 @@
+-- Tests for the table AM amoptions callback and add_reloption_to_kind()
+CREATE EXTENSION dummy_table_am;
+-- Sanity: CREATE TABLE with AM-specific options succeeds and round-trips
+CREATE TABLE dummy_t (a int) USING dummy_table_am
+    WITH (option_int = 17, option_real = 2.5, option_bool = false,
+          option_enum = 'two', fillfactor = 60);
+SELECT reloptions FROM pg_class
+    WHERE oid = 'dummy_t'::regclass ORDER BY reloptions;
+                                   reloptions                                    
+---------------------------------------------------------------------------------
+ {option_int=17,option_real=2.5,option_bool=false,option_enum=two,fillfactor=60}
+(1 row)
+
+-- AM-specific option ranges are enforced (option_int allows -10..100)
+CREATE TABLE dummy_oor (a int) USING dummy_table_am WITH (option_int = 9999);
+ERROR:  value 9999 out of bounds for option "option_int"
+DETAIL:  Valid values are between "-10" and "100".
+-- Unknown options are rejected at CREATE TABLE time
+CREATE TABLE dummy_bad (a int) USING dummy_table_am WITH (autovacuum_vacuum_threshold = 4);
+ERROR:  unrecognized parameter "autovacuum_vacuum_threshold"
+-- Default values land in pg_class only when the user did not set them
+CREATE TABLE dummy_defaults (a int) USING dummy_table_am;
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_defaults'::regclass;
+ reloptions 
+------------
+ 
+(1 row)
+
+DROP TABLE dummy_defaults;
+-- ALTER TABLE ... SET (...) with AM-specific option
+ALTER TABLE dummy_t SET (option_int = 42);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+                                   reloptions                                    
+---------------------------------------------------------------------------------
+ {option_real=2.5,option_bool=false,option_enum=two,fillfactor=60,option_int=42}
+(1 row)
+
+-- ALTER TABLE ... SET (...) with an unknown option errors
+ALTER TABLE dummy_t SET (autovacuum_vacuum_threshold = 4);
+ERROR:  unrecognized parameter "autovacuum_vacuum_threshold"
+-- ALTER TABLE ... RESET (option) round-trips
+ALTER TABLE dummy_t RESET (option_int);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+                            reloptions                             
+-------------------------------------------------------------------
+ {option_real=2.5,option_bool=false,option_enum=two,fillfactor=60}
+(1 row)
+
+-- SET ACCESS METHOD revalidation:
+--   moving a heap table that has standard heap options not accepted by the
+--   new AM (autovacuum_vacuum_threshold; dummy_table_am inherits
+--   autovacuum_enabled but not the rest of the autovacuum_* family) into
+--   dummy_table_am must fail with a clear message and must NOT silently
+--   drop the option.
+CREATE TABLE heap_t (a int) WITH (fillfactor = 70, autovacuum_vacuum_threshold = 4);
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+                  reloptions                   
+-----------------------------------------------
+ {fillfactor=70,autovacuum_vacuum_threshold=4}
+(1 row)
+
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am;
+ERROR:  unrecognized parameter "autovacuum_vacuum_threshold"
+-- Confirm nothing changed
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+ amname 
+--------
+ heap
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+                  reloptions                   
+-----------------------------------------------
+ {fillfactor=70,autovacuum_vacuum_threshold=4}
+(1 row)
+
+-- After RESETing the offending option in the same statement the swap
+-- succeeds; fillfactor survives because dummy_table_am inherits it via
+-- add_reloption_to_kind().
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am, RESET (autovacuum_vacuum_threshold);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+     amname     
+----------------
+ dummy_table_am
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+   reloptions    
+-----------------
+ {fillfactor=70}
+(1 row)
+
+-- Going back to heap still works: heap accepts fillfactor.
+ALTER TABLE heap_t SET ACCESS METHOD heap;
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+ amname 
+--------
+ heap
+(1 row)
+
+-- SET ACCESS METHOD + SET (...) of an option that only the new AM accepts.
+CREATE TABLE heap_to_dt (a int);
+ALTER TABLE heap_to_dt SET ACCESS METHOD dummy_table_am, SET (option_int = 25);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+     amname     
+----------------
+ dummy_table_am
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+   reloptions    
+-----------------
+ {option_int=25}
+(1 row)
+
+-- fillfactor genuinely reaches heap's own page-packing logic now, not just
+-- pg_class.reloptions: dummy_table_am embeds a full StdRdOptions as the
+-- first member of its own options struct and sets
+-- TableAmRoutine.has_std_options_prefix, so RelationGetFillFactor() can read
+-- it directly instead of always seeing the hardcoded default.
+CREATE TABLE dummy_ff10 (a int) USING dummy_table_am WITH (fillfactor = 10);
+CREATE TABLE dummy_ff100 (a int) USING dummy_table_am WITH (fillfactor = 100);
+INSERT INTO dummy_ff10 SELECT generate_series(1, 5000);
+INSERT INTO dummy_ff100 SELECT generate_series(1, 5000);
+VACUUM dummy_ff10;
+VACUUM dummy_ff100;
+SELECT (SELECT relpages FROM pg_class WHERE oid = 'dummy_ff10'::regclass) >
+       (SELECT relpages FROM pg_class WHERE oid = 'dummy_ff100'::regclass)
+       AS low_fillfactor_uses_more_pages;
+ low_fillfactor_uses_more_pages 
+--------------------------------
+ t
+(1 row)
+
+DROP TABLE dummy_ff10;
+DROP TABLE dummy_ff100;
+-- A table with a toastable column works: dummy_table_am overrides
+-- relation_toast_am rather than inheriting heap's, which would return
+-- this AM's own oid instead of heap's for its TOAST table, making that
+-- TOAST table itself a dummy_table_am relation and failing as soon as
+-- its chunk_id/chunk_seq index was built (that scan goes through
+-- heap_getnext() directly, which requires a real heap relation).
+CREATE TABLE dummy_txt (a int, b text) USING dummy_table_am;
+INSERT INTO dummy_txt VALUES (1, repeat('x', 10000));
+SELECT a, length(b) FROM dummy_txt;
+ a | length 
+---+--------
+ 1 |  10000
+(1 row)
+
+DROP TABLE dummy_txt;
+-- Partitioned-table inheritance: AM declared on the parent partition flows
+-- to partitions that don't override it.  Partitioned tables themselves
+-- cannot carry reloptions; the test verifies the AM lookup that
+-- DefineRelation does for partitions.
+CREATE TABLE parted (a int) PARTITION BY RANGE (a) USING dummy_table_am;
+CREATE TABLE parted_p1 PARTITION OF parted FOR VALUES FROM (0) TO (100)
+    WITH (option_int = 11);
+SELECT c.relname,
+       (SELECT amname FROM pg_am WHERE oid = c.relam) AS amname,
+       c.reloptions
+    FROM pg_class c
+    WHERE c.oid IN ('parted'::regclass, 'parted_p1'::regclass)
+    ORDER BY c.relname;
+  relname  |     amname     |   reloptions    
+-----------+----------------+-----------------
+ parted    | dummy_table_am | 
+ parted_p1 | dummy_table_am | {option_int=11}
+(2 rows)
+
+-- A partition that explicitly chooses heap must reject options that are
+-- only known to the parent's AM.
+CREATE TABLE parted_p2 PARTITION OF parted FOR VALUES FROM (100) TO (200)
+    USING heap WITH (option_int = 9);
+ERROR:  unrecognized parameter "option_int"
+DROP TABLE parted;
+DROP TABLE heap_to_dt;
+DROP TABLE heap_t;
+DROP TABLE dummy_t;
+DROP EXTENSION dummy_table_am;
diff --git a/src/test/modules/dummy_table_am/meson.build b/src/test/modules/dummy_table_am/meson.build
new file mode 100644
index 00000000000..ad3fa2410cc
--- /dev/null
+++ b/src/test/modules/dummy_table_am/meson.build
@@ -0,0 +1,33 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+dummy_table_am_sources = files(
+  'dummy_table_am.c',
+)
+
+if host_system == 'windows'
+  dummy_table_am_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'dummy_table_am',
+    '--FILEDESC', 'dummy_table_am - table access method template',])
+endif
+
+dummy_table_am = shared_module('dummy_table_am',
+  dummy_table_am_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += dummy_table_am
+
+test_install_data += files(
+  'dummy_table_am.control',
+  'dummy_table_am--1.0.sql',
+)
+
+tests += {
+  'name': 'dummy_table_am',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'reloptions',
+    ],
+  },
+}
diff --git a/src/test/modules/dummy_table_am/sql/reloptions.sql b/src/test/modules/dummy_table_am/sql/reloptions.sql
new file mode 100644
index 00000000000..97bc8479527
--- /dev/null
+++ b/src/test/modules/dummy_table_am/sql/reloptions.sql
@@ -0,0 +1,119 @@
+-- Tests for the table AM amoptions callback and add_reloption_to_kind()
+CREATE EXTENSION dummy_table_am;
+
+-- Sanity: CREATE TABLE with AM-specific options succeeds and round-trips
+CREATE TABLE dummy_t (a int) USING dummy_table_am
+    WITH (option_int = 17, option_real = 2.5, option_bool = false,
+          option_enum = 'two', fillfactor = 60);
+SELECT reloptions FROM pg_class
+    WHERE oid = 'dummy_t'::regclass ORDER BY reloptions;
+
+-- AM-specific option ranges are enforced (option_int allows -10..100)
+CREATE TABLE dummy_oor (a int) USING dummy_table_am WITH (option_int = 9999);
+
+-- Unknown options are rejected at CREATE TABLE time
+CREATE TABLE dummy_bad (a int) USING dummy_table_am WITH (autovacuum_vacuum_threshold = 4);
+
+-- Default values land in pg_class only when the user did not set them
+CREATE TABLE dummy_defaults (a int) USING dummy_table_am;
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_defaults'::regclass;
+DROP TABLE dummy_defaults;
+
+-- ALTER TABLE ... SET (...) with AM-specific option
+ALTER TABLE dummy_t SET (option_int = 42);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+
+-- ALTER TABLE ... SET (...) with an unknown option errors
+ALTER TABLE dummy_t SET (autovacuum_vacuum_threshold = 4);
+
+-- ALTER TABLE ... RESET (option) round-trips
+ALTER TABLE dummy_t RESET (option_int);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+
+-- SET ACCESS METHOD revalidation:
+--   moving a heap table that has standard heap options not accepted by the
+--   new AM (autovacuum_vacuum_threshold; dummy_table_am inherits
+--   autovacuum_enabled but not the rest of the autovacuum_* family) into
+--   dummy_table_am must fail with a clear message and must NOT silently
+--   drop the option.
+CREATE TABLE heap_t (a int) WITH (fillfactor = 70, autovacuum_vacuum_threshold = 4);
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am;
+-- Confirm nothing changed
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+
+-- After RESETing the offending option in the same statement the swap
+-- succeeds; fillfactor survives because dummy_table_am inherits it via
+-- add_reloption_to_kind().
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am, RESET (autovacuum_vacuum_threshold);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+
+-- Going back to heap still works: heap accepts fillfactor.
+ALTER TABLE heap_t SET ACCESS METHOD heap;
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+
+-- SET ACCESS METHOD + SET (...) of an option that only the new AM accepts.
+CREATE TABLE heap_to_dt (a int);
+ALTER TABLE heap_to_dt SET ACCESS METHOD dummy_table_am, SET (option_int = 25);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+
+-- fillfactor genuinely reaches heap's own page-packing logic now, not just
+-- pg_class.reloptions: dummy_table_am embeds a full StdRdOptions as the
+-- first member of its own options struct and sets
+-- TableAmRoutine.has_std_options_prefix, so RelationGetFillFactor() can read
+-- it directly instead of always seeing the hardcoded default.
+CREATE TABLE dummy_ff10 (a int) USING dummy_table_am WITH (fillfactor = 10);
+CREATE TABLE dummy_ff100 (a int) USING dummy_table_am WITH (fillfactor = 100);
+INSERT INTO dummy_ff10 SELECT generate_series(1, 5000);
+INSERT INTO dummy_ff100 SELECT generate_series(1, 5000);
+VACUUM dummy_ff10;
+VACUUM dummy_ff100;
+SELECT (SELECT relpages FROM pg_class WHERE oid = 'dummy_ff10'::regclass) >
+       (SELECT relpages FROM pg_class WHERE oid = 'dummy_ff100'::regclass)
+       AS low_fillfactor_uses_more_pages;
+DROP TABLE dummy_ff10;
+DROP TABLE dummy_ff100;
+
+-- A table with a toastable column works: dummy_table_am overrides
+-- relation_toast_am rather than inheriting heap's, which would return
+-- this AM's own oid instead of heap's for its TOAST table, making that
+-- TOAST table itself a dummy_table_am relation and failing as soon as
+-- its chunk_id/chunk_seq index was built (that scan goes through
+-- heap_getnext() directly, which requires a real heap relation).
+CREATE TABLE dummy_txt (a int, b text) USING dummy_table_am;
+INSERT INTO dummy_txt VALUES (1, repeat('x', 10000));
+SELECT a, length(b) FROM dummy_txt;
+DROP TABLE dummy_txt;
+
+-- Partitioned-table inheritance: AM declared on the parent partition flows
+-- to partitions that don't override it.  Partitioned tables themselves
+-- cannot carry reloptions; the test verifies the AM lookup that
+-- DefineRelation does for partitions.
+CREATE TABLE parted (a int) PARTITION BY RANGE (a) USING dummy_table_am;
+CREATE TABLE parted_p1 PARTITION OF parted FOR VALUES FROM (0) TO (100)
+    WITH (option_int = 11);
+SELECT c.relname,
+       (SELECT amname FROM pg_am WHERE oid = c.relam) AS amname,
+       c.reloptions
+    FROM pg_class c
+    WHERE c.oid IN ('parted'::regclass, 'parted_p1'::regclass)
+    ORDER BY c.relname;
+
+-- A partition that explicitly chooses heap must reject options that are
+-- only known to the parent's AM.
+CREATE TABLE parted_p2 PARTITION OF parted FOR VALUES FROM (100) TO (200)
+    USING heap WITH (option_int = 9);
+
+DROP TABLE parted;
+DROP TABLE heap_to_dt;
+DROP TABLE heap_t;
+DROP TABLE dummy_t;
+
+DROP EXTENSION dummy_table_am;
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index ce09e00531d..2a5d5ac6aea 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -5,6 +5,7 @@ subdir('commit_ts')
 subdir('delay_execution')
 subdir('dummy_index_am')
 subdir('dummy_seclabel')
+subdir('dummy_table_am')
 subdir('gin')
 subdir('index')
 subdir('injection_points')
-- 
2.43.0



^ permalink  raw  reply  [nested|flat] 25+ messages in thread

* Re: Allow table AMs to define their own reloptions
@ 2026-08-31 12:27  Andrew Dunstan <andrew@dunslane.net>
  parent: Andrew Dunstan <andrew@dunslane.net>
  0 siblings, 2 replies; 25+ messages in thread

From: Andrew Dunstan @ 2026-08-31 12:27 UTC (permalink / raw)
  To: Junwang Zhao <zhjwpku@gmail.com>; Zsolt Parragi <zsolt.parragi@percona.com>; +Cc: pgsql-hackers@lists.postgresql.org, Rafia Sabih <rafia.pghackers@gmail.com>; Julien Tachoires <julien@tachoires.me>


On 2026-08-28 Fr 3:43 PM, Andrew Dunstan wrote:
>
> rebased patch attached.
>
Attached is v7. Changes since v6:

- ALTER TABLE ... SET ACCESS METHOD skipped reloption revalidation when 
the new AM has no option parser of its own, on the theory that falling 
back to the heap parser meant nothing could have changed. But the
old AM may have accepted options heap doesn't know, so e.g. switching a 
dummy_table_am table back to heap with option_int still set silently 
kept an option the next relcache load couldn't interpret. Validate
unconditionally, with a test covering that direction.
- DefineRelation validated a partition's reloptions against the wrong AM 
when the partitioned parent has no AM of its own (relam = 0): the 
validation lookup stopped at get_rel_relam(parent) while the actual
relation creation falls back to default_table_access_method. With a 
non-heap default AM, PARTITION OF ... WITH (am_specific_option) was 
spuriously rejected. Apply the same fallback, with a test.
- dummy_table_am registered autovacuum_enabled as a bool reloption; core 
defines it as ternary. Also rewrote the comments explaining why every 
standard option must be registered (the has_std_options_prefix
   contract), which were more confusing than helpful.

cheers


andrew


--
Andrew Dunstan
EDB: https://www.enterprisedb.com

Attachments:

  [text/x-patch] v7-0001-Add-amoptions-callback-to-table-access-methods.patch (64.8K, ../../a389c8ce-0063-43ae-a1d5-4fe4deb070ee@dunslane.net/2-v7-0001-Add-amoptions-callback-to-table-access-methods.patch)
  download | inline diff:
From c845a3da16bdbb8d15fb2df2ea4175d94fbb2fd4 Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <andrew@dunslane.net>
Date: Sat, 29 Aug 2026 11:00:02 -0400
Subject: [PATCH v7] Add amoptions callback to table access methods

Table access methods have no way to define their own storage
parameters: CREATE/ALTER TABLE always parse the WITH clause with the
standard heap parser, regardless of the table's actual AM. Index AMs
have had this ability via IndexAmRoutine.amoptions for a long time;
give table AMs the same option.

TableAmRoutine gets an optional amoptions field, same signature as
the index AM version. table_reloptions() dispatches to it when set,
else falls back to heap_reloptions() as today. An AM that supplies
amoptions owns the option set entirely, and the bytea it returns,
stored verbatim in Relation->rd_options, dictates the layout its
other callbacks read.

DefineRelation, ATExecSetRelOptions, RelationParseRelOptions and
extractRelOptions all route through table_reloptions() for
RELKIND_RELATION/RELKIND_MATVIEW. The AM is resolved from the USING
clause, the partition parent, or default_table_access_method at
CREATE TABLE, and from rel->rd_tableam at ALTER TABLE SET -- except
a SET ACCESS METHOD queued in the same statement uses the new AM's
parser instead, so "SET ACCESS METHOD x, SET (foo = bar)" works when
foo belongs to x. SET ACCESS METHOD also revalidates the relation's
final reloptions against the new AM once other subcommands in the
statement have run, turning a reloption the new AM doesn't accept
into an immediate error instead of a silent drop at the next
relcache load; RESET in the same statement clears it. That
revalidation is unconditional: even when the new AM has no amoptions
of its own, table_reloptions() falls back to the standard heap
parser, and the old AM may have accepted options heap doesn't know
(its own custom reloptions), so "falls back to heap" is not itself a
reason to skip the check.

Core code that reads StdRdOptions fields straight out of rd_options
(RelationGetFillFactor and friends, plus direct casts in
vacuum.c/index.c) now checks RelationHasStdRdOptions() first, since
an AM-owned bytea isn't guaranteed to be StdRdOptions-shaped. An AM
can opt back into all of these at once via
TableAmRoutine.has_std_options_prefix, set when its own struct embeds
a full StdRdOptions as its first member -- and it must register every
field these call sites read, not just the ones it cares about, since
an embedded field it never registers is left zeroed rather than at
its real default (wrong for e.g. parallel_workers, default -1).

add_reloption_to_kind(name, kind) extends an existing reloption's
kind bitmask, so an AM can accept a core-registered option
(fillfactor, autovacuum_*, ...) without redeclaring it; this is how
an AM fills in the embedded StdRdOptions fields above. One exception:
autovacuum's own scheduling always parses via the standard heap
parser regardless of AM, so it only ever sees the standard
autovacuum_* names, never an AM's own amoptions -- consulting each
relation's AM during autovacuum's periodic scan isn't worth a
catalog lookup per relation for a capability nothing needs.

src/test/modules/dummy_table_am demonstrates the API: it copies
heap's TableAmRoutine wholesale and overrides two callbacks.
amoptions parses a mix of inherited standard options and its own
into a struct embedding StdRdOptions. relation_toast_am also needs
overriding, since heap's default returns the calling relation's own
AM oid -- correct for heap, wrong for an AM that just inherits it,
whose TOAST tables would then belong to that AM instead of heap.
Its regression test also exercises the SET ACCESS METHOD
revalidation in both directions: heap -> dummy_table_am with a
heap-only option still set, and dummy_table_am -> heap with an
AM-only option still set.
---
 doc/src/sgml/ref/alter_table.sgml             |  19 ++
 doc/src/sgml/tableam.sgml                     |  92 +++++++
 src/backend/access/common/reloptions.c        |  88 +++++-
 src/backend/catalog/index.c                   |   3 +-
 src/backend/commands/tablecmds.c              | 177 +++++++++++-
 src/backend/commands/vacuum.c                 |   3 +-
 src/backend/utils/cache/relcache.c            |   4 +-
 src/include/access/reloptions.h               |   3 +
 src/include/access/tableam.h                  |  60 ++++
 src/include/utils/rel.h                       |  29 +-
 src/test/modules/Makefile                     |   1 +
 src/test/modules/dummy_table_am/Makefile      |  20 ++
 src/test/modules/dummy_table_am/README        |  21 ++
 .../dummy_table_am/dummy_table_am--1.0.sql    |  13 +
 .../modules/dummy_table_am/dummy_table_am.c   | 257 ++++++++++++++++++
 .../dummy_table_am/dummy_table_am.control     |   5 +
 .../dummy_table_am/expected/reloptions.out    | 243 +++++++++++++++++
 src/test/modules/dummy_table_am/meson.build   |  33 +++
 .../modules/dummy_table_am/sql/reloptions.sql | 153 +++++++++++
 src/test/modules/meson.build                  |   1 +
 20 files changed, 1213 insertions(+), 12 deletions(-)
 create mode 100644 src/test/modules/dummy_table_am/Makefile
 create mode 100644 src/test/modules/dummy_table_am/README
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am.c
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am.control
 create mode 100644 src/test/modules/dummy_table_am/expected/reloptions.out
 create mode 100644 src/test/modules/dummy_table_am/meson.build
 create mode 100644 src/test/modules/dummy_table_am/sql/reloptions.sql

diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index 0f9d698d170..4f19fb8f656 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -807,6 +807,25 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
       causing future partitions to default to
       <varname>default_table_access_method</varname>.
      </para>
+     <para>
+      The new access method must accept every storage parameter
+      currently set on the table.  An access method may define its own
+      set of parameters, so a parameter that was legal under the old
+      access method is not necessarily recognized by the new one; if any
+      such parameter remains, <command>ALTER TABLE</command> raises an
+      error rather than silently dropping the value.  The unwanted
+      parameters can be cleared in the same statement, for example:
+<programlisting>
+ALTER TABLE measurement
+    SET ACCESS METHOD columnar,
+    RESET (fillfactor);
+</programlisting>
+      Validation is performed once, after all storage-parameter
+      sub-commands in the statement have been applied, so the order of
+      <literal>SET</literal>, <literal>RESET</literal>, and
+      <literal>SET ACCESS METHOD</literal> within the same
+      <command>ALTER TABLE</command> does not matter.
+     </para>
     </listitem>
    </varlistentry>
 
diff --git a/doc/src/sgml/tableam.sgml b/doc/src/sgml/tableam.sgml
index 9ccf5b739ed..68d8fd62f44 100644
--- a/doc/src/sgml/tableam.sgml
+++ b/doc/src/sgml/tableam.sgml
@@ -152,4 +152,96 @@ my_tableam_handler(PG_FUNCTION_ARGS)
   its implementation.
  </para>
 
+ <sect1 id="tableam-reloptions">
+  <title>Table Access Method Storage Parameters</title>
+
+  <para>
+   A table access method may define its own set of storage parameters
+   (reloptions) by supplying an <structfield>amoptions</structfield>
+   callback in its <structname>TableAmRoutine</structname>.  The callback
+   has the same signature as the corresponding index AM callback; it is
+   invoked at <command>CREATE TABLE</command> and
+   <command>ALTER TABLE</command> time to parse and validate the option
+   set, and at relation open time (with <literal>validate = false</literal>)
+   to build the in-memory representation stored in
+   <structfield>Relation-&gt;rd_options</structfield>.  An AM that does not
+   supply an <structfield>amoptions</structfield> callback inherits the
+   standard heap parser and the <structname>StdRdOptions</structname>
+   layout.
+  </para>
+
+  <para>
+   When the AM provides its own parser it owns the option set entirely:
+   it may accept all standard heap options, only a subset, or define
+   parameters of its own.  The bytea returned from the callback is
+   stored verbatim in <structfield>rd_options</structfield>, so the AM
+   also dictates the in-memory layout that its other callbacks read.
+  </para>
+
+  <para>
+   The parser is expected to validate user-supplied values, but
+   <emphasis>must not silently rewrite them</emphasis>.  In particular
+   it must not coerce out-of-range values to a default, drop unknown
+   options when <literal>validate = true</literal>, or substitute a
+   different unit; the user must be able to verify with
+   <command>SELECT reloptions FROM pg_class</command> that the values
+   they supplied are what the relation will use.  Out-of-range or
+   unknown options should be reported with
+   <function>ereport(ERROR)</function>.
+  </para>
+
+  <para>
+   To honour an option that the core code already registers for
+   <literal>RELOPT_KIND_HEAP</literal> (for example
+   <literal>fillfactor</literal> or the <literal>autovacuum_*</literal>
+   family), call <function>add_reloption_to_kind()</function> once per
+   option in the module's <function>_PG_init</function>.  This extends
+   the existing registration with the AM's own kind without forcing
+   the AM to re-declare each option.
+  </para>
+
+  <para>
+   The <literal>autovacuum_*</literal> family is a special case, and
+   reusing the standard names is the only way to reach autovacuum's own
+   scheduling logic with them at all: every field of
+   <structname>AutoVacOpts</structname> (whether autovacuum runs on the
+   table, its vacuum/analyze thresholds and scale factors, freeze ages,
+   cost delay and limit, log-duration settings, and so on) is extracted
+   by autovacuum's periodic scan of <structname>pg_class</structname>
+   using the standard heap parser unconditionally, never the relation's
+   own <structfield>amoptions</structfield> callback -- looking up and
+   calling the AM's own parser for every relation on every autovacuum
+   cycle would add a catalog lookup to a hot path for no AM that
+   currently needs it.  This is independent of
+   <structfield>has_std_options_prefix</structfield>: it applies even to an
+   AM whose reloptions struct does not embed
+   <structname>StdRdOptions</structname> at all.  A table AM can only
+   affect autovacuum's own scheduling by exposing these standard
+   <literal>autovacuum_*</literal> names via
+   <function>add_reloption_to_kind()</function>; a differently-named
+   option of its own is stored and readable from
+   <structfield>Relation-&gt;rd_options</structfield> like any other
+   AM-specific option, but autovacuum's scheduling logic will never see
+   it.
+  </para>
+
+  <para>
+   <command>ALTER TABLE ... SET ACCESS METHOD</command> revalidates the
+   relation's current storage parameters against the new access
+   method's parser after all <literal>SET</literal>,
+   <literal>RESET</literal>, and <literal>REPLACE</literal>
+   sub-commands in the same statement have been applied.  A parameter
+   that is not accepted by the new AM raises an error; the user can
+   clear such parameters in the same statement (see <xref
+   linkend="sql-altertable"/>).
+  </para>
+
+  <para>
+   See <filename>src/test/modules/dummy_table_am</filename> for a
+   minimal example that exercises both
+   <structfield>amoptions</structfield> and
+   <function>add_reloption_to_kind()</function>.
+  </para>
+ </sect1>
+
 </chapter>
diff --git a/src/backend/access/common/reloptions.c b/src/backend/access/common/reloptions.c
index c3f6f45f460..c062135dfac 100644
--- a/src/backend/access/common/reloptions.c
+++ b/src/backend/access/common/reloptions.c
@@ -24,6 +24,7 @@
 #include "access/nbtree.h"
 #include "access/reloptions.h"
 #include "access/spgist_private.h"
+#include "access/tableam.h"
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "commands/tablespace.h"
@@ -823,6 +824,44 @@ add_reloption_kind(void)
 	return (relopt_kind) last_assigned_kind;
 }
 
+/*
+ * add_reloption_to_kind
+ *		Extend an already-registered reloption so it is also accepted for
+ *		the given kind.
+ *
+ * Useful for table access methods that want their own RELOPT_KIND_*
+ * parser to accept standard options (fillfactor, parallel_workers,
+ * autovacuum_*, etc.) that core registers only for RELOPT_KIND_HEAP.
+ * Without this, every AM that wants the standard option set would
+ * have to re-register each option under its own kind.
+ *
+ * 'name' must match an existing option; 'kind' is OR'ed into that
+ * option's kinds mask.  Errors if no option with that name exists.
+ */
+void
+add_reloption_to_kind(const char *name, relopt_kind kind)
+{
+	int			namelen = strlen(name);
+	int			i;
+
+	if (need_initialization)
+		initialize_reloptions();
+
+	for (i = 0; relOpts[i]; i++)
+	{
+		if (relOpts[i]->namelen == namelen &&
+			strncmp(relOpts[i]->name, name, namelen) == 0)
+		{
+			relOpts[i]->kinds |= kind;
+			return;
+		}
+	}
+
+	ereport(ERROR,
+			(errcode(ERRCODE_UNDEFINED_OBJECT),
+			 errmsg("reloption \"%s\" does not exist", name)));
+}
+
 /*
  * add_reloption
  *		Add an already-created custom reloption to the list, and recompute the
@@ -1589,8 +1628,11 @@ extractRelOptions(HeapTuple tuple, TupleDesc tupdesc,
 	switch (classForm->relkind)
 	{
 		case RELKIND_RELATION:
-		case RELKIND_TOASTVALUE:
 		case RELKIND_MATVIEW:
+			options = table_reloptions(amoptions, classForm->relkind,
+									   datum, false);
+			break;
+		case RELKIND_TOASTVALUE:
 			options = heap_reloptions(classForm->relkind, datum, false);
 			break;
 		case RELKIND_PARTITIONED_TABLE:
@@ -2368,6 +2410,50 @@ heap_reloptions(char relkind, Datum reloptions, bool validate)
 	}
 }
 
+/*
+ * Parse options for a table relation, dispatching to the access method's
+ * own option parser when it supplies one.
+ *
+ *	amoptions	the table AM's option parser, or NULL to fall back to the
+ *				standard heap parser for this relkind.
+ *	relkind		the relation's kind.
+ *	reloptions	options as a text[] datum.
+ *	validate	error flag for unknown options or bad values.
+ *
+ * When amoptions is non-NULL the AM owns the option set: it may accept
+ * all standard heap options, only a subset, or define its own.  The
+ * returned bytea is laid out as the AM dictates (it is stored verbatim
+ * in Relation->rd_options).  When amoptions is NULL the result is the
+ * standard StdRdOptions layout.
+ */
+bytea *
+table_reloptions(amoptions_function amoptions, char relkind,
+				 Datum reloptions, bool validate)
+{
+	if (amoptions != NULL)
+		return amoptions(reloptions, validate);
+	return heap_reloptions(relkind, reloptions, validate);
+}
+
+/*
+ * Returns true when the relation's rd_options buffer is laid out as
+ * StdRdOptions.  The rel.h accessor macros (RelationGetFillFactor,
+ * RelationIsUsedAsCatalogTable, ...) check this first before casting
+ * rd_options to StdRdOptions, so that a table access method which supplies
+ * its own amoptions callback (and therefore owns the rd_options layout)
+ * does not have its bytes misinterpreted.
+ */
+bool
+RelationHasStdRdOptions(Relation relation)
+{
+	if (relation->rd_options == NULL)
+		return false;
+	if (relation->rd_tableam == NULL)
+		return false;
+	return relation->rd_tableam->amoptions == NULL ||
+		relation->rd_tableam->has_std_options_prefix;
+}
+
 
 /*
  * Parse options for indexes.
diff --git a/src/backend/catalog/index.c b/src/backend/catalog/index.c
index ec21b83b6b8..7bf5652b4a4 100644
--- a/src/backend/catalog/index.c
+++ b/src/backend/catalog/index.c
@@ -2898,7 +2898,8 @@ index_update_stats(Relation rel,
 	{
 		if (AutoVacuumingActive())
 		{
-			StdRdOptions *options = (StdRdOptions *) rel->rd_options;
+			StdRdOptions *options = RelationHasStdRdOptions(rel) ?
+				(StdRdOptions *) rel->rd_options : NULL;
 
 			if (options != NULL &&
 				options->autovacuum.enabled == PG_TERNARY_FALSE)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index fd144d783d9..498f56c44f8 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -702,9 +702,11 @@ static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
 								const char *tablespacename, LOCKMODE lockmode);
 static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
 static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
+static void ATValidateAccessMethodOptions(List **wqueue);
 static void ATExecSetRelOptions(Relation rel, List *defList,
 								AlterTableType operation,
-								LOCKMODE lockmode);
+								LOCKMODE lockmode,
+								Oid newAccessMethodId);
 static void ATExecEnableDisableTrigger(Relation rel, const char *trigname,
 									   char fires_when, bool skip_system, bool recurse,
 									   LOCKMODE lockmode);
@@ -965,6 +967,49 @@ DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId,
 		case RELKIND_PARTITIONED_TABLE:
 			(void) partitioned_table_reloptions(reloptions, true);
 			break;
+		case RELKIND_RELATION:
+		case RELKIND_MATVIEW:
+			{
+				amoptions_function amoptions = NULL;
+				Oid			amoid = InvalidOid;
+
+				/*
+				 * Resolve the table AM so its option parser can validate
+				 * AM-specific reloptions.  An AM that does not register a
+				 * parser falls back to default_reloptions for
+				 * RELOPT_KIND_HEAP.
+				 */
+				if (stmt->accessMethod != NULL)
+					amoid = get_table_am_oid(stmt->accessMethod, false);
+				else if (stmt->partbound != NULL && inheritOids != NIL)
+					amoid = get_rel_relam(linitial_oid(inheritOids));
+
+				/*
+				 * A partitioned parent may have no AM of its own (relam = 0);
+				 * fall back to default_table_access_method, matching the
+				 * resolution the actual relation creation below uses, so
+				 * options are validated by the same AM that will own the
+				 * relation.
+				 */
+				if (!OidIsValid(amoid))
+					amoid = get_table_am_oid(default_table_access_method, false);
+
+				if (OidIsValid(amoid))
+				{
+					HeapTuple	tuple;
+
+					tuple = SearchSysCache1(AMOID, ObjectIdGetDatum(amoid));
+					if (HeapTupleIsValid(tuple))
+					{
+						Form_pg_am	amform = (Form_pg_am) GETSTRUCT(tuple);
+
+						amoptions = GetTableAmRoutine(amform->amhandler)->amoptions;
+						ReleaseSysCache(tuple);
+					}
+				}
+				(void) table_reloptions(amoptions, relkind, reloptions, true);
+			}
+			break;
 		default:
 			(void) heap_reloptions(relkind, reloptions, true);
 	}
@@ -4957,6 +5002,18 @@ ATController(AlterTableStmt *parsetree,
 	/* Phase 2: update system catalogs */
 	ATRewriteCatalogs(&wqueue, lockmode, context);
 
+	/*
+	 * After all phase-2 subcommands have committed any SET / RESET / REPLACE
+	 * option changes to pg_class, but before any rewrite, ensure the final
+	 * reloptions are accepted by the access method the relation will use once
+	 * the ALTER TABLE finishes.  This catches the case where SET ACCESS
+	 * METHOD changes the AM and leaves pre-existing reloptions in pg_class
+	 * that the new AM does not recognise; without this check the new AM's
+	 * option parser would be called with validate=false at relcache load time
+	 * and silently ignore them.
+	 */
+	ATValidateAccessMethodOptions(&wqueue);
+
 	/* Phase 3: scan/rewrite tables as needed, and run afterStmts */
 	ATRewriteTables(parsetree, &wqueue, lockmode, context);
 }
@@ -5622,7 +5679,17 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
 		case AT_SetRelOptions:	/* SET (...) */
 		case AT_ResetRelOptions:	/* RESET (...) */
 		case AT_ReplaceRelOptions:	/* replace entire option list */
-			ATExecSetRelOptions(rel, (List *) cmd->def, cmd->subtype, lockmode);
+
+			/*
+			 * If SET ACCESS METHOD is queued in the same ALTER TABLE, the
+			 * reloptions in pg_class will be parsed by the new AM after the
+			 * statement finishes; tell ATExecSetRelOptions to validate
+			 * against that AM rather than the relation's current AM.  This
+			 * lets a user write ALTER TABLE t SET ACCESS METHOD x, SET (foo =
+			 * bar) where foo is recognised by x but not by the current AM.
+			 */
+			ATExecSetRelOptions(rel, (List *) cmd->def, cmd->subtype, lockmode,
+								tab->chgAccessMethod ? tab->newAccessMethod : InvalidOid);
 			break;
 		case AT_EnableTrig:		/* ENABLE TRIGGER name */
 			ATExecEnableDisableTrigger(rel, cmd->name,
@@ -17314,12 +17381,91 @@ ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel, const char *tablespacen
 	tab->newTableSpace = tablespaceId;
 }
 
+/*
+ * Re-validate pg_class.reloptions for every work-queue entry whose access
+ * method is being changed.  Called between phase 2 (catalog updates) and
+ * phase 3 (table rewrites): SET / RESET / REPLACE subcommands have already
+ * been committed to pg_class, and tab->newAccessMethod identifies the AM
+ * the relation will use once the ALTER TABLE finishes.
+ *
+ * The check exists because relcache.c calls the AM's option parser with
+ * validate=false at relation open: any pre-existing reloption that the
+ * new AM does not recognise would otherwise be silently dropped from the
+ * parsed StdRdOptions / AM-specific options struct, leaving the user
+ * unable to tell that the option is no longer in effect.  Failing the
+ * ALTER TABLE here with a clear message lets the user RESET the option
+ * in the same statement and re-run.
+ */
+static void
+ATValidateAccessMethodOptions(List **wqueue)
+{
+	ListCell   *ltab;
+
+	foreach(ltab, *wqueue)
+	{
+		AlteredTableInfo *tab = (AlteredTableInfo *) lfirst(ltab);
+		HeapTuple	amtup;
+		HeapTuple	reltup;
+		Form_pg_am	amform;
+		Form_pg_class relform;
+		amoptions_function amoptions;
+		Datum		reloptions;
+		bool		isnull;
+		Oid			amoid;
+
+		if (!tab->chgAccessMethod)
+			continue;
+
+		/*
+		 * Partitioned tables may reset the AM to "default" (InvalidOid); each
+		 * partition then chooses its own AM at create time, so there is no
+		 * per-relation AM whose parser to consult here.
+		 */
+		amoid = tab->newAccessMethod;
+		if (!OidIsValid(amoid))
+			continue;
+
+		amtup = SearchSysCache1(AMOID, ObjectIdGetDatum(amoid));
+		if (!HeapTupleIsValid(amtup))
+			elog(ERROR, "cache lookup failed for access method %u", amoid);
+		amform = (Form_pg_am) GETSTRUCT(amtup);
+		amoptions = GetTableAmRoutine(amform->amhandler)->amoptions;
+		ReleaseSysCache(amtup);
+
+		/*
+		 * Validate unconditionally, even when the new AM has no option parser
+		 * of its own: table_reloptions() then falls back to the standard heap
+		 * parser, which is exactly what relcache.c will use to reinterpret
+		 * these bytes at the next open.  The old AM may have accepted options
+		 * heap doesn't know (e.g. a custom AM's own options), so "new AM
+		 * falls back to heap" is not itself a reason to skip the check.
+		 */
+		reltup = SearchSysCache1(RELOID, ObjectIdGetDatum(tab->relid));
+		if (!HeapTupleIsValid(reltup))
+			elog(ERROR, "cache lookup failed for relation %u", tab->relid);
+		relform = (Form_pg_class) GETSTRUCT(reltup);
+		reloptions = SysCacheGetAttr(RELOID, reltup,
+									 Anum_pg_class_reloptions, &isnull);
+		if (!isnull)
+			(void) table_reloptions(amoptions, relform->relkind,
+									reloptions, true);
+		ReleaseSysCache(reltup);
+	}
+}
+
 /*
  * Set, reset, or replace reloptions.
+ *
+ * newAccessMethodId, if valid, names the table access method whose option
+ * parser should validate the resulting reloptions.  This is used when SET
+ * ACCESS METHOD is queued in the same ALTER TABLE so that the new options
+ * are checked against the AM the relation will use after the statement
+ * finishes, not the AM it has now.  Pass InvalidOid to use the relation's
+ * current access method.
  */
 static void
 ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
-					LOCKMODE lockmode)
+					LOCKMODE lockmode, Oid newAccessMethodId)
 {
 	Oid			relid;
 	Relation	pgclass;
@@ -17371,7 +17517,30 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
 	{
 		case RELKIND_RELATION:
 		case RELKIND_MATVIEW:
-			(void) heap_reloptions(rel->rd_rel->relkind, newOptions, true);
+			{
+				amoptions_function amoptions;
+
+				if (OidIsValid(newAccessMethodId))
+				{
+					HeapTuple	amtup;
+					Form_pg_am	amform;
+
+					amtup = SearchSysCache1(AMOID,
+											ObjectIdGetDatum(newAccessMethodId));
+					if (!HeapTupleIsValid(amtup))
+						elog(ERROR, "cache lookup failed for access method %u",
+							 newAccessMethodId);
+					amform = (Form_pg_am) GETSTRUCT(amtup);
+					amoptions = GetTableAmRoutine(amform->amhandler)->amoptions;
+					ReleaseSysCache(amtup);
+				}
+				else
+					amoptions = (rel->rd_tableam ?
+								 rel->rd_tableam->amoptions : NULL);
+
+				(void) table_reloptions(amoptions, rel->rd_rel->relkind,
+										newOptions, true);
+			}
 			break;
 		case RELKIND_PARTITIONED_TABLE:
 			(void) partitioned_table_reloptions(newOptions, true);
diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c
index d8c2f33c615..bbe99ec655a 100644
--- a/src/backend/commands/vacuum.c
+++ b/src/backend/commands/vacuum.c
@@ -2211,7 +2211,8 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 	 * whose parameters the caller handed down for that purpose.  For anything
 	 * else, params.main_relopts is NULL, and this just copies our own.
 	 */
-	relopts = merge_toast_reloptions((StdRdOptions *) rel->rd_options,
+	relopts = merge_toast_reloptions(RelationHasStdRdOptions(rel) ?
+									 (StdRdOptions *) rel->rd_options : NULL,
 									 params.main_relopts);
 
 	/*
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index f475d703977..b69d2a9ccc2 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -483,9 +483,11 @@ RelationParseRelOptions(Relation relation, HeapTuple tuple)
 	switch (relation->rd_rel->relkind)
 	{
 		case RELKIND_RELATION:
+		case RELKIND_MATVIEW:
+			amoptsfn = relation->rd_tableam ? relation->rd_tableam->amoptions : NULL;
+			break;
 		case RELKIND_TOASTVALUE:
 		case RELKIND_VIEW:
-		case RELKIND_MATVIEW:
 		case RELKIND_PARTITIONED_TABLE:
 			amoptsfn = NULL;
 			break;
diff --git a/src/include/access/reloptions.h b/src/include/access/reloptions.h
index ccff4717b62..dc07f78c470 100644
--- a/src/include/access/reloptions.h
+++ b/src/include/access/reloptions.h
@@ -187,6 +187,7 @@ typedef struct local_relopts
 	 (char *)(optstruct) + (optstruct)->member)
 
 extern relopt_kind add_reloption_kind(void);
+extern void add_reloption_to_kind(const char *name, relopt_kind kind);
 extern void add_bool_reloption(uint32 kinds, const char *name, const char *desc,
 							   bool default_val, LOCKMODE lockmode);
 extern void add_ternary_reloption(uint32 kinds, const char *name,
@@ -250,6 +251,8 @@ extern bytea *default_reloptions(Datum reloptions, bool validate,
 extern struct StdRdOptions *merge_toast_reloptions(const struct StdRdOptions *toast_opts,
 												   const struct StdRdOptions *main_opts);
 extern bytea *heap_reloptions(char relkind, Datum reloptions, bool validate);
+extern bytea *table_reloptions(amoptions_function amoptions, char relkind,
+							   Datum reloptions, bool validate);
 extern bytea *view_reloptions(Datum reloptions, bool validate);
 extern bytea *partitioned_table_reloptions(Datum reloptions, bool validate);
 extern bytea *index_reloptions(amoptions_function amoptions, Datum reloptions,
diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h
index ff03a2b816f..6dbc7156f18 100644
--- a/src/include/access/tableam.h
+++ b/src/include/access/tableam.h
@@ -17,6 +17,7 @@
 #ifndef TABLEAM_H
 #define TABLEAM_H
 
+#include "access/amapi.h"
 #include "access/relscan.h"
 #include "access/sdir.h"
 #include "access/xact.h"
@@ -324,6 +325,65 @@ typedef struct TableAmRoutine
 	NodeTag		type;
 
 
+	/* ------------------------------------------------------------------------
+	 * Reloption parsing.
+	 * ------------------------------------------------------------------------
+	 */
+
+	/*
+	 * Parse and validate AM-specific reloptions.  Optional: when NULL, the
+	 * caller falls back to the standard heap reloption parser
+	 * (default_reloptions with RELOPT_KIND_HEAP) and the result is laid out
+	 * as StdRdOptions.
+	 *
+	 * When non-NULL, the AM owns the option set entirely.  It is free to
+	 * accept all standard heap options, only a subset, or to add its own. The
+	 * returned bytea must begin with a VARSIZE header and is stored in
+	 * Relation->rd_options, so the AM dictates the in-memory layout that its
+	 * other callbacks read.  Core code that reads StdRdOptions fields out of
+	 * rd_options (RelationGetFillFactor, RelationIsUsedAsCatalogTable, ...)
+	 * checks RelationHasStdRdOptions() first, so a custom layout will not be
+	 * misinterpreted -- unless the AM sets has_std_options_prefix below to
+	 * declare that its struct is a StdRdOptions superset after all.
+	 *
+	 * The callback validates user-supplied values but must not silently
+	 * rewrite them: a user inspecting pg_class.reloptions must see exactly
+	 * what they passed in.  Out-of-range or unknown options should be
+	 * reported with ereport(ERROR) when validate is true.
+	 *
+	 * Signature matches the index AM's amoptions callback so the same helper
+	 * machinery (add_string_reloption, add_int_reloption, etc.) can be used.
+	 */
+	amoptions_function amoptions;
+
+	/*
+	 * Set to true when amoptions is non-NULL and the bytea it returns
+	 * begins with a full "StdRdOptions std;" as its first member (i.e. the
+	 * AM's own reloptions struct is a superset of StdRdOptions, not just a
+	 * layout that happens to share a prefix).  This tells core code that it
+	 * is safe to read StdRdOptions fields directly out of rd_options
+	 * (RelationGetFillFactor, RelationIsUsedAsCatalogTable, ...) for
+	 * relations of this AM, exactly as it would for plain heap.
+	 *
+	 * An AM that sets this must register every StdRdOptions field those
+	 * macros read (fillfactor, toast_tuple_target, user_catalog_table,
+	 * parallel_workers, vacuum_index_cleanup, vacuum_truncate,
+	 * vacuum_max_eager_freeze_failure_rate, autovacuum_enabled) with
+	 * add_reloption_to_kind(), even if it exposes none of them as options
+	 * the AM cares about itself: build_reloptions() only fills in fields
+	 * that are registered for the AM's relopt_kind, so an embedded
+	 * StdRdOptions field the AM never registers is left zeroed rather than
+	 * at that option's real default (0 is not a valid "unset" sentinel for
+	 * several of these fields, e.g. parallel_workers and
+	 * vacuum_max_eager_freeze_failure_rate both use -1).  Registering the
+	 * field via add_reloption_to_kind lets each one pick up its normal
+	 * catalog default instead.
+	 *
+	 * Ignored when amoptions is NULL.
+	 */
+	bool		has_std_options_prefix;
+
+
 	/* ------------------------------------------------------------------------
 	 * Slot related callbacks.
 	 * ------------------------------------------------------------------------
diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h
index 41ab4586c6b..73f9092f3d5 100644
--- a/src/include/utils/rel.h
+++ b/src/include/utils/rel.h
@@ -362,12 +362,33 @@ typedef struct StdRdOptions
 #define HEAP_MIN_FILLFACTOR			10
 #define HEAP_DEFAULT_FILLFACTOR		100
 
+/*
+ * RelationHasStdRdOptions
+ *		Returns true when the relation's rd_options buffer is safe to read
+ *		as StdRdOptions: either it was produced by the standard heap
+ *		reloption parser (the AM has no amoptions callback), or the AM's
+ *		own amoptions callback returns a struct that embeds a full
+ *		StdRdOptions as its first member and says so via
+ *		TableAmRoutine.has_std_options_prefix.  A table access method that
+ *		supplies amoptions without setting that flag owns its rd_options
+ *		layout entirely and is not required to expose StdRdOptions fields;
+ *		macros that read those fields must check this first to avoid
+ *		reading garbage, or past the end of a smaller custom struct.  For
+ *		indexes and other relkinds rd_options is in an AM-specific layout,
+ *		so this returns false for them.
+ *
+ *		Defined as a function (in reloptions.c) rather than a macro
+ *		because the test needs the full TableAmRoutine struct definition,
+ *		which would create an #include cycle if pulled into rel.h.
+ */
+extern bool RelationHasStdRdOptions(Relation relation);
+
 /*
  * RelationGetToastTupleTarget
  *		Returns the relation's toast_tuple_target.  Note multiple eval of argument!
  */
 #define RelationGetToastTupleTarget(relation, defaulttarg) \
-	((relation)->rd_options ? \
+	(RelationHasStdRdOptions(relation) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->toast_tuple_target : (defaulttarg))
 
 /*
@@ -375,7 +396,7 @@ typedef struct StdRdOptions
  *		Returns the relation's fillfactor.  Note multiple eval of argument!
  */
 #define RelationGetFillFactor(relation, defaultff) \
-	((relation)->rd_options ? \
+	(RelationHasStdRdOptions(relation) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->fillfactor : (defaultff))
 
 /*
@@ -398,7 +419,7 @@ typedef struct StdRdOptions
  *		from the pov of logical decoding.  Note multiple eval of argument!
  */
 #define RelationIsUsedAsCatalogTable(relation)	\
-	((relation)->rd_options && \
+	(RelationHasStdRdOptions(relation) && \
 	 ((relation)->rd_rel->relkind == RELKIND_RELATION || \
 	  (relation)->rd_rel->relkind == RELKIND_MATVIEW) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->user_catalog_table : false)
@@ -409,7 +430,7 @@ typedef struct StdRdOptions
  *		Note multiple eval of argument!
  */
 #define RelationGetParallelWorkers(relation, defaultpw) \
-	((relation)->rd_options ? \
+	(RelationHasStdRdOptions(relation) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->parallel_workers : (defaultpw))
 
 /* ViewOptions->check_option values */
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index bb88b3058ed..bfeeeba4ce1 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -10,6 +10,7 @@ SUBDIRS = \
 		  delay_execution \
 		  dummy_index_am \
 		  dummy_seclabel \
+		  dummy_table_am \
 		  index \
 		  libpq_pipeline \
 		  oauth_validator \
diff --git a/src/test/modules/dummy_table_am/Makefile b/src/test/modules/dummy_table_am/Makefile
new file mode 100644
index 00000000000..94837dff392
--- /dev/null
+++ b/src/test/modules/dummy_table_am/Makefile
@@ -0,0 +1,20 @@
+# src/test/modules/dummy_table_am/Makefile
+
+MODULES = dummy_table_am
+
+EXTENSION = dummy_table_am
+DATA = dummy_table_am--1.0.sql
+PGFILEDESC = "dummy_table_am - table access method template"
+
+REGRESS = reloptions
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/dummy_table_am
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/dummy_table_am/README b/src/test/modules/dummy_table_am/README
new file mode 100644
index 00000000000..a234a1f107f
--- /dev/null
+++ b/src/test/modules/dummy_table_am/README
@@ -0,0 +1,21 @@
+Dummy Table AM
+==============
+
+Dummy table AM is a module for testing the table access method
+amoptions callback and the add_reloption_to_kind() helper.  It
+delegates all storage and scan callbacks to the heap AM and only
+swaps in its own option parser, so a relation created with USING
+dummy_table_am behaves like a heap table but accepts a different
+set of reloptions:
+
+  - "fillfactor"     (inherited from the core heap registration via
+                      add_reloption_to_kind)
+  - "option_int"     (integer)
+  - "option_real"    (real)
+  - "option_bool"    (boolean)
+  - "option_enum"    (enum, one|two)
+
+Standard heap options such as parallel_workers, autovacuum_*, and
+toast_tuple_target are intentionally NOT accepted, to exercise the
+"AM rejects an unknown option" path in ALTER TABLE ... SET ACCESS
+METHOD revalidation.
diff --git a/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql b/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
new file mode 100644
index 00000000000..2e295b95845
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
@@ -0,0 +1,13 @@
+/* src/test/modules/dummy_table_am/dummy_table_am--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION dummy_table_am" to load this file. \quit
+
+CREATE FUNCTION dthandler(internal)
+RETURNS table_am_handler
+AS 'MODULE_PATHNAME'
+LANGUAGE C;
+
+-- Access method
+CREATE ACCESS METHOD dummy_table_am TYPE TABLE HANDLER dthandler;
+COMMENT ON ACCESS METHOD dummy_table_am IS 'dummy table access method';
diff --git a/src/test/modules/dummy_table_am/dummy_table_am.c b/src/test/modules/dummy_table_am/dummy_table_am.c
new file mode 100644
index 00000000000..94be9ec7cc3
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am.c
@@ -0,0 +1,257 @@
+/*-------------------------------------------------------------------------
+ *
+ * dummy_table_am.c
+ *		Table AM template main file.
+ *
+ * This module exists primarily to demonstrate and exercise the table AM
+ * amoptions callback and the add_reloption_to_kind() helper.  Storage
+ * and scan callbacks are delegated to the heap AM, so a relation
+ * created with USING dummy_table_am behaves like a heap table; only the
+ * reloption surface differs.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/test/modules/dummy_table_am/dummy_table_am.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/reloptions.h"
+#include "access/tableam.h"
+#include "catalog/pg_am_d.h"
+#include "fmgr.h"
+#include "utils/rel.h"
+
+PG_MODULE_MAGIC;
+
+/* Parse table for build_reloptions: 8 inherited standard options + 4 of our own */
+static relopt_parse_elt dt_relopt_tab[12];
+
+/* Kind of relation options for dummy table */
+static relopt_kind dt_relopt_kind;
+
+typedef enum DummyTableEnum
+{
+	DUMMY_TABLE_ENUM_ONE,
+	DUMMY_TABLE_ENUM_TWO,
+}			DummyTableEnum;
+
+/*
+ * Dummy table options.
+ *
+ * This AM sets TableAmRoutine.has_std_options_prefix (see dthandler()
+ * below), which promises core code that rd_options begins with a complete,
+ * valid StdRdOptions it may read directly -- RelationGetFillFactor(), the
+ * autovacuum option readers, and so on.  "std" is that StdRdOptions, and
+ * must be the first member.
+ *
+ * The promise only holds if every field of "std" carries a sensible value
+ * even when the user set nothing.  build_reloptions() fills exactly the
+ * fields listed in the parse table (with the option's default when unset)
+ * and leaves the rest zeroed -- and zero is the wrong "unset" value for
+ * several of them (parallel_workers and
+ * vacuum_max_eager_freeze_failure_rate both use -1; fillfactor's default
+ * is HEAP_DEFAULT_FILLFACTOR).  That is why create_reloptions_table()
+ * inherits and registers every option heap's default_reloptions()
+ * understands, not just the ones this module is interesting for.
+ *
+ * The remaining four are AM-specific options that only dummy_table_am
+ * knows about.
+ */
+typedef struct DummyTableOptions
+{
+	StdRdOptions std;			/* must be first, see above */
+	int			option_int;
+	double		option_real;
+	bool		option_bool;
+	DummyTableEnum option_enum;
+}			DummyTableOptions;
+
+static relopt_enum_elt_def dummyTableEnumValues[] =
+{
+	{"one", DUMMY_TABLE_ENUM_ONE},
+	{"two", DUMMY_TABLE_ENUM_TWO},
+	{(const char *) NULL}		/* list terminator */
+};
+
+PG_FUNCTION_INFO_V1(dthandler);
+
+/*
+ * Register a relopt_kind for this AM and populate the parse table.
+ */
+static void
+create_reloptions_table(void)
+{
+	int			i = 0;
+
+	dt_relopt_kind = add_reloption_kind();
+
+	/*
+	 * Accept every standard option that core's default_reloptions()
+	 * understands (registered for RELOPT_KIND_HEAP and/or RELOPT_KIND_TOAST)
+	 * under our own kind.  This is the canonical use of
+	 * add_reloption_to_kind(): an AM that wants to honour existing
+	 * core-registered options without duplicating their definitions.  All of
+	 * them, not just the interesting ones, must be both inherited and listed
+	 * in the parse table, or the corresponding DummyTableOptions.std fields
+	 * would stay zeroed rather than get their defaults -- and core code reads
+	 * those fields directly because of has_std_options_prefix (see the
+	 * comment on DummyTableOptions).
+	 */
+	add_reloption_to_kind("fillfactor", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "fillfactor";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.fillfactor);
+	i++;
+
+	add_reloption_to_kind("toast_tuple_target", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "toast_tuple_target";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.toast_tuple_target);
+	i++;
+
+	add_reloption_to_kind("parallel_workers", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "parallel_workers";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.parallel_workers);
+	i++;
+
+	add_reloption_to_kind("vacuum_index_cleanup", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "vacuum_index_cleanup";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_ENUM;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.vacuum_index_cleanup);
+	i++;
+
+	add_reloption_to_kind("vacuum_truncate", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "vacuum_truncate";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_TERNARY;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.vacuum_truncate);
+	i++;
+
+	add_reloption_to_kind("vacuum_max_eager_freeze_failure_rate", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "vacuum_max_eager_freeze_failure_rate";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_REAL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.vacuum_max_eager_freeze_failure_rate);
+	i++;
+
+	add_reloption_to_kind("autovacuum_enabled", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "autovacuum_enabled";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_TERNARY;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.autovacuum.enabled);
+	i++;
+
+	add_reloption_to_kind("user_catalog_table", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "user_catalog_table";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_BOOL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.user_catalog_table);
+	i++;
+
+	add_int_reloption(dt_relopt_kind, "option_int",
+					  "Integer option for dummy_table_am",
+					  10, -10, 100, AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_int";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_int);
+	i++;
+
+	add_real_reloption(dt_relopt_kind, "option_real",
+					   "Real option for dummy_table_am",
+					   3.1415, -10, 100, AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_real";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_REAL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_real);
+	i++;
+
+	add_bool_reloption(dt_relopt_kind, "option_bool",
+					   "Boolean option for dummy_table_am",
+					   true, AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_bool";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_BOOL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_bool);
+	i++;
+
+	add_enum_reloption(dt_relopt_kind, "option_enum",
+					   "Enum option for dummy_table_am",
+					   dummyTableEnumValues,
+					   DUMMY_TABLE_ENUM_ONE,
+					   "Valid values are \"one\" and \"two\".",
+					   AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_enum";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_ENUM;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_enum);
+	i++;
+}
+
+/*
+ * Parse reloptions for dummy_table_am.
+ *
+ * Returning DummyTableOptions tells the caller (relcache.c) to store
+ * exactly that layout in Relation->rd_options.
+ */
+static bytea *
+dtoptions(Datum reloptions, bool validate)
+{
+	return (bytea *) build_reloptions(reloptions, validate,
+									  dt_relopt_kind,
+									  sizeof(DummyTableOptions),
+									  dt_relopt_tab, lengthof(dt_relopt_tab));
+}
+
+/*
+ * heapam_relation_toast_am() (heap's own relation_toast_am callback, which
+ * we would otherwise inherit unchanged along with the rest of heap's
+ * routine) returns rel->rd_rel->relam -- correct for a real heap table, but
+ * for dummy_table_am that's dummy_table_am's own oid, not heap's.  That
+ * would make this AM's TOAST tables dummy_table_am relations too, and
+ * building their chunk_id/chunk_seq index fails as soon as it's scanned,
+ * since that scan goes through heap_getnext() directly.  Override it to
+ * return the literal heap AM oid: this AM's TOAST tables are always plain
+ * heap, regardless of what created the owning table.
+ */
+static Oid
+dummy_table_relation_toast_am(Relation rel)
+{
+	return HEAP_TABLE_AM_OID;
+}
+
+/*
+ * Handler for table AM.
+ *
+ * All storage-side callbacks are inherited from heap; we swap in our own
+ * amoptions so that the AM owns its reloption set, and our own
+ * relation_toast_am (see dummy_table_relation_toast_am() above).  This
+ * keeps the example focused on the new API without duplicating the heap
+ * AM.
+ *
+ * has_std_options_prefix is set because DummyTableOptions embeds a full
+ * StdRdOptions as its first member with every field populated (see the
+ * comment on DummyTableOptions): that makes it safe for core code to keep
+ * reading fillfactor and friends directly out of rd_options, exactly as
+ * it would for a plain heap table.
+ */
+Datum
+dthandler(PG_FUNCTION_ARGS)
+{
+	static TableAmRoutine routine;
+	static bool initialized = false;
+
+	if (!initialized)
+	{
+		memcpy(&routine, GetHeapamTableAmRoutine(), sizeof(routine));
+		routine.amoptions = dtoptions;
+		routine.has_std_options_prefix = true;
+		routine.relation_toast_am = dummy_table_relation_toast_am;
+		initialized = true;
+	}
+
+	PG_RETURN_POINTER(&routine);
+}
+
+void
+_PG_init(void)
+{
+	create_reloptions_table();
+}
diff --git a/src/test/modules/dummy_table_am/dummy_table_am.control b/src/test/modules/dummy_table_am/dummy_table_am.control
new file mode 100644
index 00000000000..08f2f868d49
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am.control
@@ -0,0 +1,5 @@
+# dummy_table_am extension
+comment = 'dummy_table_am - table access method template'
+default_version = '1.0'
+module_pathname = '$libdir/dummy_table_am'
+relocatable = true
diff --git a/src/test/modules/dummy_table_am/expected/reloptions.out b/src/test/modules/dummy_table_am/expected/reloptions.out
new file mode 100644
index 00000000000..660b25a3a9f
--- /dev/null
+++ b/src/test/modules/dummy_table_am/expected/reloptions.out
@@ -0,0 +1,243 @@
+-- Tests for the table AM amoptions callback and add_reloption_to_kind()
+CREATE EXTENSION dummy_table_am;
+-- Sanity: CREATE TABLE with AM-specific options succeeds and round-trips
+CREATE TABLE dummy_t (a int) USING dummy_table_am
+    WITH (option_int = 17, option_real = 2.5, option_bool = false,
+          option_enum = 'two', fillfactor = 60);
+SELECT reloptions FROM pg_class
+    WHERE oid = 'dummy_t'::regclass ORDER BY reloptions;
+                                   reloptions                                    
+---------------------------------------------------------------------------------
+ {option_int=17,option_real=2.5,option_bool=false,option_enum=two,fillfactor=60}
+(1 row)
+
+-- AM-specific option ranges are enforced (option_int allows -10..100)
+CREATE TABLE dummy_oor (a int) USING dummy_table_am WITH (option_int = 9999);
+ERROR:  value 9999 out of bounds for option "option_int"
+DETAIL:  Valid values are between "-10" and "100".
+-- Unknown options are rejected at CREATE TABLE time
+CREATE TABLE dummy_bad (a int) USING dummy_table_am WITH (autovacuum_vacuum_threshold = 4);
+ERROR:  unrecognized parameter "autovacuum_vacuum_threshold"
+-- Default values land in pg_class only when the user did not set them
+CREATE TABLE dummy_defaults (a int) USING dummy_table_am;
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_defaults'::regclass;
+ reloptions 
+------------
+ 
+(1 row)
+
+DROP TABLE dummy_defaults;
+-- ALTER TABLE ... SET (...) with AM-specific option
+ALTER TABLE dummy_t SET (option_int = 42);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+                                   reloptions                                    
+---------------------------------------------------------------------------------
+ {option_real=2.5,option_bool=false,option_enum=two,fillfactor=60,option_int=42}
+(1 row)
+
+-- ALTER TABLE ... SET (...) with an unknown option errors
+ALTER TABLE dummy_t SET (autovacuum_vacuum_threshold = 4);
+ERROR:  unrecognized parameter "autovacuum_vacuum_threshold"
+-- ALTER TABLE ... RESET (option) round-trips
+ALTER TABLE dummy_t RESET (option_int);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+                            reloptions                             
+-------------------------------------------------------------------
+ {option_real=2.5,option_bool=false,option_enum=two,fillfactor=60}
+(1 row)
+
+-- SET ACCESS METHOD revalidation:
+--   moving a heap table that has standard heap options not accepted by the
+--   new AM (autovacuum_vacuum_threshold; dummy_table_am inherits
+--   autovacuum_enabled but not the rest of the autovacuum_* family) into
+--   dummy_table_am must fail with a clear message and must NOT silently
+--   drop the option.
+CREATE TABLE heap_t (a int) WITH (fillfactor = 70, autovacuum_vacuum_threshold = 4);
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+                  reloptions                   
+-----------------------------------------------
+ {fillfactor=70,autovacuum_vacuum_threshold=4}
+(1 row)
+
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am;
+ERROR:  unrecognized parameter "autovacuum_vacuum_threshold"
+-- Confirm nothing changed
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+ amname 
+--------
+ heap
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+                  reloptions                   
+-----------------------------------------------
+ {fillfactor=70,autovacuum_vacuum_threshold=4}
+(1 row)
+
+-- After RESETing the offending option in the same statement the swap
+-- succeeds; fillfactor survives because dummy_table_am inherits it via
+-- add_reloption_to_kind().
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am, RESET (autovacuum_vacuum_threshold);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+     amname     
+----------------
+ dummy_table_am
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+   reloptions    
+-----------------
+ {fillfactor=70}
+(1 row)
+
+-- Going back to heap still works: heap accepts fillfactor.
+ALTER TABLE heap_t SET ACCESS METHOD heap;
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+ amname 
+--------
+ heap
+(1 row)
+
+-- SET ACCESS METHOD + SET (...) of an option that only the new AM accepts.
+CREATE TABLE heap_to_dt (a int);
+ALTER TABLE heap_to_dt SET ACCESS METHOD dummy_table_am, SET (option_int = 25);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+     amname     
+----------------
+ dummy_table_am
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+   reloptions    
+-----------------
+ {option_int=25}
+(1 row)
+
+-- The reverse direction must be caught too: heap has no amoptions of its
+-- own (table_reloptions() just falls back to heap_reloptions()), but that
+-- is not a reason to skip validation.  option_int is dummy_table_am-only,
+-- so switching back to heap while it is still set must fail the same way,
+-- not silently drop it at the next relcache load.
+ALTER TABLE heap_to_dt SET ACCESS METHOD heap;
+ERROR:  unrecognized parameter "option_int"
+-- Confirm nothing changed
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+     amname     
+----------------
+ dummy_table_am
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+   reloptions    
+-----------------
+ {option_int=25}
+(1 row)
+
+-- RESETting the offending option in the same statement lets it through
+ALTER TABLE heap_to_dt SET ACCESS METHOD heap, RESET (option_int);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+ amname 
+--------
+ heap
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+ reloptions 
+------------
+ 
+(1 row)
+
+-- fillfactor genuinely reaches heap's own page-packing logic now, not just
+-- pg_class.reloptions: dummy_table_am embeds a full StdRdOptions as the
+-- first member of its own options struct and sets
+-- TableAmRoutine.has_std_options_prefix, so RelationGetFillFactor() can read
+-- it directly instead of always seeing the hardcoded default.
+CREATE TABLE dummy_ff10 (a int) USING dummy_table_am WITH (fillfactor = 10);
+CREATE TABLE dummy_ff100 (a int) USING dummy_table_am WITH (fillfactor = 100);
+INSERT INTO dummy_ff10 SELECT generate_series(1, 5000);
+INSERT INTO dummy_ff100 SELECT generate_series(1, 5000);
+VACUUM dummy_ff10;
+VACUUM dummy_ff100;
+SELECT (SELECT relpages FROM pg_class WHERE oid = 'dummy_ff10'::regclass) >
+       (SELECT relpages FROM pg_class WHERE oid = 'dummy_ff100'::regclass)
+       AS low_fillfactor_uses_more_pages;
+ low_fillfactor_uses_more_pages 
+--------------------------------
+ t
+(1 row)
+
+DROP TABLE dummy_ff10;
+DROP TABLE dummy_ff100;
+-- A table with a toastable column works: dummy_table_am overrides
+-- relation_toast_am rather than inheriting heap's, which would return
+-- this AM's own oid instead of heap's for its TOAST table, making that
+-- TOAST table itself a dummy_table_am relation and failing as soon as
+-- its chunk_id/chunk_seq index was built (that scan goes through
+-- heap_getnext() directly, which requires a real heap relation).
+CREATE TABLE dummy_txt (a int, b text) USING dummy_table_am;
+INSERT INTO dummy_txt VALUES (1, repeat('x', 10000));
+SELECT a, length(b) FROM dummy_txt;
+ a | length 
+---+--------
+ 1 |  10000
+(1 row)
+
+DROP TABLE dummy_txt;
+-- Partitioned-table inheritance: AM declared on the parent partition flows
+-- to partitions that don't override it.  Partitioned tables themselves
+-- cannot carry reloptions; the test verifies the AM lookup that
+-- DefineRelation does for partitions.
+CREATE TABLE parted (a int) PARTITION BY RANGE (a) USING dummy_table_am;
+CREATE TABLE parted_p1 PARTITION OF parted FOR VALUES FROM (0) TO (100)
+    WITH (option_int = 11);
+SELECT c.relname,
+       (SELECT amname FROM pg_am WHERE oid = c.relam) AS amname,
+       c.reloptions
+    FROM pg_class c
+    WHERE c.oid IN ('parted'::regclass, 'parted_p1'::regclass)
+    ORDER BY c.relname;
+  relname  |     amname     |   reloptions    
+-----------+----------------+-----------------
+ parted    | dummy_table_am | 
+ parted_p1 | dummy_table_am | {option_int=11}
+(2 rows)
+
+-- A partition that explicitly chooses heap must reject options that are
+-- only known to the parent's AM.
+CREATE TABLE parted_p2 PARTITION OF parted FOR VALUES FROM (100) TO (200)
+    USING heap WITH (option_int = 9);
+ERROR:  unrecognized parameter "option_int"
+-- A parent created without USING has no AM of its own (relam = 0); a
+-- partition of it takes default_table_access_method, so its reloptions
+-- must be validated by that AM, not silently fall through to heap's
+-- parser (which would reject the AM's own options and accept heap-only
+-- ones the AM would then drop).
+SET default_table_access_method = dummy_table_am;
+CREATE TABLE parted_noam (a int) PARTITION BY RANGE (a);
+CREATE TABLE parted_noam_p1 PARTITION OF parted_noam
+    FOR VALUES FROM (0) TO (100) WITH (option_int = 12);
+SELECT c.relname,
+       (SELECT amname FROM pg_am WHERE oid = c.relam) AS amname,
+       c.reloptions
+    FROM pg_class c
+    WHERE c.oid IN ('parted_noam'::regclass, 'parted_noam_p1'::regclass)
+    ORDER BY c.relname;
+    relname     |     amname     |   reloptions    
+----------------+----------------+-----------------
+ parted_noam    |                | 
+ parted_noam_p1 | dummy_table_am | {option_int=12}
+(2 rows)
+
+RESET default_table_access_method;
+DROP TABLE parted_noam;
+DROP TABLE parted;
+DROP TABLE heap_to_dt;
+DROP TABLE heap_t;
+DROP TABLE dummy_t;
+DROP EXTENSION dummy_table_am;
diff --git a/src/test/modules/dummy_table_am/meson.build b/src/test/modules/dummy_table_am/meson.build
new file mode 100644
index 00000000000..ad3fa2410cc
--- /dev/null
+++ b/src/test/modules/dummy_table_am/meson.build
@@ -0,0 +1,33 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+dummy_table_am_sources = files(
+  'dummy_table_am.c',
+)
+
+if host_system == 'windows'
+  dummy_table_am_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'dummy_table_am',
+    '--FILEDESC', 'dummy_table_am - table access method template',])
+endif
+
+dummy_table_am = shared_module('dummy_table_am',
+  dummy_table_am_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += dummy_table_am
+
+test_install_data += files(
+  'dummy_table_am.control',
+  'dummy_table_am--1.0.sql',
+)
+
+tests += {
+  'name': 'dummy_table_am',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'reloptions',
+    ],
+  },
+}
diff --git a/src/test/modules/dummy_table_am/sql/reloptions.sql b/src/test/modules/dummy_table_am/sql/reloptions.sql
new file mode 100644
index 00000000000..9811ecc9c18
--- /dev/null
+++ b/src/test/modules/dummy_table_am/sql/reloptions.sql
@@ -0,0 +1,153 @@
+-- Tests for the table AM amoptions callback and add_reloption_to_kind()
+CREATE EXTENSION dummy_table_am;
+
+-- Sanity: CREATE TABLE with AM-specific options succeeds and round-trips
+CREATE TABLE dummy_t (a int) USING dummy_table_am
+    WITH (option_int = 17, option_real = 2.5, option_bool = false,
+          option_enum = 'two', fillfactor = 60);
+SELECT reloptions FROM pg_class
+    WHERE oid = 'dummy_t'::regclass ORDER BY reloptions;
+
+-- AM-specific option ranges are enforced (option_int allows -10..100)
+CREATE TABLE dummy_oor (a int) USING dummy_table_am WITH (option_int = 9999);
+
+-- Unknown options are rejected at CREATE TABLE time
+CREATE TABLE dummy_bad (a int) USING dummy_table_am WITH (autovacuum_vacuum_threshold = 4);
+
+-- Default values land in pg_class only when the user did not set them
+CREATE TABLE dummy_defaults (a int) USING dummy_table_am;
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_defaults'::regclass;
+DROP TABLE dummy_defaults;
+
+-- ALTER TABLE ... SET (...) with AM-specific option
+ALTER TABLE dummy_t SET (option_int = 42);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+
+-- ALTER TABLE ... SET (...) with an unknown option errors
+ALTER TABLE dummy_t SET (autovacuum_vacuum_threshold = 4);
+
+-- ALTER TABLE ... RESET (option) round-trips
+ALTER TABLE dummy_t RESET (option_int);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+
+-- SET ACCESS METHOD revalidation:
+--   moving a heap table that has standard heap options not accepted by the
+--   new AM (autovacuum_vacuum_threshold; dummy_table_am inherits
+--   autovacuum_enabled but not the rest of the autovacuum_* family) into
+--   dummy_table_am must fail with a clear message and must NOT silently
+--   drop the option.
+CREATE TABLE heap_t (a int) WITH (fillfactor = 70, autovacuum_vacuum_threshold = 4);
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am;
+-- Confirm nothing changed
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+
+-- After RESETing the offending option in the same statement the swap
+-- succeeds; fillfactor survives because dummy_table_am inherits it via
+-- add_reloption_to_kind().
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am, RESET (autovacuum_vacuum_threshold);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+
+-- Going back to heap still works: heap accepts fillfactor.
+ALTER TABLE heap_t SET ACCESS METHOD heap;
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+
+-- SET ACCESS METHOD + SET (...) of an option that only the new AM accepts.
+CREATE TABLE heap_to_dt (a int);
+ALTER TABLE heap_to_dt SET ACCESS METHOD dummy_table_am, SET (option_int = 25);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+
+-- The reverse direction must be caught too: heap has no amoptions of its
+-- own (table_reloptions() just falls back to heap_reloptions()), but that
+-- is not a reason to skip validation.  option_int is dummy_table_am-only,
+-- so switching back to heap while it is still set must fail the same way,
+-- not silently drop it at the next relcache load.
+ALTER TABLE heap_to_dt SET ACCESS METHOD heap;
+-- Confirm nothing changed
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+-- RESETting the offending option in the same statement lets it through
+ALTER TABLE heap_to_dt SET ACCESS METHOD heap, RESET (option_int);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+
+-- fillfactor genuinely reaches heap's own page-packing logic now, not just
+-- pg_class.reloptions: dummy_table_am embeds a full StdRdOptions as the
+-- first member of its own options struct and sets
+-- TableAmRoutine.has_std_options_prefix, so RelationGetFillFactor() can read
+-- it directly instead of always seeing the hardcoded default.
+CREATE TABLE dummy_ff10 (a int) USING dummy_table_am WITH (fillfactor = 10);
+CREATE TABLE dummy_ff100 (a int) USING dummy_table_am WITH (fillfactor = 100);
+INSERT INTO dummy_ff10 SELECT generate_series(1, 5000);
+INSERT INTO dummy_ff100 SELECT generate_series(1, 5000);
+VACUUM dummy_ff10;
+VACUUM dummy_ff100;
+SELECT (SELECT relpages FROM pg_class WHERE oid = 'dummy_ff10'::regclass) >
+       (SELECT relpages FROM pg_class WHERE oid = 'dummy_ff100'::regclass)
+       AS low_fillfactor_uses_more_pages;
+DROP TABLE dummy_ff10;
+DROP TABLE dummy_ff100;
+
+-- A table with a toastable column works: dummy_table_am overrides
+-- relation_toast_am rather than inheriting heap's, which would return
+-- this AM's own oid instead of heap's for its TOAST table, making that
+-- TOAST table itself a dummy_table_am relation and failing as soon as
+-- its chunk_id/chunk_seq index was built (that scan goes through
+-- heap_getnext() directly, which requires a real heap relation).
+CREATE TABLE dummy_txt (a int, b text) USING dummy_table_am;
+INSERT INTO dummy_txt VALUES (1, repeat('x', 10000));
+SELECT a, length(b) FROM dummy_txt;
+DROP TABLE dummy_txt;
+
+-- Partitioned-table inheritance: AM declared on the parent partition flows
+-- to partitions that don't override it.  Partitioned tables themselves
+-- cannot carry reloptions; the test verifies the AM lookup that
+-- DefineRelation does for partitions.
+CREATE TABLE parted (a int) PARTITION BY RANGE (a) USING dummy_table_am;
+CREATE TABLE parted_p1 PARTITION OF parted FOR VALUES FROM (0) TO (100)
+    WITH (option_int = 11);
+SELECT c.relname,
+       (SELECT amname FROM pg_am WHERE oid = c.relam) AS amname,
+       c.reloptions
+    FROM pg_class c
+    WHERE c.oid IN ('parted'::regclass, 'parted_p1'::regclass)
+    ORDER BY c.relname;
+
+-- A partition that explicitly chooses heap must reject options that are
+-- only known to the parent's AM.
+CREATE TABLE parted_p2 PARTITION OF parted FOR VALUES FROM (100) TO (200)
+    USING heap WITH (option_int = 9);
+
+-- A parent created without USING has no AM of its own (relam = 0); a
+-- partition of it takes default_table_access_method, so its reloptions
+-- must be validated by that AM, not silently fall through to heap's
+-- parser (which would reject the AM's own options and accept heap-only
+-- ones the AM would then drop).
+SET default_table_access_method = dummy_table_am;
+CREATE TABLE parted_noam (a int) PARTITION BY RANGE (a);
+CREATE TABLE parted_noam_p1 PARTITION OF parted_noam
+    FOR VALUES FROM (0) TO (100) WITH (option_int = 12);
+SELECT c.relname,
+       (SELECT amname FROM pg_am WHERE oid = c.relam) AS amname,
+       c.reloptions
+    FROM pg_class c
+    WHERE c.oid IN ('parted_noam'::regclass, 'parted_noam_p1'::regclass)
+    ORDER BY c.relname;
+RESET default_table_access_method;
+DROP TABLE parted_noam;
+
+DROP TABLE parted;
+DROP TABLE heap_to_dt;
+DROP TABLE heap_t;
+DROP TABLE dummy_t;
+
+DROP EXTENSION dummy_table_am;
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index ce09e00531d..2a5d5ac6aea 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -5,6 +5,7 @@ subdir('commit_ts')
 subdir('delay_execution')
 subdir('dummy_index_am')
 subdir('dummy_seclabel')
+subdir('dummy_table_am')
 subdir('gin')
 subdir('index')
 subdir('injection_points')
-- 
2.43.0



^ permalink  raw  reply  [nested|flat] 25+ messages in thread

* Re: Allow table AMs to define their own reloptions
@ 2026-09-07 11:15  Ajit Awekar <ajitpostgres@gmail.com>
  parent: Andrew Dunstan <andrew@dunslane.net>
  1 sibling, 0 replies; 25+ messages in thread

From: Ajit Awekar @ 2026-09-07 11:15 UTC (permalink / raw)
  To: Andrew Dunstan <andrew@dunslane.net>; +Cc: Junwang Zhao <zhjwpku@gmail.com>; Zsolt Parragi <zsolt.parragi@percona.com>; pgsql-hackers@lists.postgresql.org, Rafia Sabih <rafia.pghackers@gmail.com>; Julien Tachoires <julien@tachoires.me>

Hi Hackers,

While testing dummy_table_am (src/test/modules/dummy_table_am/), I noticed
the README doesn't match the code

 README says

dummy_table_am behaves like a heap table but accepts a different
set of reloptions:

  - "fillfactor"     (inherited from the core heap registration via
                      add_reloption_to_kind)
  - "option_int"     (integer)
  - "option_real"    (real)
  - "option_bool"    (boolean)
  - "option_enum"    (enum, one|two)

Standard heap options such as parallel_workers, autovacuum_*, and
toast_tuple_target are intentionally NOT accepted.

But create_reloptions_table() in dummy_table_am.c registers 8 standard
options: fillfactor, toast_tuple_target, parallel_workers,
vacuum_index_cleanup,
vacuum_truncate, vacuum_max_eager_freeze_failure_rate,
autovacuum_enabled, and user_catalog_table

Do we need to update documentation as per create_reloptions_table()?

Thanks & Best Regards,
Ajit

On Mon, 31 Aug 2026 at 17:57, Andrew Dunstan <andrew@dunslane.net> wrote:

>
> On 2026-08-28 Fr 3:43 PM, Andrew Dunstan wrote:
> >
> > rebased patch attached.
> >
> Attached is v7. Changes since v6:
>
> - ALTER TABLE ... SET ACCESS METHOD skipped reloption revalidation when
> the new AM has no option parser of its own, on the theory that falling
> back to the heap parser meant nothing could have changed. But the
> old AM may have accepted options heap doesn't know, so e.g. switching a
> dummy_table_am table back to heap with option_int still set silently
> kept an option the next relcache load couldn't interpret. Validate
> unconditionally, with a test covering that direction.
> - DefineRelation validated a partition's reloptions against the wrong AM
> when the partitioned parent has no AM of its own (relam = 0): the
> validation lookup stopped at get_rel_relam(parent) while the actual
> relation creation falls back to default_table_access_method. With a
> non-heap default AM, PARTITION OF ... WITH (am_specific_option) was
> spuriously rejected. Apply the same fallback, with a test.
> - dummy_table_am registered autovacuum_enabled as a bool reloption; core
> defines it as ternary. Also rewrote the comments explaining why every
> standard option must be registered (the has_std_options_prefix
>    contract), which were more confusing than helpful.
>
> cheers
>
>
> andrew
>
>
> --
> Andrew Dunstan
> EDB: https://www.enterprisedb.com
>

^ permalink  raw  reply  [nested|flat] 25+ messages in thread

* Re: Allow table AMs to define their own reloptions
@ 2026-09-07 12:41  Aleksander Alekseev <aleksander@tigerdata.com>
  parent: Andrew Dunstan <andrew@dunslane.net>
  1 sibling, 2 replies; 25+ messages in thread

From: Aleksander Alekseev @ 2026-09-07 12:41 UTC (permalink / raw)
  To: pgsql-hackers@lists.postgresql.org; +Cc: Andrew Dunstan <andrew@dunslane.net>; Junwang Zhao <zhjwpku@gmail.com>; Zsolt Parragi <zsolt.parragi@percona.com>; Rafia Sabih <rafia.pghackers@gmail.com>; Julien Tachoires <julien@tachoires.me>

Hi Andrew,

> Attached is v7. Changes since v6:
>
> [...]

That's an important feature, thanks for driving it. We wanted
something like this in the recent commit 0e944fe3 but ended up using a
GUC.

There is one important design difference however. Your patch allows
given TAMs to have their own reloptions. In 0e944fe3 the task was
different. An extension that doesn't implement a TableAM should be
able to extend `CREATE TABLE foo WITH (...)` with its own options
independent of the underlying TAM. The way these options are
interpreted depends on the extension, not the underlying TAM.

If we could separate reloptions extension from TAM implementation that
would be much more flexible. Do you think it's possible?

-- 
Best regards,
Aleksander Alekseev






^ permalink  raw  reply  [nested|flat] 25+ messages in thread

* Re: Allow table AMs to define their own reloptions
@ 2026-09-08 10:03  Ajit Awekar <ajitpostgres@gmail.com>
  parent: Aleksander Alekseev <aleksander@tigerdata.com>
  1 sibling, 1 reply; 25+ messages in thread

From: Ajit Awekar @ 2026-09-08 10:03 UTC (permalink / raw)
  To: Aleksander Alekseev <aleksander@tigerdata.com>; +Cc: pgsql-hackers@lists.postgresql.org, Andrew Dunstan <andrew@dunslane.net>; Junwang Zhao <zhjwpku@gmail.com>; Zsolt Parragi <zsolt.parragi@percona.com>; Rafia Sabih <rafia.pghackers@gmail.com>; Julien Tachoires <julien@tachoires.me>

Hi hackers,

I managed to get a crash with the patch. Below are the details


Repro:

Any table AM that sets amoptions but leaves has_std_options_prefix false
and returns a bytea smaller than sizeof(StdRdOptions) will
crash on VACUUM of a table that has a toastable column.

postgres=# CREATE EXTENSION tiny_table_am;
CREATE EXTENSION
postgres=# CREATE TABLE t_tiny (a int, b text) USING tiny_table_am WITH
(option_int = 7);
CREATE TABLE
postgres=# INSERT INTO t_tiny VALUES (1, repeat('x', 10000));
INSERT 0 1
postgres=# VACUUM t_tiny;
server closed the connection unexpectedly
This probably means the server terminated abnormally
before or while processing the request.
The connection to the server was lost. Attempting reset: Failed.
The connection to the server was lost. Attempting reset: Failed.


Root cause:
vacuum_rel() in  has two places that read
rel->rd_options as a StdRdOptions to hand storage parameters down to the
relation's TOAST table. Only one of them was updated to use the new
RelationHasStdRdOptions() guard:

    ~line 2214 (correctly guarded):
      relopts = merge_toast_reloptions(RelationHasStdRdOptions(rel) ?
                                       (StdRdOptions *) rel->rd_options :
NULL,
                                       params.main_relopts);

    ~line 2310-2312 (still just checks != NULL):
      if (OidIsValid(toast_relid) && rel->rd_options)
      {
          memcpy(&relopts_copy, rel->rd_options, sizeof(StdRdOptions));
          toast_vacuum_params.main_relopts = &relopts_copy;
      }




Suggested fix
-------------
Same guard as the nearby, already-fixed call:

--- a/src/backend/commands/vacuum.c
+++ b/src/backend/commands/vacuum.c
@@ -2307,9 +2307,8 @@ vacuum_rel(Oid relid, RangeVar *relation,
VacuumParams params,
     * Hand our storage parameters down for the TOAST table to inherit.
Take
     * a copy while we still have the relation open; the relcache entry can
go
     * away once we close it.
     */
-   if (OidIsValid(toast_relid) && rel->rd_options)
+   if (OidIsValid(toast_relid) && RelationHasStdRdOptions(rel))
    {
        memcpy(&relopts_copy, rel->rd_options, sizeof(StdRdOptions));
        toast_vacuum_params.main_relopts = &relopts_copy;
    }

Thanks & Best Regards,
Ajit

On Mon, 7 Sept 2026 at 18:11, Aleksander Alekseev <aleksander@tigerdata.com>
wrote:

> Hi Andrew,
>
> > Attached is v7. Changes since v6:
> >
> > [...]
>
> That's an important feature, thanks for driving it. We wanted
> something like this in the recent commit 0e944fe3 but ended up using a
> GUC.
>
> There is one important design difference however. Your patch allows
> given TAMs to have their own reloptions. In 0e944fe3 the task was
> different. An extension that doesn't implement a TableAM should be
> able to extend `CREATE TABLE foo WITH (...)` with its own options
> independent of the underlying TAM. The way these options are
> interpreted depends on the extension, not the underlying TAM.
>
> If we could separate reloptions extension from TAM implementation that
> would be much more flexible. Do you think it's possible?
>
> --
> Best regards,
> Aleksander Alekseev
>
>
>

^ permalink  raw  reply  [nested|flat] 25+ messages in thread

* Re: Allow table AMs to define their own reloptions
@ 2026-09-08 12:34  Ajit Awekar <ajitpostgres@gmail.com>
  parent: Ajit Awekar <ajitpostgres@gmail.com>
  0 siblings, 1 reply; 25+ messages in thread

From: Ajit Awekar @ 2026-09-08 12:34 UTC (permalink / raw)
  To: Aleksander Alekseev <aleksander@tigerdata.com>; +Cc: pgsql-hackers@lists.postgresql.org, Andrew Dunstan <andrew@dunslane.net>; Junwang Zhao <zhjwpku@gmail.com>; Zsolt Parragi <zsolt.parragi@percona.com>; Rafia Sabih <rafia.pghackers@gmail.com>; Julien Tachoires <julien@tachoires.me>

Hi all,

I forgot to attach the TAM code used for repro. please find it attached

Thanks & Best Regards,
Ajit

On Tue, 8 Sept 2026 at 15:33, Ajit Awekar <ajitpostgres@gmail.com> wrote:

> Hi hackers,
>
> I managed to get a crash with the patch. Below are the details
>
>
> Repro:
>
> Any table AM that sets amoptions but leaves has_std_options_prefix false
> and returns a bytea smaller than sizeof(StdRdOptions) will
> crash on VACUUM of a table that has a toastable column.
>
> postgres=# CREATE EXTENSION tiny_table_am;
> CREATE EXTENSION
> postgres=# CREATE TABLE t_tiny (a int, b text) USING tiny_table_am WITH
> (option_int = 7);
> CREATE TABLE
> postgres=# INSERT INTO t_tiny VALUES (1, repeat('x', 10000));
> INSERT 0 1
> postgres=# VACUUM t_tiny;
> server closed the connection unexpectedly
> This probably means the server terminated abnormally
> before or while processing the request.
> The connection to the server was lost. Attempting reset: Failed.
> The connection to the server was lost. Attempting reset: Failed.
>
>
> Root cause:
> vacuum_rel() in  has two places that read
> rel->rd_options as a StdRdOptions to hand storage parameters down to the
> relation's TOAST table. Only one of them was updated to use the new
> RelationHasStdRdOptions() guard:
>
>     ~line 2214 (correctly guarded):
>       relopts = merge_toast_reloptions(RelationHasStdRdOptions(rel) ?
>                                        (StdRdOptions *) rel->rd_options :
> NULL,
>                                        params.main_relopts);
>
>     ~line 2310-2312 (still just checks != NULL):
>       if (OidIsValid(toast_relid) && rel->rd_options)
>       {
>           memcpy(&relopts_copy, rel->rd_options, sizeof(StdRdOptions));
>           toast_vacuum_params.main_relopts = &relopts_copy;
>       }
>
>
>
>
> Suggested fix
> -------------
> Same guard as the nearby, already-fixed call:
>
> --- a/src/backend/commands/vacuum.c
> +++ b/src/backend/commands/vacuum.c
> @@ -2307,9 +2307,8 @@ vacuum_rel(Oid relid, RangeVar *relation,
> VacuumParams params,
>      * Hand our storage parameters down for the TOAST table to inherit.
> Take
>      * a copy while we still have the relation open; the relcache entry
> can go
>      * away once we close it.
>      */
> -   if (OidIsValid(toast_relid) && rel->rd_options)
> +   if (OidIsValid(toast_relid) && RelationHasStdRdOptions(rel))
>     {
>         memcpy(&relopts_copy, rel->rd_options, sizeof(StdRdOptions));
>         toast_vacuum_params.main_relopts = &relopts_copy;
>     }
>
> Thanks & Best Regards,
> Ajit
>
> On Mon, 7 Sept 2026 at 18:11, Aleksander Alekseev <
> aleksander@tigerdata.com> wrote:
>
>> Hi Andrew,
>>
>> > Attached is v7. Changes since v6:
>> >
>> > [...]
>>
>> That's an important feature, thanks for driving it. We wanted
>> something like this in the recent commit 0e944fe3 but ended up using a
>> GUC.
>>
>> There is one important design difference however. Your patch allows
>> given TAMs to have their own reloptions. In 0e944fe3 the task was
>> different. An extension that doesn't implement a TableAM should be
>> able to extend `CREATE TABLE foo WITH (...)` with its own options
>> independent of the underlying TAM. The way these options are
>> interpreted depends on the extension, not the underlying TAM.
>>
>> If we could separate reloptions extension from TAM implementation that
>> would be much more flexible. Do you think it's possible?
>>
>> --
>> Best regards,
>> Aleksander Alekseev
>>
>>
>>

Attachments:

  [application/x-gzip] tiny_table_am-repro.tar.gz (1.3K, ../../CAER375NkRrY395teqrLO9=-dBEs_OHhJEMLQYJMvF9E-wYUVGQ@mail.gmail.com/3-tiny_table_am-repro.tar.gz)
  download

^ permalink  raw  reply  [nested|flat] 25+ messages in thread

* Re: Allow table AMs to define their own reloptions
@ 2026-09-10 17:50  Andrew Dunstan <andrew@dunslane.net>
  parent: Aleksander Alekseev <aleksander@tigerdata.com>
  1 sibling, 0 replies; 25+ messages in thread

From: Andrew Dunstan @ 2026-09-10 17:50 UTC (permalink / raw)
  To: Aleksander Alekseev <aleksander@tigerdata.com>; pgsql-hackers@lists.postgresql.org; +Cc: Junwang Zhao <zhjwpku@gmail.com>; Zsolt Parragi <zsolt.parragi@percona.com>; Rafia Sabih <rafia.pghackers@gmail.com>; Julien Tachoires <julien@tachoires.me>


On 2026-09-07 Mo 8:41 AM, Aleksander Alekseev wrote:
> Hi Andrew,
>
>> Attached is v7. Changes since v6:
>>
>> [...]
> That's an important feature, thanks for driving it. We wanted
> something like this in the recent commit 0e944fe3 but ended up using a
> GUC.
>
> There is one important design difference however. Your patch allows
> given TAMs to have their own reloptions. In 0e944fe3 the task was
> different. An extension that doesn't implement a TableAM should be
> able to extend `CREATE TABLE foo WITH (...)` with its own options
> independent of the underlying TAM. The way these options are
> interpreted depends on the extension, not the underlying TAM.
>
> If we could separate reloptions extension from TAM implementation that
> would be much more flexible. Do you think it's possible?


I kind of feel like that's a separate project, and I'm not keen to hold 
this one up for scope creep.


cheers


andrew

--
Andrew Dunstan
EDB: https://www.enterprisedb.com







^ permalink  raw  reply  [nested|flat] 25+ messages in thread

* Re: Allow table AMs to define their own reloptions
@ 2026-09-28 13:23  Andrew Dunstan <andrew@dunslane.net>
  parent: Ajit Awekar <ajitpostgres@gmail.com>
  0 siblings, 0 replies; 25+ messages in thread

From: Andrew Dunstan @ 2026-09-28 13:23 UTC (permalink / raw)
  To: Ajit Awekar <ajitpostgres@gmail.com>; Aleksander Alekseev <aleksander@tigerdata.com>; +Cc: pgsql-hackers@lists.postgresql.org, Junwang Zhao <zhjwpku@gmail.com>; Zsolt Parragi <zsolt.parragi@percona.com>; Rafia Sabih <rafia.pghackers@gmail.com>; Julien Tachoires <julien@tachoires.me>


On 2026-09-08 Tu 8:34 AM, Ajit Awekar wrote:
> Hi all,
>
> I forgot to attach the TAM code used for repro. please find it attached
>
> Thanks & Best Regards,
> Ajit
>
> On Tue, 8 Sept 2026 at 15:33, Ajit Awekar <ajitpostgres@gmail.com> wrote:
>
>     Hi hackers,
>
>     I managed to get a crash with the patch. Below are the details
>
>
>     Repro:
>
>     Any table AM that sets amoptions but leaves has_std_options_prefix
>     false
>     and returns a bytea smaller than sizeof(StdRdOptions) will
>     crash on VACUUM of a table that has a toastable column.
>
>     postgres=# CREATE EXTENSION tiny_table_am;
>     CREATE EXTENSION
>     postgres=# CREATE TABLE t_tiny (a int, b text) USING tiny_table_am
>     WITH (option_int = 7);
>     CREATE TABLE
>     postgres=# INSERT INTO t_tiny VALUES (1, repeat('x', 10000));
>     INSERT 0 1
>     postgres=# VACUUM t_tiny;
>     server closed the connection unexpectedly
>     This probably means the server terminated abnormally
>     before or while processing the request.
>     The connection to the server was lost. Attempting reset: Failed.
>     The connection to the server was lost. Attempting reset: Failed.
>
>
>     Root cause:
>     vacuum_rel() in  has two places that read
>     rel->rd_options as a StdRdOptions to hand storage parameters down
>     to the
>     relation's TOAST table. Only one of them was updated to use the new
>     RelationHasStdRdOptions() guard:
>
>         ~line 2214 (correctly guarded):
>           relopts = merge_toast_reloptions(RelationHasStdRdOptions(rel) ?
>                                            (StdRdOptions *)
>     rel->rd_options : NULL,
>      params.main_relopts);
>
>         ~line 2310-2312 (still just checks != NULL):
>           if (OidIsValid(toast_relid) && rel->rd_options)
>           {
>               memcpy(&relopts_copy, rel->rd_options,
>     sizeof(StdRdOptions));
>               toast_vacuum_params.main_relopts = &relopts_copy;
>           }
>
>
>
>
>     Suggested fix
>     -------------
>     Same guard as the nearby, already-fixed call:
>
>     --- a/src/backend/commands/vacuum.c
>     +++ b/src/backend/commands/vacuum.c
>     @@ -2307,9 +2307,8 @@ vacuum_rel(Oid relid, RangeVar *relation,
>     VacuumParams params,
>          * Hand our storage parameters down for the TOAST table to
>     inherit.  Take
>          * a copy while we still have the relation open; the relcache
>     entry can go
>          * away once we close it.
>          */
>     -   if (OidIsValid(toast_relid) && rel->rd_options)
>     +   if (OidIsValid(toast_relid) && RelationHasStdRdOptions(rel))
>         {
>             memcpy(&relopts_copy, rel->rd_options, sizeof(StdRdOptions));
>             toast_vacuum_params.main_relopts = &relopts_copy;
>         }
>
>

Thanks, this should be fixed in v8 attached.


cheers


andrew


--
Andrew Dunstan
EDB:https://www.enterprisedb.com

Attachments:

  [text/x-patch] v8-0001-Add-amoptions-callback-to-table-access-methods.patch (65.4K, ../../04c18e23-32c0-4ce8-85f8-e47f1c027ed0@dunslane.net/3-v8-0001-Add-amoptions-callback-to-table-access-methods.patch)
  download | inline diff:
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <andrew@dunslane.net>
Date: Sat, 29 Aug 2026 11:00:02 -0400
Subject: [PATCH v8] Add amoptions callback to table access methods

Table AMs have had no way to define their own storage parameters:
CREATE/ALTER TABLE always parsed the WITH clause with the standard
heap parser, regardless of the table's actual AM. Index AMs have had
this via IndexAmRoutine.amoptions for a long time; give table AMs
the same.

TableAmRoutine gets an optional amoptions field, same signature as
the index AM version. table_reloptions() dispatches to it when set,
else falls back to heap_reloptions(). An AM that supplies amoptions
owns the option set entirely; the bytea it returns is stored
verbatim in Relation->rd_options and dictates the layout its other
callbacks read.

SET ACCESS METHOD revalidates a relation's final reloptions against
the new AM once other subcommands in the same statement have run,
unconditionally -- even a new AM that falls back to the standard
heap parser may not accept an option the old AM did. A reloption the
new AM rejects is an immediate error rather than a silent drop at
the next relcache load; RESET in the same statement clears it.

Core code that reads StdRdOptions fields straight out of rd_options
(RelationGetFillFactor and friends, direct casts in vacuum.c and
index.c) checks RelationHasStdRdOptions() first, since an AM-owned
bytea isn't guaranteed to be StdRdOptions-shaped. An AM opts back in
via TableAmRoutine.has_std_options_prefix when its own struct embeds
a full StdRdOptions, registering every such field or leaving it
zeroed instead of at its real default. vacuum_rel() has two such
guarded reads when handing a relation's storage parameters down to
its TOAST table.

add_reloption_to_kind(name, kind) lets an AM accept a
core-registered option (fillfactor, autovacuum_*, ...) without
redeclaring it. It's also the only way an AM can affect autovacuum's
own scheduling, which always parses via the standard heap parser
regardless of AM.

src/test/modules/dummy_table_am demonstrates the API: SET ACCESS
METHOD revalidation in both directions, and every StdRdOptions field
registered and exercised, including toast_value_type on a table
with a toastable column.

Discussion: https://www.postgresql.org/message-id/flat/ea1c4d33-0780-473c-96dc-1468cf733a04@dunslane.net
---
 doc/src/sgml/ref/alter_table.sgml             |  19 ++
 doc/src/sgml/tableam.sgml                     |  92 ++++++
 src/backend/access/common/reloptions.c        |  88 +++++-
 src/backend/catalog/index.c                   |   3 +-
 src/backend/commands/tablecmds.c              | 177 +++++++++++-
 src/backend/commands/vacuum.c                 |   5 +-
 src/backend/utils/cache/relcache.c            |   4 +-
 src/include/access/reloptions.h               |   3 +
 src/include/access/tableam.h                  |  60 ++++
 src/include/utils/rel.h                       |  29 +-
 src/test/modules/Makefile                     |   1 +
 src/test/modules/dummy_table_am/Makefile      |  20 ++
 src/test/modules/dummy_table_am/README        |  23 ++
 .../dummy_table_am/dummy_table_am--1.0.sql    |  13 +
 .../modules/dummy_table_am/dummy_table_am.c   | 263 ++++++++++++++++++
 .../dummy_table_am/dummy_table_am.control     |   5 +
 .../dummy_table_am/expected/reloptions.out    | 258 +++++++++++++++++
 src/test/modules/dummy_table_am/meson.build   |  33 +++
 .../modules/dummy_table_am/sql/reloptions.sql | 164 +++++++++++
 src/test/modules/meson.build                  |   1 +
 20 files changed, 1248 insertions(+), 13 deletions(-)
 create mode 100644 src/test/modules/dummy_table_am/Makefile
 create mode 100644 src/test/modules/dummy_table_am/README
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am.c
 create mode 100644 src/test/modules/dummy_table_am/dummy_table_am.control
 create mode 100644 src/test/modules/dummy_table_am/expected/reloptions.out
 create mode 100644 src/test/modules/dummy_table_am/meson.build
 create mode 100644 src/test/modules/dummy_table_am/sql/reloptions.sql

diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index 0f9d698d170..4f19fb8f656 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -807,6 +807,25 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
       causing future partitions to default to
       <varname>default_table_access_method</varname>.
      </para>
+     <para>
+      The new access method must accept every storage parameter
+      currently set on the table.  An access method may define its own
+      set of parameters, so a parameter that was legal under the old
+      access method is not necessarily recognized by the new one; if any
+      such parameter remains, <command>ALTER TABLE</command> raises an
+      error rather than silently dropping the value.  The unwanted
+      parameters can be cleared in the same statement, for example:
+<programlisting>
+ALTER TABLE measurement
+    SET ACCESS METHOD columnar,
+    RESET (fillfactor);
+</programlisting>
+      Validation is performed once, after all storage-parameter
+      sub-commands in the statement have been applied, so the order of
+      <literal>SET</literal>, <literal>RESET</literal>, and
+      <literal>SET ACCESS METHOD</literal> within the same
+      <command>ALTER TABLE</command> does not matter.
+     </para>
     </listitem>
    </varlistentry>
 
diff --git a/doc/src/sgml/tableam.sgml b/doc/src/sgml/tableam.sgml
index 9ccf5b739ed..68d8fd62f44 100644
--- a/doc/src/sgml/tableam.sgml
+++ b/doc/src/sgml/tableam.sgml
@@ -152,4 +152,96 @@ my_tableam_handler(PG_FUNCTION_ARGS)
   its implementation.
  </para>
 
+ <sect1 id="tableam-reloptions">
+  <title>Table Access Method Storage Parameters</title>
+
+  <para>
+   A table access method may define its own set of storage parameters
+   (reloptions) by supplying an <structfield>amoptions</structfield>
+   callback in its <structname>TableAmRoutine</structname>.  The callback
+   has the same signature as the corresponding index AM callback; it is
+   invoked at <command>CREATE TABLE</command> and
+   <command>ALTER TABLE</command> time to parse and validate the option
+   set, and at relation open time (with <literal>validate = false</literal>)
+   to build the in-memory representation stored in
+   <structfield>Relation-&gt;rd_options</structfield>.  An AM that does not
+   supply an <structfield>amoptions</structfield> callback inherits the
+   standard heap parser and the <structname>StdRdOptions</structname>
+   layout.
+  </para>
+
+  <para>
+   When the AM provides its own parser it owns the option set entirely:
+   it may accept all standard heap options, only a subset, or define
+   parameters of its own.  The bytea returned from the callback is
+   stored verbatim in <structfield>rd_options</structfield>, so the AM
+   also dictates the in-memory layout that its other callbacks read.
+  </para>
+
+  <para>
+   The parser is expected to validate user-supplied values, but
+   <emphasis>must not silently rewrite them</emphasis>.  In particular
+   it must not coerce out-of-range values to a default, drop unknown
+   options when <literal>validate = true</literal>, or substitute a
+   different unit; the user must be able to verify with
+   <command>SELECT reloptions FROM pg_class</command> that the values
+   they supplied are what the relation will use.  Out-of-range or
+   unknown options should be reported with
+   <function>ereport(ERROR)</function>.
+  </para>
+
+  <para>
+   To honour an option that the core code already registers for
+   <literal>RELOPT_KIND_HEAP</literal> (for example
+   <literal>fillfactor</literal> or the <literal>autovacuum_*</literal>
+   family), call <function>add_reloption_to_kind()</function> once per
+   option in the module's <function>_PG_init</function>.  This extends
+   the existing registration with the AM's own kind without forcing
+   the AM to re-declare each option.
+  </para>
+
+  <para>
+   The <literal>autovacuum_*</literal> family is a special case, and
+   reusing the standard names is the only way to reach autovacuum's own
+   scheduling logic with them at all: every field of
+   <structname>AutoVacOpts</structname> (whether autovacuum runs on the
+   table, its vacuum/analyze thresholds and scale factors, freeze ages,
+   cost delay and limit, log-duration settings, and so on) is extracted
+   by autovacuum's periodic scan of <structname>pg_class</structname>
+   using the standard heap parser unconditionally, never the relation's
+   own <structfield>amoptions</structfield> callback -- looking up and
+   calling the AM's own parser for every relation on every autovacuum
+   cycle would add a catalog lookup to a hot path for no AM that
+   currently needs it.  This is independent of
+   <structfield>has_std_options_prefix</structfield>: it applies even to an
+   AM whose reloptions struct does not embed
+   <structname>StdRdOptions</structname> at all.  A table AM can only
+   affect autovacuum's own scheduling by exposing these standard
+   <literal>autovacuum_*</literal> names via
+   <function>add_reloption_to_kind()</function>; a differently-named
+   option of its own is stored and readable from
+   <structfield>Relation-&gt;rd_options</structfield> like any other
+   AM-specific option, but autovacuum's scheduling logic will never see
+   it.
+  </para>
+
+  <para>
+   <command>ALTER TABLE ... SET ACCESS METHOD</command> revalidates the
+   relation's current storage parameters against the new access
+   method's parser after all <literal>SET</literal>,
+   <literal>RESET</literal>, and <literal>REPLACE</literal>
+   sub-commands in the same statement have been applied.  A parameter
+   that is not accepted by the new AM raises an error; the user can
+   clear such parameters in the same statement (see <xref
+   linkend="sql-altertable"/>).
+  </para>
+
+  <para>
+   See <filename>src/test/modules/dummy_table_am</filename> for a
+   minimal example that exercises both
+   <structfield>amoptions</structfield> and
+   <function>add_reloption_to_kind()</function>.
+  </para>
+ </sect1>
+
 </chapter>
diff --git a/src/backend/access/common/reloptions.c b/src/backend/access/common/reloptions.c
index ea9a0417909..440b09a3794 100644
--- a/src/backend/access/common/reloptions.c
+++ b/src/backend/access/common/reloptions.c
@@ -24,6 +24,7 @@
 #include "access/nbtree.h"
 #include "access/reloptions.h"
 #include "access/spgist_private.h"
+#include "access/tableam.h"
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
 #include "commands/tablespace.h"
@@ -843,6 +844,44 @@ add_reloption_kind(void)
 	return (relopt_kind) last_assigned_kind;
 }
 
+/*
+ * add_reloption_to_kind
+ *		Extend an already-registered reloption so it is also accepted for
+ *		the given kind.
+ *
+ * Useful for table access methods that want their own RELOPT_KIND_*
+ * parser to accept standard options (fillfactor, parallel_workers,
+ * autovacuum_*, etc.) that core registers only for RELOPT_KIND_HEAP.
+ * Without this, every AM that wants the standard option set would
+ * have to re-register each option under its own kind.
+ *
+ * 'name' must match an existing option; 'kind' is OR'ed into that
+ * option's kinds mask.  Errors if no option with that name exists.
+ */
+void
+add_reloption_to_kind(const char *name, relopt_kind kind)
+{
+	int			namelen = strlen(name);
+	int			i;
+
+	if (need_initialization)
+		initialize_reloptions();
+
+	for (i = 0; relOpts[i]; i++)
+	{
+		if (relOpts[i]->namelen == namelen &&
+			strncmp(relOpts[i]->name, name, namelen) == 0)
+		{
+			relOpts[i]->kinds |= kind;
+			return;
+		}
+	}
+
+	ereport(ERROR,
+			(errcode(ERRCODE_UNDEFINED_OBJECT),
+			 errmsg("reloption \"%s\" does not exist", name)));
+}
+
 /*
  * add_reloption
  *		Add an already-created custom reloption to the list, and recompute the
@@ -1609,8 +1648,11 @@ extractRelOptions(HeapTuple tuple, TupleDesc tupdesc,
 	switch (classForm->relkind)
 	{
 		case RELKIND_RELATION:
-		case RELKIND_TOASTVALUE:
 		case RELKIND_MATVIEW:
+			options = table_reloptions(amoptions, classForm->relkind,
+									   datum, false);
+			break;
+		case RELKIND_TOASTVALUE:
 			options = heap_reloptions(classForm->relkind, datum, false);
 			break;
 		case RELKIND_PARTITIONED_TABLE:
@@ -2390,6 +2432,50 @@ heap_reloptions(char relkind, Datum reloptions, bool validate)
 	}
 }
 
+/*
+ * Parse options for a table relation, dispatching to the access method's
+ * own option parser when it supplies one.
+ *
+ *	amoptions	the table AM's option parser, or NULL to fall back to the
+ *				standard heap parser for this relkind.
+ *	relkind		the relation's kind.
+ *	reloptions	options as a text[] datum.
+ *	validate	error flag for unknown options or bad values.
+ *
+ * When amoptions is non-NULL the AM owns the option set: it may accept
+ * all standard heap options, only a subset, or define its own.  The
+ * returned bytea is laid out as the AM dictates (it is stored verbatim
+ * in Relation->rd_options).  When amoptions is NULL the result is the
+ * standard StdRdOptions layout.
+ */
+bytea *
+table_reloptions(amoptions_function amoptions, char relkind,
+				 Datum reloptions, bool validate)
+{
+	if (amoptions != NULL)
+		return amoptions(reloptions, validate);
+	return heap_reloptions(relkind, reloptions, validate);
+}
+
+/*
+ * Returns true when the relation's rd_options buffer is laid out as
+ * StdRdOptions.  The rel.h accessor macros (RelationGetFillFactor,
+ * RelationIsUsedAsCatalogTable, ...) check this first before casting
+ * rd_options to StdRdOptions, so that a table access method which supplies
+ * its own amoptions callback (and therefore owns the rd_options layout)
+ * does not have its bytes misinterpreted.
+ */
+bool
+RelationHasStdRdOptions(Relation relation)
+{
+	if (relation->rd_options == NULL)
+		return false;
+	if (relation->rd_tableam == NULL)
+		return false;
+	return relation->rd_tableam->amoptions == NULL ||
+		relation->rd_tableam->has_std_options_prefix;
+}
+
 
 /*
  * Parse options for indexes.
diff --git a/src/backend/catalog/index.c b/src/backend/catalog/index.c
index 4d232b85ad5..c2e4b959509 100644
--- a/src/backend/catalog/index.c
+++ b/src/backend/catalog/index.c
@@ -2972,7 +2972,8 @@ index_update_stats(Relation rel,
 	{
 		if (AutoVacuumingActive())
 		{
-			StdRdOptions *options = (StdRdOptions *) rel->rd_options;
+			StdRdOptions *options = RelationHasStdRdOptions(rel) ?
+				(StdRdOptions *) rel->rd_options : NULL;
 
 			if (options != NULL &&
 				options->autovacuum.enabled == PG_TERNARY_FALSE)
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 0274d892f2e..ee4052fbcbf 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -696,9 +696,11 @@ static void ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel,
 								const char *tablespacename, LOCKMODE lockmode);
 static void ATExecSetTableSpace(Oid tableOid, Oid newTableSpace, LOCKMODE lockmode);
 static void ATExecSetTableSpaceNoStorage(Relation rel, Oid newTableSpace);
+static void ATValidateAccessMethodOptions(List **wqueue);
 static void ATExecSetRelOptions(Relation rel, List *defList,
 								AlterTableType operation,
-								LOCKMODE lockmode);
+								LOCKMODE lockmode,
+								Oid newAccessMethodId);
 static void ATExecEnableDisableTrigger(Relation rel, const char *trigname,
 									   char fires_when, bool skip_system, bool recurse,
 									   LOCKMODE lockmode);
@@ -959,6 +961,49 @@ DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId,
 		case RELKIND_PARTITIONED_TABLE:
 			(void) partitioned_table_reloptions(reloptions, true);
 			break;
+		case RELKIND_RELATION:
+		case RELKIND_MATVIEW:
+			{
+				amoptions_function amoptions = NULL;
+				Oid			amoid = InvalidOid;
+
+				/*
+				 * Resolve the table AM so its option parser can validate
+				 * AM-specific reloptions.  An AM that does not register a
+				 * parser falls back to default_reloptions for
+				 * RELOPT_KIND_HEAP.
+				 */
+				if (stmt->accessMethod != NULL)
+					amoid = get_table_am_oid(stmt->accessMethod, false);
+				else if (stmt->partbound != NULL && inheritOids != NIL)
+					amoid = get_rel_relam(linitial_oid(inheritOids));
+
+				/*
+				 * A partitioned parent may have no AM of its own (relam = 0);
+				 * fall back to default_table_access_method, matching the
+				 * resolution the actual relation creation below uses, so
+				 * options are validated by the same AM that will own the
+				 * relation.
+				 */
+				if (!OidIsValid(amoid))
+					amoid = get_table_am_oid(default_table_access_method, false);
+
+				if (OidIsValid(amoid))
+				{
+					HeapTuple	tuple;
+
+					tuple = SearchSysCache1(AMOID, ObjectIdGetDatum(amoid));
+					if (HeapTupleIsValid(tuple))
+					{
+						Form_pg_am	amform = (Form_pg_am) GETSTRUCT(tuple);
+
+						amoptions = GetTableAmRoutine(amform->amhandler)->amoptions;
+						ReleaseSysCache(tuple);
+					}
+				}
+				(void) table_reloptions(amoptions, relkind, reloptions, true);
+			}
+			break;
 		default:
 			(void) heap_reloptions(relkind, reloptions, true);
 	}
@@ -4947,6 +4992,18 @@ ATController(AlterTableStmt *parsetree,
 	/* Phase 2: update system catalogs */
 	ATRewriteCatalogs(&wqueue, lockmode, context);
 
+	/*
+	 * After all phase-2 subcommands have committed any SET / RESET / REPLACE
+	 * option changes to pg_class, but before any rewrite, ensure the final
+	 * reloptions are accepted by the access method the relation will use once
+	 * the ALTER TABLE finishes.  This catches the case where SET ACCESS
+	 * METHOD changes the AM and leaves pre-existing reloptions in pg_class
+	 * that the new AM does not recognise; without this check the new AM's
+	 * option parser would be called with validate=false at relcache load time
+	 * and silently ignore them.
+	 */
+	ATValidateAccessMethodOptions(&wqueue);
+
 	/* Phase 3: scan/rewrite tables as needed, and run afterStmts */
 	ATRewriteTables(parsetree, &wqueue, lockmode, context);
 }
@@ -5612,7 +5669,17 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
 		case AT_SetRelOptions:	/* SET (...) */
 		case AT_ResetRelOptions:	/* RESET (...) */
 		case AT_ReplaceRelOptions:	/* replace entire option list */
-			ATExecSetRelOptions(rel, (List *) cmd->def, cmd->subtype, lockmode);
+
+			/*
+			 * If SET ACCESS METHOD is queued in the same ALTER TABLE, the
+			 * reloptions in pg_class will be parsed by the new AM after the
+			 * statement finishes; tell ATExecSetRelOptions to validate
+			 * against that AM rather than the relation's current AM.  This
+			 * lets a user write ALTER TABLE t SET ACCESS METHOD x, SET (foo =
+			 * bar) where foo is recognised by x but not by the current AM.
+			 */
+			ATExecSetRelOptions(rel, (List *) cmd->def, cmd->subtype, lockmode,
+								tab->chgAccessMethod ? tab->newAccessMethod : InvalidOid);
 			break;
 		case AT_EnableTrig:		/* ENABLE TRIGGER name */
 			ATExecEnableDisableTrigger(rel, cmd->name,
@@ -17304,12 +17371,91 @@ ATPrepSetTableSpace(AlteredTableInfo *tab, Relation rel, const char *tablespacen
 	tab->newTableSpace = tablespaceId;
 }
 
+/*
+ * Re-validate pg_class.reloptions for every work-queue entry whose access
+ * method is being changed.  Called between phase 2 (catalog updates) and
+ * phase 3 (table rewrites): SET / RESET / REPLACE subcommands have already
+ * been committed to pg_class, and tab->newAccessMethod identifies the AM
+ * the relation will use once the ALTER TABLE finishes.
+ *
+ * The check exists because relcache.c calls the AM's option parser with
+ * validate=false at relation open: any pre-existing reloption that the
+ * new AM does not recognise would otherwise be silently dropped from the
+ * parsed StdRdOptions / AM-specific options struct, leaving the user
+ * unable to tell that the option is no longer in effect.  Failing the
+ * ALTER TABLE here with a clear message lets the user RESET the option
+ * in the same statement and re-run.
+ */
+static void
+ATValidateAccessMethodOptions(List **wqueue)
+{
+	ListCell   *ltab;
+
+	foreach(ltab, *wqueue)
+	{
+		AlteredTableInfo *tab = (AlteredTableInfo *) lfirst(ltab);
+		HeapTuple	amtup;
+		HeapTuple	reltup;
+		Form_pg_am	amform;
+		Form_pg_class relform;
+		amoptions_function amoptions;
+		Datum		reloptions;
+		bool		isnull;
+		Oid			amoid;
+
+		if (!tab->chgAccessMethod)
+			continue;
+
+		/*
+		 * Partitioned tables may reset the AM to "default" (InvalidOid); each
+		 * partition then chooses its own AM at create time, so there is no
+		 * per-relation AM whose parser to consult here.
+		 */
+		amoid = tab->newAccessMethod;
+		if (!OidIsValid(amoid))
+			continue;
+
+		amtup = SearchSysCache1(AMOID, ObjectIdGetDatum(amoid));
+		if (!HeapTupleIsValid(amtup))
+			elog(ERROR, "cache lookup failed for access method %u", amoid);
+		amform = (Form_pg_am) GETSTRUCT(amtup);
+		amoptions = GetTableAmRoutine(amform->amhandler)->amoptions;
+		ReleaseSysCache(amtup);
+
+		/*
+		 * Validate unconditionally, even when the new AM has no option parser
+		 * of its own: table_reloptions() then falls back to the standard heap
+		 * parser, which is exactly what relcache.c will use to reinterpret
+		 * these bytes at the next open.  The old AM may have accepted options
+		 * heap doesn't know (e.g. a custom AM's own options), so "new AM
+		 * falls back to heap" is not itself a reason to skip the check.
+		 */
+		reltup = SearchSysCache1(RELOID, ObjectIdGetDatum(tab->relid));
+		if (!HeapTupleIsValid(reltup))
+			elog(ERROR, "cache lookup failed for relation %u", tab->relid);
+		relform = (Form_pg_class) GETSTRUCT(reltup);
+		reloptions = SysCacheGetAttr(RELOID, reltup,
+									 Anum_pg_class_reloptions, &isnull);
+		if (!isnull)
+			(void) table_reloptions(amoptions, relform->relkind,
+									reloptions, true);
+		ReleaseSysCache(reltup);
+	}
+}
+
 /*
  * Set, reset, or replace reloptions.
+ *
+ * newAccessMethodId, if valid, names the table access method whose option
+ * parser should validate the resulting reloptions.  This is used when SET
+ * ACCESS METHOD is queued in the same ALTER TABLE so that the new options
+ * are checked against the AM the relation will use after the statement
+ * finishes, not the AM it has now.  Pass InvalidOid to use the relation's
+ * current access method.
  */
 static void
 ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
-					LOCKMODE lockmode)
+					LOCKMODE lockmode, Oid newAccessMethodId)
 {
 	Oid			relid;
 	Relation	pgclass;
@@ -17361,7 +17507,30 @@ ATExecSetRelOptions(Relation rel, List *defList, AlterTableType operation,
 	{
 		case RELKIND_RELATION:
 		case RELKIND_MATVIEW:
-			(void) heap_reloptions(rel->rd_rel->relkind, newOptions, true);
+			{
+				amoptions_function amoptions;
+
+				if (OidIsValid(newAccessMethodId))
+				{
+					HeapTuple	amtup;
+					Form_pg_am	amform;
+
+					amtup = SearchSysCache1(AMOID,
+											ObjectIdGetDatum(newAccessMethodId));
+					if (!HeapTupleIsValid(amtup))
+						elog(ERROR, "cache lookup failed for access method %u",
+							 newAccessMethodId);
+					amform = (Form_pg_am) GETSTRUCT(amtup);
+					amoptions = GetTableAmRoutine(amform->amhandler)->amoptions;
+					ReleaseSysCache(amtup);
+				}
+				else
+					amoptions = (rel->rd_tableam ?
+								 rel->rd_tableam->amoptions : NULL);
+
+				(void) table_reloptions(amoptions, rel->rd_rel->relkind,
+										newOptions, true);
+			}
 			break;
 		case RELKIND_PARTITIONED_TABLE:
 			(void) partitioned_table_reloptions(newOptions, true);
diff --git a/src/backend/commands/vacuum.c b/src/backend/commands/vacuum.c
index d8c2f33c615..0d7897b4e44 100644
--- a/src/backend/commands/vacuum.c
+++ b/src/backend/commands/vacuum.c
@@ -2211,7 +2211,8 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 	 * whose parameters the caller handed down for that purpose.  For anything
 	 * else, params.main_relopts is NULL, and this just copies our own.
 	 */
-	relopts = merge_toast_reloptions((StdRdOptions *) rel->rd_options,
+	relopts = merge_toast_reloptions(RelationHasStdRdOptions(rel) ?
+									 (StdRdOptions *) rel->rd_options : NULL,
 									 params.main_relopts);
 
 	/*
@@ -2306,7 +2307,7 @@ vacuum_rel(Oid relid, RangeVar *relation, VacuumParams params,
 	 * a copy while we still have the relation open; the relcache entry can go
 	 * away once we close it.
 	 */
-	if (OidIsValid(toast_relid) && rel->rd_options)
+	if (OidIsValid(toast_relid) && RelationHasStdRdOptions(rel))
 	{
 		memcpy(&relopts_copy, rel->rd_options, sizeof(StdRdOptions));
 		toast_vacuum_params.main_relopts = &relopts_copy;
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index d8f04a05309..dcb298f61db 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -483,9 +483,11 @@ RelationParseRelOptions(Relation relation, HeapTuple tuple)
 	switch (relation->rd_rel->relkind)
 	{
 		case RELKIND_RELATION:
+		case RELKIND_MATVIEW:
+			amoptsfn = relation->rd_tableam ? relation->rd_tableam->amoptions : NULL;
+			break;
 		case RELKIND_TOASTVALUE:
 		case RELKIND_VIEW:
-		case RELKIND_MATVIEW:
 		case RELKIND_PARTITIONED_TABLE:
 			amoptsfn = NULL;
 			break;
diff --git a/src/include/access/reloptions.h b/src/include/access/reloptions.h
index ccff4717b62..dc07f78c470 100644
--- a/src/include/access/reloptions.h
+++ b/src/include/access/reloptions.h
@@ -187,6 +187,7 @@ typedef struct local_relopts
 	 (char *)(optstruct) + (optstruct)->member)
 
 extern relopt_kind add_reloption_kind(void);
+extern void add_reloption_to_kind(const char *name, relopt_kind kind);
 extern void add_bool_reloption(uint32 kinds, const char *name, const char *desc,
 							   bool default_val, LOCKMODE lockmode);
 extern void add_ternary_reloption(uint32 kinds, const char *name,
@@ -250,6 +251,8 @@ extern bytea *default_reloptions(Datum reloptions, bool validate,
 extern struct StdRdOptions *merge_toast_reloptions(const struct StdRdOptions *toast_opts,
 												   const struct StdRdOptions *main_opts);
 extern bytea *heap_reloptions(char relkind, Datum reloptions, bool validate);
+extern bytea *table_reloptions(amoptions_function amoptions, char relkind,
+							   Datum reloptions, bool validate);
 extern bytea *view_reloptions(Datum reloptions, bool validate);
 extern bytea *partitioned_table_reloptions(Datum reloptions, bool validate);
 extern bytea *index_reloptions(amoptions_function amoptions, Datum reloptions,
diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h
index ea3f2a6be99..9079939e5a4 100644
--- a/src/include/access/tableam.h
+++ b/src/include/access/tableam.h
@@ -17,6 +17,7 @@
 #ifndef TABLEAM_H
 #define TABLEAM_H
 
+#include "access/amapi.h"
 #include "access/relscan.h"
 #include "access/sdir.h"
 #include "access/xact.h"
@@ -325,6 +326,65 @@ typedef struct TableAmRoutine
 	NodeTag		type;
 
 
+	/* ------------------------------------------------------------------------
+	 * Reloption parsing.
+	 * ------------------------------------------------------------------------
+	 */
+
+	/*
+	 * Parse and validate AM-specific reloptions.  Optional: when NULL, the
+	 * caller falls back to the standard heap reloption parser
+	 * (default_reloptions with RELOPT_KIND_HEAP) and the result is laid out
+	 * as StdRdOptions.
+	 *
+	 * When non-NULL, the AM owns the option set entirely.  It is free to
+	 * accept all standard heap options, only a subset, or to add its own. The
+	 * returned bytea must begin with a VARSIZE header and is stored in
+	 * Relation->rd_options, so the AM dictates the in-memory layout that its
+	 * other callbacks read.  Core code that reads StdRdOptions fields out of
+	 * rd_options (RelationGetFillFactor, RelationIsUsedAsCatalogTable, ...)
+	 * checks RelationHasStdRdOptions() first, so a custom layout will not be
+	 * misinterpreted -- unless the AM sets has_std_options_prefix below to
+	 * declare that its struct is a StdRdOptions superset after all.
+	 *
+	 * The callback validates user-supplied values but must not silently
+	 * rewrite them: a user inspecting pg_class.reloptions must see exactly
+	 * what they passed in.  Out-of-range or unknown options should be
+	 * reported with ereport(ERROR) when validate is true.
+	 *
+	 * Signature matches the index AM's amoptions callback so the same helper
+	 * machinery (add_string_reloption, add_int_reloption, etc.) can be used.
+	 */
+	amoptions_function amoptions;
+
+	/*
+	 * Set to true when amoptions is non-NULL and the bytea it returns
+	 * begins with a full "StdRdOptions std;" as its first member (i.e. the
+	 * AM's own reloptions struct is a superset of StdRdOptions, not just a
+	 * layout that happens to share a prefix).  This tells core code that it
+	 * is safe to read StdRdOptions fields directly out of rd_options
+	 * (RelationGetFillFactor, RelationIsUsedAsCatalogTable, ...) for
+	 * relations of this AM, exactly as it would for plain heap.
+	 *
+	 * An AM that sets this must register every StdRdOptions field those
+	 * macros read (fillfactor, toast_tuple_target, user_catalog_table,
+	 * parallel_workers, vacuum_index_cleanup, vacuum_truncate,
+	 * vacuum_max_eager_freeze_failure_rate, autovacuum_enabled) with
+	 * add_reloption_to_kind(), even if it exposes none of them as options
+	 * the AM cares about itself: build_reloptions() only fills in fields
+	 * that are registered for the AM's relopt_kind, so an embedded
+	 * StdRdOptions field the AM never registers is left zeroed rather than
+	 * at that option's real default (0 is not a valid "unset" sentinel for
+	 * several of these fields, e.g. parallel_workers and
+	 * vacuum_max_eager_freeze_failure_rate both use -1).  Registering the
+	 * field via add_reloption_to_kind lets each one pick up its normal
+	 * catalog default instead.
+	 *
+	 * Ignored when amoptions is NULL.
+	 */
+	bool		has_std_options_prefix;
+
+
 	/* ------------------------------------------------------------------------
 	 * Slot related callbacks.
 	 * ------------------------------------------------------------------------
diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h
index 7b8c2b1e362..62976db3f03 100644
--- a/src/include/utils/rel.h
+++ b/src/include/utils/rel.h
@@ -381,12 +381,33 @@ typedef struct StdRdOptions
 #define HEAP_MIN_FILLFACTOR			10
 #define HEAP_DEFAULT_FILLFACTOR		100
 
+/*
+ * RelationHasStdRdOptions
+ *		Returns true when the relation's rd_options buffer is safe to read
+ *		as StdRdOptions: either it was produced by the standard heap
+ *		reloption parser (the AM has no amoptions callback), or the AM's
+ *		own amoptions callback returns a struct that embeds a full
+ *		StdRdOptions as its first member and says so via
+ *		TableAmRoutine.has_std_options_prefix.  A table access method that
+ *		supplies amoptions without setting that flag owns its rd_options
+ *		layout entirely and is not required to expose StdRdOptions fields;
+ *		macros that read those fields must check this first to avoid
+ *		reading garbage, or past the end of a smaller custom struct.  For
+ *		indexes and other relkinds rd_options is in an AM-specific layout,
+ *		so this returns false for them.
+ *
+ *		Defined as a function (in reloptions.c) rather than a macro
+ *		because the test needs the full TableAmRoutine struct definition,
+ *		which would create an #include cycle if pulled into rel.h.
+ */
+extern bool RelationHasStdRdOptions(Relation relation);
+
 /*
  * RelationGetToastTupleTarget
  *		Returns the relation's toast_tuple_target.  Note multiple eval of argument!
  */
 #define RelationGetToastTupleTarget(relation, defaulttarg) \
-	((relation)->rd_options ? \
+	(RelationHasStdRdOptions(relation) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->toast_tuple_target : (defaulttarg))
 
 /*
@@ -402,7 +423,7 @@ typedef struct StdRdOptions
  *		Returns the relation's fillfactor.  Note multiple eval of argument!
  */
 #define RelationGetFillFactor(relation, defaultff) \
-	((relation)->rd_options ? \
+	(RelationHasStdRdOptions(relation) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->fillfactor : (defaultff))
 
 /*
@@ -425,7 +446,7 @@ typedef struct StdRdOptions
  *		from the pov of logical decoding.  Note multiple eval of argument!
  */
 #define RelationIsUsedAsCatalogTable(relation)	\
-	((relation)->rd_options && \
+	(RelationHasStdRdOptions(relation) && \
 	 ((relation)->rd_rel->relkind == RELKIND_RELATION || \
 	  (relation)->rd_rel->relkind == RELKIND_MATVIEW) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->user_catalog_table : false)
@@ -436,7 +457,7 @@ typedef struct StdRdOptions
  *		Note multiple eval of argument!
  */
 #define RelationGetParallelWorkers(relation, defaultpw) \
-	((relation)->rd_options ? \
+	(RelationHasStdRdOptions(relation) ? \
 	 ((StdRdOptions *) (relation)->rd_options)->parallel_workers : (defaultpw))
 
 /* ViewOptions->check_option values */
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 71a2e65ad70..e3c0ce7e051 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -10,6 +10,7 @@ SUBDIRS = \
 		  delay_execution \
 		  dummy_index_am \
 		  dummy_seclabel \
+		  dummy_table_am \
 		  index \
 		  libpq_pipeline \
 		  oauth_validator \
diff --git a/src/test/modules/dummy_table_am/Makefile b/src/test/modules/dummy_table_am/Makefile
new file mode 100644
index 00000000000..94837dff392
--- /dev/null
+++ b/src/test/modules/dummy_table_am/Makefile
@@ -0,0 +1,20 @@
+# src/test/modules/dummy_table_am/Makefile
+
+MODULES = dummy_table_am
+
+EXTENSION = dummy_table_am
+DATA = dummy_table_am--1.0.sql
+PGFILEDESC = "dummy_table_am - table access method template"
+
+REGRESS = reloptions
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/dummy_table_am
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/dummy_table_am/README b/src/test/modules/dummy_table_am/README
new file mode 100644
index 00000000000..c3fd263b003
--- /dev/null
+++ b/src/test/modules/dummy_table_am/README
@@ -0,0 +1,23 @@
+Dummy Table AM
+==============
+
+Dummy table AM is a module for testing the table access method
+amoptions callback and the add_reloption_to_kind() helper.  It
+delegates all storage and scan callbacks to the heap AM and only
+swaps in its own option parser, so a relation created with USING
+dummy_table_am behaves like a heap table but accepts a different
+set of reloptions:
+
+  - every field of StdRdOptions (fillfactor, toast_tuple_target,
+    toast_value_type, parallel_workers, vacuum_index_cleanup,
+    vacuum_truncate, vacuum_max_eager_freeze_failure_rate,
+    autovacuum_enabled, user_catalog_table), inherited from the core
+    heap registration via add_reloption_to_kind()
+  - "option_int"     (integer)
+  - "option_real"    (real)
+  - "option_bool"    (boolean)
+  - "option_enum"    (enum, one|two)
+
+The rest of the autovacuum_* family (e.g. autovacuum_vacuum_threshold)
+is intentionally NOT accepted, to exercise the "AM rejects an unknown
+option" path in ALTER TABLE ... SET ACCESS METHOD revalidation.
diff --git a/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql b/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
new file mode 100644
index 00000000000..2e295b95845
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am--1.0.sql
@@ -0,0 +1,13 @@
+/* src/test/modules/dummy_table_am/dummy_table_am--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION dummy_table_am" to load this file. \quit
+
+CREATE FUNCTION dthandler(internal)
+RETURNS table_am_handler
+AS 'MODULE_PATHNAME'
+LANGUAGE C;
+
+-- Access method
+CREATE ACCESS METHOD dummy_table_am TYPE TABLE HANDLER dthandler;
+COMMENT ON ACCESS METHOD dummy_table_am IS 'dummy table access method';
diff --git a/src/test/modules/dummy_table_am/dummy_table_am.c b/src/test/modules/dummy_table_am/dummy_table_am.c
new file mode 100644
index 00000000000..791416e3e08
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am.c
@@ -0,0 +1,263 @@
+/*-------------------------------------------------------------------------
+ *
+ * dummy_table_am.c
+ *		Table AM template main file.
+ *
+ * This module exists primarily to demonstrate and exercise the table AM
+ * amoptions callback and the add_reloption_to_kind() helper.  Storage
+ * and scan callbacks are delegated to the heap AM, so a relation
+ * created with USING dummy_table_am behaves like a heap table; only the
+ * reloption surface differs.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/test/modules/dummy_table_am/dummy_table_am.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/reloptions.h"
+#include "access/tableam.h"
+#include "catalog/pg_am_d.h"
+#include "fmgr.h"
+#include "utils/rel.h"
+
+PG_MODULE_MAGIC;
+
+/* Parse table for build_reloptions: 8 inherited standard options + 4 of our own */
+static relopt_parse_elt dt_relopt_tab[13];
+
+/* Kind of relation options for dummy table */
+static relopt_kind dt_relopt_kind;
+
+typedef enum DummyTableEnum
+{
+	DUMMY_TABLE_ENUM_ONE,
+	DUMMY_TABLE_ENUM_TWO,
+}			DummyTableEnum;
+
+/*
+ * Dummy table options.
+ *
+ * This AM sets TableAmRoutine.has_std_options_prefix (see dthandler()
+ * below), which promises core code that rd_options begins with a complete,
+ * valid StdRdOptions it may read directly -- RelationGetFillFactor(), the
+ * autovacuum option readers, and so on.  "std" is that StdRdOptions, and
+ * must be the first member.
+ *
+ * The promise only holds if every field of "std" carries a sensible value
+ * even when the user set nothing.  build_reloptions() fills exactly the
+ * fields listed in the parse table (with the option's default when unset)
+ * and leaves the rest zeroed -- and zero is the wrong "unset" value for
+ * several of them (parallel_workers and
+ * vacuum_max_eager_freeze_failure_rate both use -1; fillfactor's default
+ * is HEAP_DEFAULT_FILLFACTOR).  That is why create_reloptions_table()
+ * inherits and registers every option heap's default_reloptions()
+ * understands, not just the ones this module is interesting for.
+ *
+ * The remaining four are AM-specific options that only dummy_table_am
+ * knows about.
+ */
+typedef struct DummyTableOptions
+{
+	StdRdOptions std;			/* must be first, see above */
+	int			option_int;
+	double		option_real;
+	bool		option_bool;
+	DummyTableEnum option_enum;
+}			DummyTableOptions;
+
+static relopt_enum_elt_def dummyTableEnumValues[] =
+{
+	{"one", DUMMY_TABLE_ENUM_ONE},
+	{"two", DUMMY_TABLE_ENUM_TWO},
+	{(const char *) NULL}		/* list terminator */
+};
+
+PG_FUNCTION_INFO_V1(dthandler);
+
+/*
+ * Register a relopt_kind for this AM and populate the parse table.
+ */
+static void
+create_reloptions_table(void)
+{
+	int			i = 0;
+
+	dt_relopt_kind = add_reloption_kind();
+
+	/*
+	 * Accept every standard option that core's default_reloptions()
+	 * understands (registered for RELOPT_KIND_HEAP and/or RELOPT_KIND_TOAST)
+	 * under our own kind.  This is the canonical use of
+	 * add_reloption_to_kind(): an AM that wants to honour existing
+	 * core-registered options without duplicating their definitions.  All of
+	 * them, not just the interesting ones, must be both inherited and listed
+	 * in the parse table, or the corresponding DummyTableOptions.std fields
+	 * would stay zeroed rather than get their defaults -- and core code reads
+	 * those fields directly because of has_std_options_prefix (see the
+	 * comment on DummyTableOptions).
+	 */
+	add_reloption_to_kind("fillfactor", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "fillfactor";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.fillfactor);
+	i++;
+
+	add_reloption_to_kind("toast_tuple_target", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "toast_tuple_target";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.toast_tuple_target);
+	i++;
+
+	add_reloption_to_kind("toast_value_type", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "toast_value_type";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_ENUM;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.toast_value_type);
+	i++;
+
+	add_reloption_to_kind("parallel_workers", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "parallel_workers";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.parallel_workers);
+	i++;
+
+	add_reloption_to_kind("vacuum_index_cleanup", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "vacuum_index_cleanup";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_ENUM;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.vacuum_index_cleanup);
+	i++;
+
+	add_reloption_to_kind("vacuum_truncate", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "vacuum_truncate";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_TERNARY;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.vacuum_truncate);
+	i++;
+
+	add_reloption_to_kind("vacuum_max_eager_freeze_failure_rate", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "vacuum_max_eager_freeze_failure_rate";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_REAL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.vacuum_max_eager_freeze_failure_rate);
+	i++;
+
+	add_reloption_to_kind("autovacuum_enabled", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "autovacuum_enabled";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_TERNARY;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.autovacuum.enabled);
+	i++;
+
+	add_reloption_to_kind("user_catalog_table", dt_relopt_kind);
+	dt_relopt_tab[i].optname = "user_catalog_table";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_BOOL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, std.user_catalog_table);
+	i++;
+
+	add_int_reloption(dt_relopt_kind, "option_int",
+					  "Integer option for dummy_table_am",
+					  10, -10, 100, AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_int";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_INT;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_int);
+	i++;
+
+	add_real_reloption(dt_relopt_kind, "option_real",
+					   "Real option for dummy_table_am",
+					   3.1415, -10, 100, AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_real";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_REAL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_real);
+	i++;
+
+	add_bool_reloption(dt_relopt_kind, "option_bool",
+					   "Boolean option for dummy_table_am",
+					   true, AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_bool";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_BOOL;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_bool);
+	i++;
+
+	add_enum_reloption(dt_relopt_kind, "option_enum",
+					   "Enum option for dummy_table_am",
+					   dummyTableEnumValues,
+					   DUMMY_TABLE_ENUM_ONE,
+					   "Valid values are \"one\" and \"two\".",
+					   AccessExclusiveLock);
+	dt_relopt_tab[i].optname = "option_enum";
+	dt_relopt_tab[i].opttype = RELOPT_TYPE_ENUM;
+	dt_relopt_tab[i].offset = offsetof(DummyTableOptions, option_enum);
+	i++;
+}
+
+/*
+ * Parse reloptions for dummy_table_am.
+ *
+ * Returning DummyTableOptions tells the caller (relcache.c) to store
+ * exactly that layout in Relation->rd_options.
+ */
+static bytea *
+dtoptions(Datum reloptions, bool validate)
+{
+	return (bytea *) build_reloptions(reloptions, validate,
+									  dt_relopt_kind,
+									  sizeof(DummyTableOptions),
+									  dt_relopt_tab, lengthof(dt_relopt_tab));
+}
+
+/*
+ * heapam_relation_toast_am() (heap's own relation_toast_am callback, which
+ * we would otherwise inherit unchanged along with the rest of heap's
+ * routine) returns rel->rd_rel->relam -- correct for a real heap table, but
+ * for dummy_table_am that's dummy_table_am's own oid, not heap's.  That
+ * would make this AM's TOAST tables dummy_table_am relations too, and
+ * building their chunk_id/chunk_seq index fails as soon as it's scanned,
+ * since that scan goes through heap_getnext() directly.  Override it to
+ * return the literal heap AM oid: this AM's TOAST tables are always plain
+ * heap, regardless of what created the owning table.
+ */
+static Oid
+dummy_table_relation_toast_am(Relation rel)
+{
+	return HEAP_TABLE_AM_OID;
+}
+
+/*
+ * Handler for table AM.
+ *
+ * All storage-side callbacks are inherited from heap; we swap in our own
+ * amoptions so that the AM owns its reloption set, and our own
+ * relation_toast_am (see dummy_table_relation_toast_am() above).  This
+ * keeps the example focused on the new API without duplicating the heap
+ * AM.
+ *
+ * has_std_options_prefix is set because DummyTableOptions embeds a full
+ * StdRdOptions as its first member with every field populated (see the
+ * comment on DummyTableOptions): that makes it safe for core code to keep
+ * reading fillfactor and friends directly out of rd_options, exactly as
+ * it would for a plain heap table.
+ */
+Datum
+dthandler(PG_FUNCTION_ARGS)
+{
+	static TableAmRoutine routine;
+	static bool initialized = false;
+
+	if (!initialized)
+	{
+		memcpy(&routine, GetHeapamTableAmRoutine(), sizeof(routine));
+		routine.amoptions = dtoptions;
+		routine.has_std_options_prefix = true;
+		routine.relation_toast_am = dummy_table_relation_toast_am;
+		initialized = true;
+	}
+
+	PG_RETURN_POINTER(&routine);
+}
+
+void
+_PG_init(void)
+{
+	create_reloptions_table();
+}
diff --git a/src/test/modules/dummy_table_am/dummy_table_am.control b/src/test/modules/dummy_table_am/dummy_table_am.control
new file mode 100644
index 00000000000..08f2f868d49
--- /dev/null
+++ b/src/test/modules/dummy_table_am/dummy_table_am.control
@@ -0,0 +1,5 @@
+# dummy_table_am extension
+comment = 'dummy_table_am - table access method template'
+default_version = '1.0'
+module_pathname = '$libdir/dummy_table_am'
+relocatable = true
diff --git a/src/test/modules/dummy_table_am/expected/reloptions.out b/src/test/modules/dummy_table_am/expected/reloptions.out
new file mode 100644
index 00000000000..e7e9bee4395
--- /dev/null
+++ b/src/test/modules/dummy_table_am/expected/reloptions.out
@@ -0,0 +1,258 @@
+-- Tests for the table AM amoptions callback and add_reloption_to_kind()
+CREATE EXTENSION dummy_table_am;
+-- Sanity: CREATE TABLE with AM-specific options succeeds and round-trips
+CREATE TABLE dummy_t (a int) USING dummy_table_am
+    WITH (option_int = 17, option_real = 2.5, option_bool = false,
+          option_enum = 'two', fillfactor = 60);
+SELECT reloptions FROM pg_class
+    WHERE oid = 'dummy_t'::regclass ORDER BY reloptions;
+                                   reloptions                                    
+---------------------------------------------------------------------------------
+ {option_int=17,option_real=2.5,option_bool=false,option_enum=two,fillfactor=60}
+(1 row)
+
+-- AM-specific option ranges are enforced (option_int allows -10..100)
+CREATE TABLE dummy_oor (a int) USING dummy_table_am WITH (option_int = 9999);
+ERROR:  value 9999 out of bounds for option "option_int"
+DETAIL:  Valid values are between "-10" and "100".
+-- Unknown options are rejected at CREATE TABLE time
+CREATE TABLE dummy_bad (a int) USING dummy_table_am WITH (autovacuum_vacuum_threshold = 4);
+ERROR:  unrecognized parameter "autovacuum_vacuum_threshold"
+-- Default values land in pg_class only when the user did not set them
+CREATE TABLE dummy_defaults (a int) USING dummy_table_am;
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_defaults'::regclass;
+ reloptions 
+------------
+ 
+(1 row)
+
+DROP TABLE dummy_defaults;
+-- ALTER TABLE ... SET (...) with AM-specific option
+ALTER TABLE dummy_t SET (option_int = 42);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+                                   reloptions                                    
+---------------------------------------------------------------------------------
+ {option_real=2.5,option_bool=false,option_enum=two,fillfactor=60,option_int=42}
+(1 row)
+
+-- ALTER TABLE ... SET (...) with an unknown option errors
+ALTER TABLE dummy_t SET (autovacuum_vacuum_threshold = 4);
+ERROR:  unrecognized parameter "autovacuum_vacuum_threshold"
+-- ALTER TABLE ... RESET (option) round-trips
+ALTER TABLE dummy_t RESET (option_int);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+                            reloptions                             
+-------------------------------------------------------------------
+ {option_real=2.5,option_bool=false,option_enum=two,fillfactor=60}
+(1 row)
+
+-- SET ACCESS METHOD revalidation:
+--   moving a heap table that has standard heap options not accepted by the
+--   new AM (autovacuum_vacuum_threshold; dummy_table_am inherits
+--   autovacuum_enabled but not the rest of the autovacuum_* family) into
+--   dummy_table_am must fail with a clear message and must NOT silently
+--   drop the option.
+CREATE TABLE heap_t (a int) WITH (fillfactor = 70, autovacuum_vacuum_threshold = 4);
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+                  reloptions                   
+-----------------------------------------------
+ {fillfactor=70,autovacuum_vacuum_threshold=4}
+(1 row)
+
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am;
+ERROR:  unrecognized parameter "autovacuum_vacuum_threshold"
+-- Confirm nothing changed
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+ amname 
+--------
+ heap
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+                  reloptions                   
+-----------------------------------------------
+ {fillfactor=70,autovacuum_vacuum_threshold=4}
+(1 row)
+
+-- After RESETing the offending option in the same statement the swap
+-- succeeds; fillfactor survives because dummy_table_am inherits it via
+-- add_reloption_to_kind().
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am, RESET (autovacuum_vacuum_threshold);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+     amname     
+----------------
+ dummy_table_am
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+   reloptions    
+-----------------
+ {fillfactor=70}
+(1 row)
+
+-- Going back to heap still works: heap accepts fillfactor.
+ALTER TABLE heap_t SET ACCESS METHOD heap;
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+ amname 
+--------
+ heap
+(1 row)
+
+-- SET ACCESS METHOD + SET (...) of an option that only the new AM accepts.
+CREATE TABLE heap_to_dt (a int);
+ALTER TABLE heap_to_dt SET ACCESS METHOD dummy_table_am, SET (option_int = 25);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+     amname     
+----------------
+ dummy_table_am
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+   reloptions    
+-----------------
+ {option_int=25}
+(1 row)
+
+-- The reverse direction must be caught too: heap has no amoptions of its
+-- own (table_reloptions() just falls back to heap_reloptions()), but that
+-- is not a reason to skip validation.  option_int is dummy_table_am-only,
+-- so switching back to heap while it is still set must fail the same way,
+-- not silently drop it at the next relcache load.
+ALTER TABLE heap_to_dt SET ACCESS METHOD heap;
+ERROR:  unrecognized parameter "option_int"
+-- Confirm nothing changed
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+     amname     
+----------------
+ dummy_table_am
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+   reloptions    
+-----------------
+ {option_int=25}
+(1 row)
+
+-- RESETting the offending option in the same statement lets it through
+ALTER TABLE heap_to_dt SET ACCESS METHOD heap, RESET (option_int);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+ amname 
+--------
+ heap
+(1 row)
+
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+ reloptions 
+------------
+ 
+(1 row)
+
+-- fillfactor genuinely reaches heap's own page-packing logic now, not just
+-- pg_class.reloptions: dummy_table_am embeds a full StdRdOptions as the
+-- first member of its own options struct and sets
+-- TableAmRoutine.has_std_options_prefix, so RelationGetFillFactor() can read
+-- it directly instead of always seeing the hardcoded default.
+CREATE TABLE dummy_ff10 (a int) USING dummy_table_am WITH (fillfactor = 10);
+CREATE TABLE dummy_ff100 (a int) USING dummy_table_am WITH (fillfactor = 100);
+INSERT INTO dummy_ff10 SELECT generate_series(1, 5000);
+INSERT INTO dummy_ff100 SELECT generate_series(1, 5000);
+VACUUM dummy_ff10;
+VACUUM dummy_ff100;
+SELECT (SELECT relpages FROM pg_class WHERE oid = 'dummy_ff10'::regclass) >
+       (SELECT relpages FROM pg_class WHERE oid = 'dummy_ff100'::regclass)
+       AS low_fillfactor_uses_more_pages;
+ low_fillfactor_uses_more_pages 
+--------------------------------
+ t
+(1 row)
+
+DROP TABLE dummy_ff10;
+DROP TABLE dummy_ff100;
+-- A table with a toastable column works: dummy_table_am overrides
+-- relation_toast_am rather than inheriting heap's, which would return
+-- this AM's own oid instead of heap's for its TOAST table, making that
+-- TOAST table itself a dummy_table_am relation and failing as soon as
+-- its chunk_id/chunk_seq index was built (that scan goes through
+-- heap_getnext() directly, which requires a real heap relation).
+CREATE TABLE dummy_txt (a int, b text) USING dummy_table_am;
+INSERT INTO dummy_txt VALUES (1, repeat('x', 10000));
+SELECT a, length(b) FROM dummy_txt;
+ a | length 
+---+--------
+ 1 |  10000
+(1 row)
+
+DROP TABLE dummy_txt;
+-- The same with a reloption set: rd_options is non-NULL going into
+-- create_toast_table(), which reads toast_value_type straight out of it
+-- via RelationGetToastValueType().  toast_value_type must be registered
+-- like every other StdRdOptions field, or that read finds it zeroed
+-- rather than defaulted to STDRD_OPTION_TOAST_VALUE_TYPE_OID.
+CREATE TABLE dummy_txt_opt (a int, b text)
+    USING dummy_table_am WITH (option_int = 7);
+INSERT INTO dummy_txt_opt VALUES (1, repeat('x', 10000));
+SELECT a, length(b) FROM dummy_txt_opt;
+ a | length 
+---+--------
+ 1 |  10000
+(1 row)
+
+DROP TABLE dummy_txt_opt;
+-- Partitioned-table inheritance: AM declared on the parent partition flows
+-- to partitions that don't override it.  Partitioned tables themselves
+-- cannot carry reloptions; the test verifies the AM lookup that
+-- DefineRelation does for partitions.
+CREATE TABLE parted (a int) PARTITION BY RANGE (a) USING dummy_table_am;
+CREATE TABLE parted_p1 PARTITION OF parted FOR VALUES FROM (0) TO (100)
+    WITH (option_int = 11);
+SELECT c.relname,
+       (SELECT amname FROM pg_am WHERE oid = c.relam) AS amname,
+       c.reloptions
+    FROM pg_class c
+    WHERE c.oid IN ('parted'::regclass, 'parted_p1'::regclass)
+    ORDER BY c.relname;
+  relname  |     amname     |   reloptions    
+-----------+----------------+-----------------
+ parted    | dummy_table_am | 
+ parted_p1 | dummy_table_am | {option_int=11}
+(2 rows)
+
+-- A partition that explicitly chooses heap must reject options that are
+-- only known to the parent's AM.
+CREATE TABLE parted_p2 PARTITION OF parted FOR VALUES FROM (100) TO (200)
+    USING heap WITH (option_int = 9);
+ERROR:  unrecognized parameter "option_int"
+-- A parent created without USING has no AM of its own (relam = 0); a
+-- partition of it takes default_table_access_method, so its reloptions
+-- must be validated by that AM, not silently fall through to heap's
+-- parser (which would reject the AM's own options and accept heap-only
+-- ones the AM would then drop).
+SET default_table_access_method = dummy_table_am;
+CREATE TABLE parted_noam (a int) PARTITION BY RANGE (a);
+CREATE TABLE parted_noam_p1 PARTITION OF parted_noam
+    FOR VALUES FROM (0) TO (100) WITH (option_int = 12);
+SELECT c.relname,
+       (SELECT amname FROM pg_am WHERE oid = c.relam) AS amname,
+       c.reloptions
+    FROM pg_class c
+    WHERE c.oid IN ('parted_noam'::regclass, 'parted_noam_p1'::regclass)
+    ORDER BY c.relname;
+    relname     |     amname     |   reloptions    
+----------------+----------------+-----------------
+ parted_noam    |                | 
+ parted_noam_p1 | dummy_table_am | {option_int=12}
+(2 rows)
+
+RESET default_table_access_method;
+DROP TABLE parted_noam;
+DROP TABLE parted;
+DROP TABLE heap_to_dt;
+DROP TABLE heap_t;
+DROP TABLE dummy_t;
+DROP EXTENSION dummy_table_am;
diff --git a/src/test/modules/dummy_table_am/meson.build b/src/test/modules/dummy_table_am/meson.build
new file mode 100644
index 00000000000..ad3fa2410cc
--- /dev/null
+++ b/src/test/modules/dummy_table_am/meson.build
@@ -0,0 +1,33 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+dummy_table_am_sources = files(
+  'dummy_table_am.c',
+)
+
+if host_system == 'windows'
+  dummy_table_am_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'dummy_table_am',
+    '--FILEDESC', 'dummy_table_am - table access method template',])
+endif
+
+dummy_table_am = shared_module('dummy_table_am',
+  dummy_table_am_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += dummy_table_am
+
+test_install_data += files(
+  'dummy_table_am.control',
+  'dummy_table_am--1.0.sql',
+)
+
+tests += {
+  'name': 'dummy_table_am',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'regress': {
+    'sql': [
+      'reloptions',
+    ],
+  },
+}
diff --git a/src/test/modules/dummy_table_am/sql/reloptions.sql b/src/test/modules/dummy_table_am/sql/reloptions.sql
new file mode 100644
index 00000000000..94bab5cfbae
--- /dev/null
+++ b/src/test/modules/dummy_table_am/sql/reloptions.sql
@@ -0,0 +1,164 @@
+-- Tests for the table AM amoptions callback and add_reloption_to_kind()
+CREATE EXTENSION dummy_table_am;
+
+-- Sanity: CREATE TABLE with AM-specific options succeeds and round-trips
+CREATE TABLE dummy_t (a int) USING dummy_table_am
+    WITH (option_int = 17, option_real = 2.5, option_bool = false,
+          option_enum = 'two', fillfactor = 60);
+SELECT reloptions FROM pg_class
+    WHERE oid = 'dummy_t'::regclass ORDER BY reloptions;
+
+-- AM-specific option ranges are enforced (option_int allows -10..100)
+CREATE TABLE dummy_oor (a int) USING dummy_table_am WITH (option_int = 9999);
+
+-- Unknown options are rejected at CREATE TABLE time
+CREATE TABLE dummy_bad (a int) USING dummy_table_am WITH (autovacuum_vacuum_threshold = 4);
+
+-- Default values land in pg_class only when the user did not set them
+CREATE TABLE dummy_defaults (a int) USING dummy_table_am;
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_defaults'::regclass;
+DROP TABLE dummy_defaults;
+
+-- ALTER TABLE ... SET (...) with AM-specific option
+ALTER TABLE dummy_t SET (option_int = 42);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+
+-- ALTER TABLE ... SET (...) with an unknown option errors
+ALTER TABLE dummy_t SET (autovacuum_vacuum_threshold = 4);
+
+-- ALTER TABLE ... RESET (option) round-trips
+ALTER TABLE dummy_t RESET (option_int);
+SELECT reloptions FROM pg_class WHERE oid = 'dummy_t'::regclass;
+
+-- SET ACCESS METHOD revalidation:
+--   moving a heap table that has standard heap options not accepted by the
+--   new AM (autovacuum_vacuum_threshold; dummy_table_am inherits
+--   autovacuum_enabled but not the rest of the autovacuum_* family) into
+--   dummy_table_am must fail with a clear message and must NOT silently
+--   drop the option.
+CREATE TABLE heap_t (a int) WITH (fillfactor = 70, autovacuum_vacuum_threshold = 4);
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am;
+-- Confirm nothing changed
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+
+-- After RESETing the offending option in the same statement the swap
+-- succeeds; fillfactor survives because dummy_table_am inherits it via
+-- add_reloption_to_kind().
+ALTER TABLE heap_t SET ACCESS METHOD dummy_table_am, RESET (autovacuum_vacuum_threshold);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_t'::regclass;
+
+-- Going back to heap still works: heap accepts fillfactor.
+ALTER TABLE heap_t SET ACCESS METHOD heap;
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_t'::regclass;
+
+-- SET ACCESS METHOD + SET (...) of an option that only the new AM accepts.
+CREATE TABLE heap_to_dt (a int);
+ALTER TABLE heap_to_dt SET ACCESS METHOD dummy_table_am, SET (option_int = 25);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+
+-- The reverse direction must be caught too: heap has no amoptions of its
+-- own (table_reloptions() just falls back to heap_reloptions()), but that
+-- is not a reason to skip validation.  option_int is dummy_table_am-only,
+-- so switching back to heap while it is still set must fail the same way,
+-- not silently drop it at the next relcache load.
+ALTER TABLE heap_to_dt SET ACCESS METHOD heap;
+-- Confirm nothing changed
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+-- RESETting the offending option in the same statement lets it through
+ALTER TABLE heap_to_dt SET ACCESS METHOD heap, RESET (option_int);
+SELECT amname FROM pg_class c JOIN pg_am a ON a.oid = c.relam
+    WHERE c.oid = 'heap_to_dt'::regclass;
+SELECT reloptions FROM pg_class WHERE oid = 'heap_to_dt'::regclass;
+
+-- fillfactor genuinely reaches heap's own page-packing logic now, not just
+-- pg_class.reloptions: dummy_table_am embeds a full StdRdOptions as the
+-- first member of its own options struct and sets
+-- TableAmRoutine.has_std_options_prefix, so RelationGetFillFactor() can read
+-- it directly instead of always seeing the hardcoded default.
+CREATE TABLE dummy_ff10 (a int) USING dummy_table_am WITH (fillfactor = 10);
+CREATE TABLE dummy_ff100 (a int) USING dummy_table_am WITH (fillfactor = 100);
+INSERT INTO dummy_ff10 SELECT generate_series(1, 5000);
+INSERT INTO dummy_ff100 SELECT generate_series(1, 5000);
+VACUUM dummy_ff10;
+VACUUM dummy_ff100;
+SELECT (SELECT relpages FROM pg_class WHERE oid = 'dummy_ff10'::regclass) >
+       (SELECT relpages FROM pg_class WHERE oid = 'dummy_ff100'::regclass)
+       AS low_fillfactor_uses_more_pages;
+DROP TABLE dummy_ff10;
+DROP TABLE dummy_ff100;
+
+-- A table with a toastable column works: dummy_table_am overrides
+-- relation_toast_am rather than inheriting heap's, which would return
+-- this AM's own oid instead of heap's for its TOAST table, making that
+-- TOAST table itself a dummy_table_am relation and failing as soon as
+-- its chunk_id/chunk_seq index was built (that scan goes through
+-- heap_getnext() directly, which requires a real heap relation).
+CREATE TABLE dummy_txt (a int, b text) USING dummy_table_am;
+INSERT INTO dummy_txt VALUES (1, repeat('x', 10000));
+SELECT a, length(b) FROM dummy_txt;
+DROP TABLE dummy_txt;
+
+-- The same with a reloption set: rd_options is non-NULL going into
+-- create_toast_table(), which reads toast_value_type straight out of it
+-- via RelationGetToastValueType().  toast_value_type must be registered
+-- like every other StdRdOptions field, or that read finds it zeroed
+-- rather than defaulted to STDRD_OPTION_TOAST_VALUE_TYPE_OID.
+CREATE TABLE dummy_txt_opt (a int, b text)
+    USING dummy_table_am WITH (option_int = 7);
+INSERT INTO dummy_txt_opt VALUES (1, repeat('x', 10000));
+SELECT a, length(b) FROM dummy_txt_opt;
+DROP TABLE dummy_txt_opt;
+
+-- Partitioned-table inheritance: AM declared on the parent partition flows
+-- to partitions that don't override it.  Partitioned tables themselves
+-- cannot carry reloptions; the test verifies the AM lookup that
+-- DefineRelation does for partitions.
+CREATE TABLE parted (a int) PARTITION BY RANGE (a) USING dummy_table_am;
+CREATE TABLE parted_p1 PARTITION OF parted FOR VALUES FROM (0) TO (100)
+    WITH (option_int = 11);
+SELECT c.relname,
+       (SELECT amname FROM pg_am WHERE oid = c.relam) AS amname,
+       c.reloptions
+    FROM pg_class c
+    WHERE c.oid IN ('parted'::regclass, 'parted_p1'::regclass)
+    ORDER BY c.relname;
+
+-- A partition that explicitly chooses heap must reject options that are
+-- only known to the parent's AM.
+CREATE TABLE parted_p2 PARTITION OF parted FOR VALUES FROM (100) TO (200)
+    USING heap WITH (option_int = 9);
+
+-- A parent created without USING has no AM of its own (relam = 0); a
+-- partition of it takes default_table_access_method, so its reloptions
+-- must be validated by that AM, not silently fall through to heap's
+-- parser (which would reject the AM's own options and accept heap-only
+-- ones the AM would then drop).
+SET default_table_access_method = dummy_table_am;
+CREATE TABLE parted_noam (a int) PARTITION BY RANGE (a);
+CREATE TABLE parted_noam_p1 PARTITION OF parted_noam
+    FOR VALUES FROM (0) TO (100) WITH (option_int = 12);
+SELECT c.relname,
+       (SELECT amname FROM pg_am WHERE oid = c.relam) AS amname,
+       c.reloptions
+    FROM pg_class c
+    WHERE c.oid IN ('parted_noam'::regclass, 'parted_noam_p1'::regclass)
+    ORDER BY c.relname;
+RESET default_table_access_method;
+DROP TABLE parted_noam;
+
+DROP TABLE parted;
+DROP TABLE heap_to_dt;
+DROP TABLE heap_t;
+DROP TABLE dummy_t;
+
+DROP EXTENSION dummy_table_am;
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 77e1a2810e5..d1fcbae0cdb 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -5,6 +5,7 @@ subdir('commit_ts')
 subdir('delay_execution')
 subdir('dummy_index_am')
 subdir('dummy_seclabel')
+subdir('dummy_table_am')
 subdir('gin')
 subdir('index')
 subdir('injection_points')
-- 
2.43.0



^ permalink  raw  reply  [nested|flat] 25+ messages in thread


end of thread, other threads:[~2026-09-28 13:23 UTC | newest]

Thread overview: 25+ messages (download: mbox mbox.gz follow: Atom feed)
-- links below jump to the message on this page --
2025-03-02 08:56 Allow table AMs to define their own reloptions Julien Tachoires <julien@tachoires.me>
2025-03-02 13:23 ` Julien Tachoires <julien@tachoires.me>
2025-03-02 15:20   ` Yura Sokolov <y.sokolov@postgrespro.ru>
2025-03-04 06:16     ` Julien Tachoires <julien@tachoires.me>
2025-03-04 09:19       ` Yura Sokolov <y.sokolov@postgrespro.ru>
2025-03-29 07:46   ` Julien Tachoires <julien@tachoires.me>
2025-05-26 11:06     ` Julien Tachoires <julien@tachoires.me>
2026-06-03 23:02       ` Andrew Dunstan <andrew@dunslane.net>
2026-07-16 13:37         ` Rafia Sabih <rafia.pghackers@gmail.com>
2026-07-16 14:32           ` Andrew Dunstan <andrew@dunslane.net>
2026-07-20 06:52             ` Rafia Sabih <rafia.pghackers@gmail.com>
2026-08-13 11:11               ` Andrew Dunstan <andrew@dunslane.net>
2026-08-14 21:46             ` Zsolt Parragi <zsolt.parragi@percona.com>
2026-08-17 09:45               ` Junwang Zhao <zhjwpku@gmail.com>
2026-08-26 15:53                 ` Andrew Dunstan <andrew@dunslane.net>
2026-08-28 19:43                   ` Andrew Dunstan <andrew@dunslane.net>
2026-08-31 12:27                     ` Andrew Dunstan <andrew@dunslane.net>
2026-09-07 11:15                       ` Ajit Awekar <ajitpostgres@gmail.com>
2026-09-07 12:41                       ` Aleksander Alekseev <aleksander@tigerdata.com>
2026-09-08 10:03                         ` Ajit Awekar <ajitpostgres@gmail.com>
2026-09-08 12:34                           ` Ajit Awekar <ajitpostgres@gmail.com>
2026-09-28 13:23                             ` Andrew Dunstan <andrew@dunslane.net>
2026-09-10 17:50                         ` Andrew Dunstan <andrew@dunslane.net>
2026-08-21 19:09               ` Andrew Dunstan <andrew@dunslane.net>
2026-08-17 09:40             ` Junwang Zhao <zhjwpku@gmail.com>

This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox