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