Subject: Re: Allow table AMs to define their own reloptions

From: Julien Tachoires <[email-masked]>
Date: Sun, 2 Mar 2025 14:23:54 +0100

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.com
> [2] https://www.postgresql.org/message-id/flat/[email-masked]
> [3] https://www.postgresql.org/message-id/flat/AMUA1wBBBxfc3tKRLLdU64rb.1.1683276279979.Hmail.wuhao%40hashdata.cn

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

-- 
Julien Tachoires
>From 4b20842ae509f6c330c48e67944442fd4c966e3f Mon Sep 17 00:00:00 2001
From: Julien Tachoires <[email-masked]>
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

>From 769cdb2d0c4e9630bf5f60dd30cf391f623d7333 Mon Sep 17 00:00:00 2001
From: Julien Tachoires <[email-masked]>
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

---

From: Yura Sokolov <[email-masked]>
Date: Sun, 2 Mar 2025 18:20:07 +0300

> Please find a new version including minor fixes: 'TAM' terms are

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

---

From: Julien Tachoires <[email-masked]>
Date: Tue, 4 Mar 2025 07:16:19 +0100

Hi Yura,

> [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

---

From: Julien Tachoires <[email-masked]>
Date: Sat, 29 Mar 2025 08:46:01 +0100

> Please find a new version including minor fixes: 'TAM' terms are

Please find a new rebased version. 

-- 
Julien Tachoires

---

From: Julien Tachoires <[email-masked]>
Date: Mon, 26 May 2025 13:06:19 +0200

> Please find a new rebased version. 

New rebased version.

-- 
Julien Tachoires

---

From: Andrew Dunstan <[email-masked]>
Date: Wed, 3 Jun 2026 19:02:11 -0400

>> Please find a 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
From 8a969146cecf5c5c04404d2c140e4b07f4519acb Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <[email-masked]>
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