From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Andrew Dunstan <andrew@dunslane.net>
Date: Sat, 29 Aug 2026 11:00:02 -0400
Subject: [PATCH v8] Add amoptions callback to table access methods

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

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

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

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

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

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

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

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

