Skip to content

feat(postgres)!: the catalogue as nodes, and one introspect question per family - #63

Merged
vistart merged 77 commits into
release/v1.0.0.dev17from
fix/column-constraint-gates
Oct 31, 2026
Merged

vistart merged 77 commits into
release/v1.0.0.dev17from
fix/column-constraint-gates

Conversation

@vistart

@vistart vistart commented Oct 31, 2026

Copy link
Copy Markdown
Contributor

Description

Lands fix/column-constraint-gates on release/v1.0.0.dev17.

Scope up front: this branch carries the whole cycle started on 2026-10-11 as it reaches this backend — the statement/expression refactor, the typed literals, and the introspection pass at the tip — not only the last few days of fixes. 76 commits, 6 of them marked breaking.

Headline changes:

  1. Every catalogue read is built from its node parameters — the table, column, index, constraint and materialized-view questions, the database-size query, and the status text that used to be concatenated (refactor(postgres): build the introspection catalog queries from their node parameters).
  2. The materialized-view, sequence and extension questions are statement factories, each with a dialect formatter and a public manager pair (refactor(postgres): name the materialized-view, sequence and extension questions as statement factories).
  3. The introspect CLI answers one question per family: extensions NAME, views NAME, and a type list derived from _TYPE_CAPABILITIES, with a single adaptation at the entry rather than per branch (feat(cli): a catalogue family takes an optional name).
  4. Catalogue answers are the catalogue's: table_count comes from the same database-info row, and the table kinds, column dimensions and extension facts are reported as the catalogue states them (fix(introspection): answer table_count from the same database-info row).

Type of change

  • feat: A new feature
  • refactor: Internal restructuring with no intended behaviour change
  • test: Adding missing tests or correcting existing tests
  • docs: Documentation only
  • perf / chore / ci / build / revert (not relevant)

Breaking Change

  • Yes, for end-users (backward-incompatible CLI behaviour)
  • Yes, for backend developers (internal architectural changes that may affect custom implementations)

Impact on End-Users:

  • introspect table no longer exists; extensions NAME and views NAME answer for one object.
  • A NAME a type cannot use is an error (exit 2) rather than an ignored argument.

Impact on Backend Developers (Custom Implementations):

  • One class per storage; the catalogue and status questions are nodes, not text.
  • parse_* lost its leading underscores, and the domain formatters this backend serves are declared.
  • Values are composed through the cast and literal factories instead of chains.

Related Repositories/Packages

repository branch tip its role in this cycle
python-activerecord 57439841 the statement layer, the capability vocabulary, and pytest_compat
python-activerecord-testsuite 1991449 the shared contracts, and the two pytest 9.2 shims
python-activerecord-postgres f1492ef this branch

Core is merged (rhosocial/python-activerecord#127, squashed as 57439841); this branch's CI now installs core from release/v1.0.0.dev30. The test suite is still referenced by branch, because its pytest_compat shims are not on its release branch yet; that reference is repointed when the test suite merges.

Testing

  • I have added new tests to cover my changes.
  • I have updated existing tests to reflect my changes.
  • All existing tests pass.
  • This change has been tested across the Python versions the matrix declares.

Test Plan: The full matrix ran green on this exact head (f1492ef) as run 41092190799 — every Python version and every service container the backend tests against. A local full run is skipped deliberately: the organisation caps a single task at 180 seconds, and the CI result on this SHA is the stronger evidence. The offline CLI tests (parser surface, NAME rules, capability gate) run in under a second and are part of that run.

Checklist

  • My code follows the project's code style guidelines.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have made corresponding changes to the documentation (docstrings, README.md, the CLI guide).
  • My changes generate no new warnings.
  • I have added new tests to cover my changes.
  • I have added a Changelog fragment.
  • I have verified that my changes do not introduce SQL injection vulnerabilities (no statement is spelled outside a dialect format_*).
  • I have checked for potential performance regressions.

Additional Notes

  • No rebase, and no divergence to reconcile. The release branch is an ancestor of this branch (0 behind, everything ahead), so this merges forward with nothing to resolve. The release ruleset requires linear history, so squash is the intended strategy.
  • Review load is front-loaded. Most commits are the statement/expression refactor reaching this backend; the introspection and CLI work is the last dozen.
  • The changelog fragments are in this branch, named after this pull request's number (added once the number existed, in a follow-up commit).

The shape check now lives where the flag has to be honoured rather than where
it is declared, so a value that would silently render the wrong DDL is named
instead. The attribute is read directly: whether the field exists is settled
by the class, so probing for it with a default would only turn a wrong class
into a quiet signed column.

CI installs the core library from fix/column-constraint-gates so this backend
runs against the branch it depends on.
Remove PostgresByteaType, PostgresUUIDType and PostgresXMLType. Each rendered
byte-identical SQL to the core class it derived from, and DataType.__eq__ is
type(self) is type(other) -- so a class differing only in its name made a
declaration and the catalog row it produced compare unequal, and the differ
reported an unchanged column as changed.

Unlike Firebird's, these were not unreachable: parse_type answered BYTEA, BLOB,
UUID and XML with the Postgres class rather than the concept, so the breakage
was live in both directions. It now answers with the core class, which is what
makes the round trip close -- each of the three comes back to itself, where
before it came back as a different class than it started as.

The round-trip test's WIDENING_ANSWERS loses three entries for that reason.
Those were not widenings: they recorded that parse_type disagreed with the
declaration, which is the defect.
…with getattr

Each of these sits behind a parameter whose annotation names the class, and
each field is in that class's constructor signature -- so nothing was ever in
doubt, and the default was only making a missing field look like a legal
answer.
Mechanical rename wave against the core's W1/W2/W3a/W3b/S1-c breaks.
Constraint surfaces follow in the next commit -- see below.

Migration surface (src + tests + docs, 215 files):

W2, 57 statement-level classes -> *Statement (core 34e1fb45). The
largest here: QueryExpression->QueryStatement 688, CreateTableExpression
->CreateTableStatement 415, DropTableExpression->DropTableStatement 309,
InsertExpression->InsertStatement 283, CreateIndexExpression
->CreateIndexStatement 86, UpdateExpression->UpdateStatement 39,
RefreshMaterializedViewExpression->RefreshMaterializedViewStatement 36,
TruncateExpression->TruncateStatement 36, CreateTableLikeExpression 36,
CreateMaterializedViewExpression 33, DropMaterializedViewExpression 32,
and the rest.

W3a, predicate/clause/match nodes (core 890aaaa3): ILIKEExpression
->ILikePredicate 29, FulltextMatchExpression->FulltextMatch 13.

W3b, value/function/introspection families (core 5dc64fd5):
WildcardExpression->Wildcard 30, SQLValue 18, QualifiedIdentifier 10,
JSONTextValue 10, Introspection*Info 53, CastFunction 8,
JSONDocumentValue 8, CaseFunction 3, CollateFunction 3,
UUIDCastFunction 3. UUIDCastExpression->UUIDCastFunction is the one
mapping the wave guide does not list; verified against the core commit.

W1, CTE family (core 683a183e): CTEExpression->CTEClause 24,
WithQueryExpression->WithStatement 20, and the call-site keyword
main_query=->query= 13 (every occurrence in this repo was an argument of
WithQueryExpression).

S1-c, PASSING: no change needed -- this backend constructs the XML
expressions through the xmlexists()/xmlquery() factories, whose passing=
signature is unchanged.

Override points renamed (functional; not renaming would drop the
override): format_ilike_predicate 5, format_begin_transaction_statement
2, format_cte_clause 1 (mixins/cte.py comment).

Behaviour: none. Every edit is a name; no SQL spelling moved.

Note: the constraint surfaces still reference the enums core's S3
removed (ColumnConstraintType/TableConstraintType), so this commit is an
expected red window -- the whole package fails to import until the next
commit. Wave 2 migrates the construction sites onto the branch nodes.

Verification:
- import smoke over the postgres backend modules: 147 import clean, 13
  fail and all 13 share the single deleted-symbol cause (cannot import
  name 'TableConstraintType') raised from
  expression/ddl/exclude_constraint.py, which is a wave-2 surface.
- python -m compileall src tests docs: exit 0 (only the two pre-existing
  SyntaxWarnings in docs/examples/chapter_02_connection_pool).
- ruff check on the 215 changed files: identical to the HEAD baseline
  for the same file set (one pre-existing E501 in
  test_insert_on_conflict_clauses.py resolved by the rename). No new
  finding outside mixins/ddl/constraint.py, whose five F821s are the
  known draft middle state.
Core's S3 wave replaced the fat constraint nodes with a production plus a
branch: ``ColumnConstraint(branch=...)`` / ``TableConstraint(branch=...)``
name the branch, the branch names the kind, and the ``*ConstraintType`` enums
are gone. This commit migrates every construction site, every override and
every assertion in the package onto that shape, and absorbs the mechanical
part of the B-series draft that was sitting uncommitted in the worktree.

Construction sites migrated (src + tests + docs, 96 files):

  ColumnPrimaryKeyConstraint  147   ColumnDefaultConstraint      39
  ColumnNotNullConstraint     131   ColumnForeignKeyConstraint    8
  ColumnNullableConstraint      4   ColumnUniqueConstraint        2
  ColumnCheckConstraint         1   TablePrimaryKeyConstraint      7
  TableForeignKeyConstraint     2   TableUniqueConstraint          1
  TableCheckConstraint          1

Call shape: ``ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)``
becomes ``ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))``.
The old keyword-per-field form is gone with it -- ``default_value=`` is now the
branch's positional ``value``, ``foreign_key_reference=`` the branch's
``references`` pair, ``check_condition=`` the CHECK branch's ``condition``.

GeneratedColumnExpression is deleted from core, so its one call site became
``ColumnGeneratedConstraint``.

EXCLUDE. PostgreSQL's grammar is richer than core's ``TableExcludeConstraint``
(one operator per element plus a partial predicate), so the branch is
``PostgresExcludeBranch`` and ``PostgresExcludeConstraint`` becomes the
production-level sugar over it -- the same shape core's own
``ForeignKeyConstraint`` has, so the 20-odd call sites keep working unchanged.
``_format_exclude_constraint`` (private, production-and-body in one) became the
public ``format_table_exclude_constraint``, which now renders the body only:
the ``CONSTRAINT <name>`` prefix and the traits are the production's and are
rendered by ``format_table_constraint``, where the grammar puts them.

Override points:

- ``format_table_constraint`` -- narrows the enforcement trait to CHECK and
  FOREIGN KEY (measured 0A000 for PRIMARY KEY/UNIQUE) before delegating.
- ``format_alter_constraint_action`` -- gates the verb on the measured
  per-kind boundary (FOREIGN KEY from 18, CHECK from 19).
- ``format_column_constraint`` -- refuses the enforcement trait outright.
- ``format_add_table_constraint_action`` -- **removed**. Core already renders
  the production, dispatches to the branch (so EXCLUDE gets its own formatter)
  and appends NOT VALID; the only PostgreSQL-specific judgement was the
  enforcement one and it lives in format_table_constraint. This is the
  narrowing the guide asks for: fewer overrides, not more.
- ``_format_constraint_enforcement`` is deliberately NOT overridden. It is
  shared by the column-level trait renderer and the table-level one, so an
  override there would have to tell the two families apart by hand; the public
  column-level seam says which family it is.

Behaviour changes (all measured, all previously silent or unimplemented):

- ``[NOT] ENFORCED`` on a column constraint now refuses. Measured 42601
  ``misplaced NOT ENFORCED clause`` for every kind on 18/19.
- A string or enum where a branch class belongs is a TypeError at
  construction, not a tag that happens to be accepted.
- ``AlterConstraint`` takes ``constraint_kind=`` (branch class or spelling)
  instead of ``constraint_type=``.
- EXCLUDE refuses DEFERRABLE/INITIALLY/ENFORCED: core's branch whitelist does
  not list it and this backend does not widen it, so the request cannot be
  written. ``PostgresExcludeConstraint(..., enforced=True)`` is a TypeError.

Serialization. ``PostgresAlterConstraint`` gains a ``constraint_kind`` view:
core's ``AlterConstraint`` declares ``constraint_kind`` but stores it as
``constraint_branch``, and ``get_params()`` -- the single serialization path --
resolves parameters by name, so without the view the kind is dropped and the
action cannot be rebuilt. The view yields the *spelling*, because a class
object is not encodable by any of the three channels.

Wildcard. ``functions/vector_search.py`` took ``Wildcard`` from core, which the
split removed, and the package would not import without it. That one import and
its one call site move to ``AllColumnsWildcard`` (a select-list position) here;
the rest of the split is the next commit.

Tests rewritten to the new API: the per-kind enforcement probe tests became
probe-version-gate plus formatter-behaviour; the column-level "renders
successfully" assertion became a refusal (the draft contradicted its own
docstring evidence -- pattern 10 of the ten reconciliation rules says the
assertion moves, not the measurement); the EXCLUDE deferral pair guards became
construction-refusal tests with the measurement quoted; ``format_default_
constraint`` became ``format_column_default_constraint`` on the branch.

Verification:

- import smoke over every module in the backend package: 346 imported, 0
  failures (was 147 ok / 13 failing on the deleted enums at the previous
  commit).
- targeted constraint suites, exit 0:
  tests/.../postgres/test_ddl_alter_config.py
  tests/.../postgres/test_postgres_constraint_capabilities.py
  tests/.../test_postgres_dialect_security.py
  tests/.../test_clause_pair_guard.py
  tests/.../postgres/test_ddl_source_collection.py
  tests/.../test_expression_roundtrip_all.py  (472 passed, 3 skipped)
- ruff check on the 102 changed files: one finding below the HEAD baseline for
  the same file set and no new class of finding. The one new F821 is one more
  reference to the already-undefined `dialect` in
  tests/.../extensions/test_pgvector_integration.py, a file with six such
  findings at HEAD.

Not green, and not this repository's to fix: the testsuite's shared
feature/ddl contract cannot run, because
python-activerecord-testsuite@fix/column-constraint-gates (a57f266) still
imports the deleted ``ColumnConstraintType`` in
feature/ddl/{fixtures/models,test_derivation,test_derivation_async,
test_ddl_source_contract}.py. That is WP-T/S4 work in the testsuite repo.
… protocol

The probes now say what they can answer and the formatters say the rest.

**Probes.** ``supports_constraint_enforced()`` and
``supports_alter_constraint_enforced()`` take no argument. A probe answers one
question -- "is this capability family present on this server" -- and the kind
is not part of that question; a parameterised probe has two call shapes
depending on its own signature, which is how the draft ended up with a switch
whose kind-less answer (``False`` on 19!) contradicted its kinded one. Both now
answer the version gate they were always answering:

  supports_constraint_enforced()        version >= 18   (170011 / 180006 / 190000)
  supports_alter_constraint_enforced()  version >= 18

The per-kind answers did not disappear; they moved to the place that can see
the kind, which is the branch class:

  _require_constraint_enforced(branch)     TableCheckConstraint / TableForeignKeyConstraint
                                           and version >= 18; anything else refuses
  _require_enforceable_constraint(branch)  TableForeignKeyConstraint from 18,
                                           TableCheckConstraint from 19

Both raise TypeError on a non-branch argument (pattern 4: a spelling is a type
question) and UnsupportedFeatureError naming the SQL spelling -- ``CHECK NOT
ENFORCED``, ``ALTER CONSTRAINT CHECK NOT ENFORCED`` -- with the message the
other ten backends use (pattern 7).

**Narrowing seams.** No private formatter was added, and the shared private
hook was left alone:

- column-level refusal -> the public ``format_column_constraint``, before
  delegation. ``_format_constraint_enforcement`` is shared by the column-level
  trait renderer and the table-level one, so an override there would have to
  tell the two families apart by hand; overriding the column seam makes the
  family the override's own business.
- table-level per-kind refusal -> the public ``format_table_constraint``.
- ALTER per-kind version -> the public ``format_alter_constraint_action``.
- EXCLUDE -> the public ``format_table_exclude_constraint``.

**Local declaration discipline** (pattern 5), now enforced rather than
asserted in prose. New in
test_postgres_constraint_capabilities.py:

- ``test_resolved_switch_is_owned_by_this_backend`` -- for every switch this
  backend relies on, the first class in the dialect's MRO that has the name in
  ``__dict__`` must be inside this package. A core default that reaches the MRO
  first would otherwise answer instead of the measured value, silently.
- ``test_the_declaring_class_is_a_postgres_mixin`` -- the answering class is a
  PostgreSQL one, whatever mixin owns it. (Not every switch is on
  ``PostgresConstraintMixin``: ``supports_drop_constraint_if_exists`` is
  declared by the ALTER COLUMN modifier, which reaches the MRO first. The
  guard is therefore package-scoped, which is what the rule actually says.)
- ``test_probes_take_no_argument`` -- ``(self)`` and nothing else, checked on
  the bound method so a defaulted parameter cannot slip back in.
- ``test_version_gate_matches_the_measurement`` -- the 17/18/19 boundary.
- ``test_docstring_quotes_the_measurement`` -- pattern 6, made mechanical: a
  switch docstring must contain a SQLSTATE, a server version, or the server's
  own "accepted" verdict. A docstring that stops quoting its measurement fails
  here instead of quietly becoming a guess with a name.

**Docstrings.** The two "KNOWN DEFECT (core S3, deliberately not fixed here)"
paragraphs are gone -- the defect they described was core's
``inspect.signature`` sniffing of a parameterised probe, which core removed.
Each probe now points at where its per-kind half lives. The two stray
"Core S3 may rename it" notes are reworded against what S3 actually did
(moved constraint *nodes*, not the REFERENCES clause and not
``supports_rename_constraint``).

**Measured facts re-asserted, not re-derived** (pattern 10). Every SQLSTATE in
the new text was already in the draft's evidence and is quoted verbatim:

  170011  42601  the clause is not in the grammar, any kind, any statement
  180006  0A000  PRIMARY KEY / UNIQUE constraints cannot be marked NOT ENFORCED
  180006  42809  cannot alter enforceability ... Only foreign key and check
  190000  accepted for both statements, both kinds
  column level, all versions: 42601 misplaced NOT ENFORCED clause

Verification:

- tests/.../postgres/test_postgres_constraint_capabilities.py, exit 0.
- tests/.../postgres/test_ddl_alter_config.py, exit 0.
- tests/.../test_clause_pair_guard.py, exit 0.
- ruff on the changed files: clean.
Core split the single asterisk into two by where it sits in the grammar
(spec 4.10): ``AllColumnsWildcard`` for the select list and RETURNING,
``RowCountWildcard`` for ``COUNT``'s argument. The two answer different
questions -- ``COUNT(*)`` counts rows, ``t.*`` expands columns -- and only the
aggregate's contract admits the second.

Call sites migrated by position, not by name:

  select-list / RETURNING (13)   Wildcard -> AllColumnsWildcard
  COUNT argument (3)             Wildcard -> RowCountWildcard
    examples/ddl/materialized_view.py, examples/query/pagination.py,
    test_partition_operations.py

One qualified form changed shape as well as name:
``Wildcard(dialect, table="users", schema_name="app")`` became
``AllColumnsWildcard(dialect, relation=NamedRelationRef(...))``. The
qualification rides the relation object because the name to render is the name
the query uses, and that is what the reference holds.

Backend-specific: ``PostgreSQLRowCountWildcard``. ``COUNT(t.*)`` counts the
non-NULL rows of one relation rather than the rows of the join -- under a LEFT
JOIN the two answer differently -- so it is a separate node and not a flag.
Core's ``RowCountWildcard`` accepts no relation by contract, so the derivation
lives here and the override is ``format_row_count_wildcard``. The override
tests the class rather than probing for a missing attribute: core's node has no
``relation`` at all, and asking for one anyway is the reflection rule 9.8
forbids. Anything that is not the qualified form renders the bare asterisk, so
core's ``COUNT(*)`` path is untouched.

No ``format_wildcard`` override existed here, so none needed renaming.

Tests: ``test_schema_namespace.py`` gains the two wildcard contracts -- bare
``COUNT(*)`` stays bare, and ``COUNT(t.*)`` renders the alias the query uses.

Verification:
- import smoke over every module in the backend package: 347 imported, 0
  failures.
- tests/.../postgres/test_schema_namespace.py, exit 0.
- tests/.../test_partition_explain.py, exit 0.
- tests/.../test_expression_roundtrip_all.py, exit 0.
- python -m compileall src tests docs: exit 0.
- ruff on the changed files: clean.
Every install line in .github/workflows/test.yml (four jobs: sync, async,
scenarios, lint) pinned the testsuite to its release branch while the core
line already pointed at fix/column-constraint-gates. The two have to move
together: the release branch's testsuite still builds constraints the way the
fat nodes were built, so against this backend's two-level nodes it would fail
at import.

  rhosocial-activerecord-testsuite @ .../python-activerecord-testsuite.git
    @release/v1.0.0.dev17 -> @fix/column-constraint-gates   (4 places)

Checked and already correct, so untouched: the core install line is
git+https://github.com/rhosocial/python-activerecord.git@fix/column-constraint-gates
at all four places. No other workflow installs either dependency by ref
(publish.yml and changelog-check.yml have no such line).

No test evidence: a workflow-file ref change has nothing to run against. The
import smoke over the backend package (347 modules, 0 failures) is what stands
behind the branch pairing it points at.
… mixin does

The signature-conformance test compares the protocol's declared parameters
with the mixin's, and they had to agree: the protocol said \�xpr\, the
override said \constraint\ (which is also what core's own
\ormat_column_constraint\ calls it).  Fixed the protocol, not the
override, because the override's name matches the one it overrides.

Caught by tests/.../test_postgres_protocol_conformance.py, exit 1 before,
exit 0 after.
… put it

The identity injection in format_column_definition still probed the column-constraint clause for is_auto_increment with getattr -- a 9.8 reflection on a field that now lives on the PRIMARY KEY branch, so the probe always answered False and every auto-increment declaration was silently dropped from CREATE TABLE. Read constraint.branch.is_auto_increment behind an isinstance of ColumnPrimaryKeyConstraint instead.

The two DDL tests had migrated the branch but left the flag on the clause (TypeError: unexpected keyword argument), which is how the CI matrix caught it: 11 failed / 9944 passed at b52c8a9, all in test_auto_increment_ddl and test_identity_execution.

Targeted: both files exit 0 on the central venv.
…acts

Carry the W3c class renames, the R1 signature alignment and the R2a arithmetic/concatenation renaming through the backend sources, examples and tests. The roundtrip matrix needed the two new core classes registered (ArithmeticOperation, SetOperation) and three imports that the rename left dangling.
Carry the core S2 discipline (101f71a5) through the 26 remaining probe
sites, following the fcc9939 precedent. Three shapes were needed:

* direct read (form A) where the field is declared on the node the
  annotation names: ddl_table.py partition/table_options/temporary,
  ddl/type.py definition_kind/action_kind (abstract properties on
  TypeDefinition/TypeAlterAction), ddl/domain.py action_kind
  (DomainAlterAction), materialized_view.py concurrent
  (RefreshMaterializedViewStatement) and action_kind
  (MaterializedViewAlterAction), types/jsonb_enhanced.py mode
  (JSONDocumentValue declares it, JSONTextValue inherits);
* isinstance dispatch where the field exists only on the PostgreSQL
  subclass: DROP DOMAIN options and CREATE INDEX opclasses /
  nulls_not_distinct / with_options, DROP TYPE cascade/restrict, CREATE
  MATERIALIZED VIEW if_not_exists -- the core nodes carry none of these
  and now say so with a False branch instead of a silent default;
* enum-or-spelling (form C, core ddl_comment.py:72 pattern):
  comment.py object_type, copy.py on_error/log_verbosity now branch on
  isinstance(x, Enum). type_values/text_search.py judges the
  to_postgres_string protocol by isinstance(TsQueryNode) instead of
  hasattr.

Migration face: 26 getattr/hasattr probes -> 0 (static gate
'rg (getattr|hasattr)\(\s*(expr|node|clause|item)\b src' is now empty).

Behaviour tightening: an object that reaches the ALTER DOMAIN / ALTER
TYPE / MATERIALIZED VIEW fall-throughs without the declared
action_kind, or a JSONPath node without mode, now fails with
AttributeError instead of rendering the class name into the refusal
message. No in-repo caller hit the removed branches.

Also dropped two dead imports in mixins/ddl/type.py (SchemaObject,
shadowed typing.Type) so the touched file is ruff-clean; both were
flagged on HEAD already.

Tests (central venv, PYTHONPATH=src;tests, all exit 0):
postgres/test_index_pg.py, dialect/test_materialized_view.py,
postgres/test_json_path_modes.py, postgres/test_textsearch_types.py,
postgres/test_ddl_expressions.py, postgres/test_pg19_copy_repack.py,
postgres/test_ddl_objects.py, postgres/test_ddl_protocols.py,
postgres/test_enum_types.py, dialect/test_materialized_view_docs.py,
postgres/test_ddl_alter_config.py, test_partition_operations.py,
test_postgres_protocol_conformance.py, postgres/test_ddl_source_collection.py.
Pre-existing, unrelated: test_clause_pair_guard.py
TestSetOperationWordsAreClosed[union_all*] fails on HEAD 8f56c2c with a
substring assertion ('UNION' is contained in 'UNION ALL'); to be repaired
with the set-operation follow-up.
Core ee5f4b55 "give an artifact a kind, not a class path" removed
register_expression_class from backend.expression.serialization: an
artifact is now a bare {kind, params} node, the kind is computed by
BaseExpression.kind(), and deserialization resolves kinds through a
registry whitelist the framework populates by walking its own packages
(core, sqlite, dummy). The backend-side registration module this
repository kept for the old FQN index is therefore dead code, and after
that core commit it broke the whole backend import chain (dialect ->
mixins -> expression/__init__ -> registry).

Removed expression/registry.py and its import-time call from
expression/__init__.py, following the shape mysql already converged to
(no backend-side registration module; whoever reads an artifact back
enters the kinds it names, which is the core commit's own answer to
"who registers a backend's kinds").

Same core commit, test side: the two TYPE-definition round-trip tests in
postgres/test_ddl_objects.py deserialize PostgreSQL-specific kinds, which
the framework whitelist does not carry. The test module now registers
exactly what it reads back -- the two definition classes, the composite
attribute dataclass and the UNSET sentinel enum -- into the default
registry at import time. Also dropped the pre-existing unused Literal
import so the touched file is ruff-clean.

Behaviour change: PostgreSQL-specific expression classes are no longer
entered into the (removed) global deserialization index at import time.
Serializing them was never affected - a node carries its own kind.

Test evidence (central venv, PYTHONPATH=src;tests):
- import smoke `from ...postgres.dialect import PostgresDialect` exit 0
  (raised ImportError before the removal);
- postgres/test_ddl_objects.py exit 0 (the two round-trip tests failed
  with "kind ... is not in the kind whitelist" before the registration);
- postgres/test_typed_values.py (new, next commit) and the
  -k "typed or literal" selection both exit 0.
…pellings

Wire the core TypedValueMixin (473b8e37 + 5ba491d5 + 1549380b) into the
dialect: a new PostgresTypedValueMixin rides ahead of it in the base
list, so the twelve typed value nodes resolve per kind while exactly the
three PostgreSQL spells differently stay backend-owned (spec 4.4 tier 2).

Overridden, with the inline (DDL-position) spellings:

- format_binary_value: '\x0102'::bytea -- the hex bytea literal, rendered
  from the bytes the node holds. Hex input needs PostgreSQL 9.0+; every
  declared version satisfies that.
- format_uuid_value: '...'::uuid -- the quoted canonical form plus the
  cast, the same shape the dialect's UUID_SQL table spells for the UUID
  constants (mixins/uuid.py).
- format_interval_value: INTERVAL '...' with the span decomposed into
  days / hours / minutes / seconds, every emitted component carrying the
  sign. Per-component signs are a measured answer, not a preference:
  live servers parse INTERVAL '-2 days 03:04:05.000006' as minus two
  days PLUS 03:04:05 (the leading minus does not carry into the time
  part), so signing each component is the only faithful spelling. A zero
  span renders INTERVAL '0 seconds'; microseconds render as a six-digit
  fraction when present.

Tier-1 audit: PostgreSQL agrees with the base defaults everywhere -- TRUE
/ FALSE booleans, numeric text, quoted ISO dates and times, doubled-quote
strings -- so no tier-1 method is overridden and no literal logic copied.
The data position binds bytes / uuid.UUID / timedelta through the core
_bind_value; the psycopg placeholder is left to the dialect base.

Measured live 2026-10-17/18 against servers reporting 13.23, 17.11,
18.6 and 19beta4 (Debian): all four accept the three spellings, and a
CREATE TABLE whose bytea / uuid / interval columns carry them as DEFAULT
values round-trips exactly through INSERT ... DEFAULT VALUES + SELECT on
all four (oldest scenario configured as "postgres_9" measured 13.23).

New test file postgres/test_typed_values.py: MRO pin (the three methods
resolve on the postgres mixin), data-position bindings for every
overridden kind plus tier-1 passthrough, exact DDL-position texts for
binary (bytes and bytearray), uuid and seven interval shapes, and live
sentinel + column-default read-backs on every configured scenario.

Test evidence (central venv, PYTHONPATH=src;tests):
- postgres/test_typed_values.py: 61 executions exit 0
  (17 offline + 4 live across 11 configured scenarios);
- feature/backend -k "typed or literal" (5 wave-4-scope collection
  breakages from core's in-flight commits ignored, see report): exit 0;
- ruff clean on every touched file (also fixed the pre-existing missing
  PostgresObjectNameMixin __all__ entry flagged in mixins/__init__.py).

Open items, not this wave: the roundtrip matrix file still imports the
retired serialization API and the testsuite expression helper -- wave 4
reconciles it against the core HEAD of that day, mirroring mysql's
vendored harness.
Core 9b559030 classed the aggregates and the scalar string functions,
a3978e10 the math family, and c5210245 reserved the generic FunctionCall
for user-defined functions. The semantic sites in this repository that
the core has classed now build (or delegate to) those classed nodes; the
~295 remaining FunctionCall sites spell extension-specific functions the
core has not classed (postgis, hstore, pgcrypto, ll_to_earth, ...) and
stay generic on purpose -- FunctionCall is the UDF node, and migrating
an unclassed concept would chase the moving target the wave guide
forbids.

Migrated (functions/math_enhanced.py, 9 of 11 factories):
- round_ / trunc -> RoundFunction / TruncateFunction via for_operand,
  preserving the always-rendered precision operand (default 0 binds a
  literal, an expression-valued precision passes through -- the previous
  factory wrapped a Literal in another Literal when handed one)
- pow / power -> PowerFunction. pow keeps its name but now writes the
  canonical POWER: both spellings accepted, measured live 2026-10-18 on
  17.11 (POW(2.0,10) and POWER(2.0,10) both answer 1024)
- sqrt -> SquareRootFunction, mod -> ModuloFunction, ceil -> CeilFunction,
  floor -> FloorFunction, avg -> AvgFunction (all render the same text
  as before)
- max_ / min_ stay FunctionCall: GREATEST / LEAST are scalar and unclassed
  even in the core (its own greatest/least factories also build the
  generic node); the aggregate Min/MaxFunction are different concepts

Spelling override added (mixins/expression.py):
- format_truncate_function writes TRUNC. The core node spells the
  SQL-standard TRUNCATE, which in PostgreSQL is the DDL statement that
  empties a table; measured live 2026-10-18 on 17.11: TRUNC(3.716, 2)
  answers 3.71, TRUNCATE(3.716) is refused with UndefinedFunction
  "function truncate(numeric) does not exist"

char_length audit: PostgreSQL uses the core default. The core's
format_char_length_function writes LENGTH and deliberately does not read
the unit field; live-measured on 17.11, length('hello') = 5 characters
against octet_length('hello') = 6 bytes, so PostgreSQL's LENGTH already
answers the character question the core spelling assumes -- no override,
like oracle / snowflake / bigquery.

Leftover: examples/conftest.py described UNION as "using
SetOperationStatement"; the class is SetOperation. Also migrated the
ROUND site in examples/extensions/earthdistance.py to RoundFunction, and
fixed three pre-existing lint findings in the touched files (Table used
before its import in the earthdistance example, two overlong lines in
the math test).

Kept as FunctionCall, with the reason where it is subtle:
- functions/bit_string.py length / bit_length / octet_length: PostgreSQL's
  bit-string overloads, not the text semantics the core classes carry
- functions/geometric.py length: the geometric overload
- examples/query/window.py ROW_NUMBER / SUM with window_spec: the classed
  nodes take no window clause; the core has not classed the windowed form

Test evidence (central venv, PYTHONPATH=src;tests):
- postgres/test_postgres_math_enhanced_functions.py
  (POW assertions updated to the documented POWER spelling) +
  test_dialect_function_support.py + examples_compile_test.py: 61
  executions exit 0
- ruff clean on every touched file
Core removed TypedValueMixin and the twelve *Value classes, leaving one
Literal(dialect, value, inline) node whose inline text goes through
SQLDialectBase.format_literal -- three tiers, of which the second is
exactly the types a backend spells for itself. PostgreSQL has three:
binary, UUID and INTERVAL. The 2026-10-17/18 live measurements behind
them (13.23 / 17.11 / 18.6 / 19beta4, CREATE TABLE DEFAULT read back
byte-exactly, the per-component interval sign) are carried over unchanged
-- an architecture change does not invalidate a measured answer.

Literal mixin
  - mixins/typed_value.py -> mixins/literal.py: PostgresLiteralValueMixin
    overrides format_literal (bytes -> '\x0102'::bytea, UUID -> '...'::uuid,
    timedelta -> INTERVAL '...'); everything else defers to super().
  - First position in the dialect base list, ahead of SQLDialectBase: a mixin
    placed after the class it overrides never wins the lookup.
  - test_typed_values.py -> test_literal_values.py, rewritten around the
    inline flag. Binding and inline assertions, the tier-1 audit, the base
    refusal and the live read-backs are all kept.

Partition bounds
  - _inline_partition_expression is gone. Whether a value inlines is the
    caller's per-node decision, so the pass that copied a bound tree and
    swapped every Literal for the DDL class is: a bound renders as composed,
    a literal left binding is refused by name. Three tests that relied on the
    old lift now say inline=True; one new test pins the refusal.

Action renames (guide 10.3, same batch)
  - AddColumn/DropColumn/AddTableConstraint/DropTableConstraint/AddIndex/
    AlterConstraint/ValidateConstraint/AlterColumn/RenameColumn ->
    *Action, in src, tests and the two schema_diff examples.
  - The suffix lists in the roundtrip matrix lost the entries that named the
    removed classes; a suffix matching nothing registers nothing.
  - examples/ddl/alter_table.py was missing the Table import its own code uses.

Plain-JSON artifacts (guide 12.1)
  - register_dataclass / register_enum are gone from the core registry, and an
    artifact carries no class path any more, so the two PostgreSQL parameters
    that were host objects had to become values:
    * PostgresCompositeTypeAttribute gains as_params/from_params and
      PostgresCompositeTypeDefinition overrides get_params to emit the
      attribute as a mapping. The constructor takes either form and stores the
      dataclass (entry wide, storage normalized).
    * _UnsetValue is now (str, Enum) with __str__ = str.__str__, so the
      "clause not supplied" sentinel travels as the text "UNSET"; the
      constructor normalises it back, without which every deserialized base
      TYPE would claim a DEFAULT it never declared.

Tests: 1044 passed, exit 0, over the twelve backend feature files this
touches. ruff check clean on every changed file except two pre-existing
E501s (mixins/ddl/column.py:126, test_ddl_expressions.py:286), both present
at HEAD and untouched here.
Sweep the backend's expression package against the org rule that a kind core
has already issued may only be overridden in rendering, never redeclared as a
node class, and that a kind core does not have is the backend's own -- named
with the backend identifier in front.

Found and fixed, per the lineage script (core 357 kinds; backend 167 -> 161
custom kinds, 0 overrides -- the count that changes here is the one made of
duplicate nodes):

* ILIKE. ``expression/ilike.py`` declared ``ILikePredicate`` under the same
  ``format_ilike_predicate`` entry core's node declares, so one concept had two
  classes and the artifact could not say which it meant. The module is gone;
  ``PostgresILIKEMixin.format_ilike_predicate`` is the whole PostgreSQL side,
  and the two test modules import core's node.

* ALTER/VALIDATE CONSTRAINT. ``ddl/constraint.py`` exported one class under four
  names each -- zero call sites, no old name to be compatible with -- and the
  VALIDATE one added nothing at all. ``PostgresValidateConstraint`` is deleted
  (core's ``ValidateConstraintAction`` is what callers build); the ALTER one
  keeps its one real addition, ``constraint_kind`` as a readable property that
  makes ``get_params()`` resolve the parameter core stores under another name,
  and is renamed ``PostgresAlterConstraintAction`` so the class carries the
  suffix its formatter already had.

* ENUM type fragments. ``CreateEnumTypeExpression`` / ``DropEnumTypeExpression``
  / ``AlterEnumAddValueExpression`` were ``pass`` subclasses of the ``Postgres*``
  classes -- same behaviour, a second kind, no backend prefix. Deleted; the
  ``Postgres*`` classes are the only spelling. ``EnumTypeNameExpression`` /
  ``EnumValuesExpression`` are real nodes but their kinds had no backend prefix
  and their formatter names did not match them; renamed
  ``PostgresEnumTypeNameExpression`` / ``PostgresEnumValuesExpression`` with
  ``format_postgres_enum_*`` entries.

* PARTITION BY. ``PostgresPartitionClause`` was an empty subclass of core's
  ``PartitionClause``: no field, no formatter of its own, one more kind for one
  concept. Removed; PostgreSQL renders core's clause.

* Compound-word kind. ``PostgreSQLRowCountWildcard`` derived
  ``postgre_sql_row_count_wildcard`` -- ``PostgreSQL`` splits into two words
  under the class-name rule, while every other class here says ``Postgres``.
  Renamed ``PostgresRowCountWildcard`` (kind ``postgres_row_count_wildcard``).

Audited and left alone: every ``Postgres*`` node that subclasses the core node
of its concept and adds a typed field (``PostgresAlterColumn.using``,
``PostgresExcludeBranch.elements``/``where``, the type definitions, the domain
actions) -- those are derived nodes with a formatter of their own, not
redeclarations. The dialect MRO was read end to end: no PostgreSQL mixin sits
behind a core class that would shadow it.

Test evidence: ``pytest`` on test_expression_signatures.py,
test_ilike_support.py, test_enum_types.py, test_ddl_alter_config.py,
test_ddl_expressions.py, test_ddl_source_collection.py, test_schema_namespace.py
-> exit 0. ruff on every touched file -> clean.
…tifact

Reconcile the matrix against core at 550ae740, where an artifact is a bare
{kind, params} node: no envelope, no marker keys, values as JSON scalars, and
the kind is the class name derived (``Literal`` the one exception, naming its
fourteen value kinds by what they hold).

The harness is now this repository's own (``expression_roundtrip_harness.py``),
vendored when testsuite deleted the shared module; its comparison learns that a
``(str, Enum)`` member and the word it carries are the same recorded value,
because JSON has no enum and a constructor may legitimately keep the word.

Matrix changes:

* ``values.*`` rows are gone -- there is no class per value kind. In their place
  ``test_value_kind_plane`` walks all fourteen kinds: wire name, JSON payload
  type (NaN/+-Inf as text, Decimal and timedelta as text, bytes as base64,
  list vs tuple preserved, everything else a plain scalar) and the round-trip
  through all three channels. The set is pinned in both directions against
  ``LiteralKind``, and a ``float_value`` payload that is not finite is asserted
  to be refused by the value layer rather than rendered.
* The removed K1 classes leave no rows: ``PostgresPartitionClause``,
  ``PostgresValidateConstraint``, the three enum compat subclasses and the six
  constraint aliases.
* Constructors added for classes the generic guess could not build for real
  reasons (a closed word set, a field name, an inline DDL condition, a value
  rather than text, a declared-result annotation wrapping a column), and for
  ``CustomType`` (its ``raw`` is keyword-only), so it leaves UNCONSTRUCTIBLE.
* ``_CODEC_GAPS`` / ``_CODEC_RENDER_GAPS`` pin what core's codec cannot carry or
  cannot read back, per class and per channel with the exception type: the XML
  codec writes a ``(str, Enum)`` with ``Enum.__str__`` (the member's *name*), a
  dataclass parameter has no encoding at all (``XMLAttribute``, ``XMLForestItem``,
  ``XMLTableColumn``, ``ReferentialAction``, ``TriggerEvent``, ``ViewOptions``,
  ``GraphColumn``, ``GeneratedColumnType``), ``CreateTableCloneMode`` is a plain
  Enum, and the two XML nodes' formatters read ``.value`` off a decoded word.
  Nothing else may lose a parameter, and each entry is exact in both directions.

Backend defects the matrix surfaced, fixed here:

* ``PostgresCollationMixin`` read ``expr.collation_options``; core's node
  declares ``schema``, so every COLLATE raised ``AttributeError``.
* ``format_alter_database_statement`` compared against
  ``AlterDatabaseAction.CONNECTION_LIMIT`` / ``.TABLESPACE``, which core does not
  declare -- every ALTER DATABASE died before rendering. It now recognises
  PostgreSQL's two extra actions by word, resolves a decoded word back to the
  core member, delegates the rest to core's formatter explicitly (``super()``
  reached the protocol stub and answered ``None``), and refuses an unknown action.
* ``mode is LoggingMode.LOGGED`` answered "not LOGGED" for a round-tripped word
  and rendered the opposite setting; it compares and spells the word now, and
  refuses an unknown one.
* ``PostgresAlterTableRlsExpression`` read ``expr.mode.value``, which is gone
  after a decode.
* ``hstore_literal``/``ltree_literal``/``xid8_literal`` chained ``.cast()`` onto
  a ``Literal``; casting is a ``SQLValue`` capability and ``Literal`` is the
  value node, so they build a ``CastExpression`` over it.
* The four value wrappers declared ``family``, stored it as ``_family``, and no
  code ever read it -- removed (rules §4.4: a parameter that changes nothing).
* Every PostgreSQL-owned enum is now ``(str, Enum)`` with
  ``__str__ = str.__str__`` (§11.3), so the word is what str() and the artifact
  carry.

Test evidence: the full matrix plus the domain round-trip file -> exit 0
(``531 constructible, 2 pinned-unconstructible; 471 rendered, 37 unsupported,
20 non-render, 3 unmodelled``). ruff clean on every touched file.
Kind hygiene (expression-kinds.md §三, expression-dialect §3, §4.4, §11.2):

* A backend's own kind is named for the backend. Renamed
  ``NetworkValueExpression`` / ``RangeValueExpression`` / ``HstoreValueExpression``
  / ``LtreeValueExpression`` -> ``Postgres*ValueExpression``, ``PartitionValue``
  -> ``PostgresPartitionValue``, ``CreateMultirangeTypeExpression`` /
  ``MultirangeAggFunctionExpression`` -> ``Postgres*``, ``MaterializedViewAlterAction``
  -> ``PostgresMaterializedViewAlterAction``, and the two private helpers
  ``_LegacySqlExpression`` / ``_UnsupportedDomainAction`` ->
  ``_PostgresLegacySqlExpression`` / ``_PostgresUnsupportedDomainAction``. Every
  affected ``format_method`` follows its kind, so class name, kind and entry are
  one identity.
* ``PostgresAlterColumn`` -> ``PostgresAlterColumnAction``: the action carries
  the suffix its formatter (``format_alter_column_action``) and its base
  (``AlterColumnAction``) already had.
* The four value wrappers declared ``family``, stored ``_family``, and nothing
  read it. Removed -- a parameter that changes nothing is not a parameter.

Kind hygiene is now a test (``dialect/test_kind_hygiene.py``): no backend class
may claim a kind core issues; a backend-owned kind must be ``postgres_*``; a
declared node's formatter must be ``format_<kind>`` (with an explicit,
two-way-checked table for the families that share one entry); and every enum in
the package must be ``(str, Enum)`` whose ``str()`` is its value.

Test discipline (expression-dialect §12, §12.1):

* The live-connection half of the literal work moved out of
  ``test_literal_values.py`` into ``test_literal_values_integration.py``, on the
  scenario-parameterised ``postgres_backend`` fixture. What stays is
  construct-and-render only, with the measured facts (and where to re-measure
  them) recorded in the docstring. ``conftest.py`` in the expression directory
  makes ``-n auto --dist=loadgroup`` the default there.

Serialization obligations (guide §13):

* §13.1: ``format_binary_operator`` spells the operator through ``.symbol`` when
  it is a ``ComparisonOperator`` -- ``expr.op`` is now the word (``EQ``), and the
  character is what SQL wants. A plain string operator (pg_trgm, range) is used
  as it is.
* §13.4: audit of every ``*_need_quote`` pass-through in this backend; all are
  real bools, no change needed.

Test evidence: the kind-hygiene file, the literal files (expression and
integration), the matrix, ``test_expression_roundtrip_all``,
``test_postgres_dialect_security``, ``test_extension_type_operations``,
``test_ddl_expressions``, ``test_ddl_alter_config``, ``test_materialized_view``,
``test_alter_table_if_exists`` -> exit 0. ruff clean.
The expression directory is construct-and-render only (expression-dialect §12):
no connection, no schema, no file. Two things there still opened one, and one
configuration still left parallelism to whoever remembered the flag.

* ``test_literal_values.py`` keeps the wiring, the data-position bindings and
  the inline spellings, and nothing else. Its ``TestLiveLiteralRoundTrip`` --
  the sentinel, the CREATE TABLE with the rendered DEFAULTs, the read-back
  through ``INSERT ... DEFAULT VALUES`` -- moved to
  ``test_literal_values_integration.py`` beside it, where the
  scenario-parameterised ``postgres_backend`` fixture already lives. The
  measured interval facts stay in the docstring that recorded them, with a
  pointer to where a re-measurement goes; the numbers did not change and are
  not restated in two places.
* ``tests/.../backend/expression/conftest.py`` makes ``-n auto`` and
  ``--dist=loadgroup`` this directory's default, and ``pyproject.toml`` pins
  ``--dist=loadgroup`` for the suite: a case runs on its own only when it
  carries an explicit ``xdist_group``, and the reason for that marker is
  written where it is used. ``-n 0`` / ``-p no:xdist`` still opt out.
* ``xdist_group`` is declared as a marker rather than left to the plugin, so a
  typo in its name fails instead of being ignored.

Also in this commit, for the same rule set: the backend's transaction mixin and
tests follow core's ``with_*`` setters (``expr.isolation_level()`` ->
``expr.with_isolation_level()`` and the three siblings), and the parse-contract
sweep reads ``parse_roundtrip_failures`` from this repository's vendored harness
now that the shared testsuite module is gone.

Test evidence: the expression directory (parallel default in effect),
``test_literal_values.py``,
``test_literal_values_integration.py`` (live; skipped where no scenario server
is configured), ``test_expressions_transaction.py``,
``test_transaction_advanced_features.py``, ``test_postgres_type_protocol.py``,
``test_ddl_alter_config.py``, ``test_object_kind_guards.py`` -> exit 0. Live
capture is out of this change's reach (no scenario server on this host), and
that is stated rather than implied by a green run.
``format_for_update_clause`` interpolated ``LockStrength.UPDATE`` straight into
the SQL, and after core split a member's *word* (what the artifact carries) from
the SQL it spells, that renders a bare ``update`` -- a clause PostgreSQL
rejects. ``LockStrength.phrase`` is where core keeps the spelling now, the same
way ``SortDirection.keyword`` holds the ordering keyword. Guide §13.2's rule,
applied to the one place this backend still read a wire word as SQL.

Test evidence: ``test_row_level_locks.py`` -> exit 0 (one skip, a version-gated
case that predates this change).
…sed to be

The same defect the ALTER DATABASE and RLS formatters had, in three more places:
a value that was a ``(str, Enum)`` member at construction comes back as its
*word* after a round-trip, and the formatter read ``.value`` off it.

* ``format_create_table_statement`` interpolated ``col_def.storage.value`` for
  ``STORAGE``.
* ``format_create_policy_statement`` interpolated ``cmd.value`` for the
  ``FOR <command>`` clause.
* ``format_create_domain_statement`` interpolated ``expr.nullability.value``
  and passed the member to ``supports_domain_nullability``, whose set
  membership would refuse the decoded word. The word is resolved back to its
  member once, by name, so the probe keeps answering and an unknown word is
  refused explicitly.

All three now interpolate the member (its ``__str__`` is its word, §11.3) and
resolve for capability checks.

Test evidence: ``test_domain_expression_roundtrip.py``, ``test_policy.py``,
``test_ddl_expressions.py``, ``test_ddl_source_collection.py``,
``test_expression_roundtrip_all.py`` -> exit 0. ruff clean.
The matrix file's header now states what it is reconciled against (core
``550ae740``, the plain-``{kind, params}`` artifact), what the value-kind plane
assertion exists for, and the census it currently reports. That is the one
place a reader looks when the numbers move, so the numbers and the baseline
belong in it rather than only in a commit message.

Evidence: the matrix plus the domain round-trip file -> exit 0 (533
registered, 531 constructible, 471 rendered, 37 unsupported, 20 non-render,
3 unmodelled). ruff clean.
The examples still chained ``Literal(...).cast(T)``. ``Literal`` is the value
node -- it answers "bind or inline" and nothing else -- so the cast is its own
node, built from the literal. The examples now show the shape the library
actually offers, which is the one a reader copies.

No behaviour is asserted by these files (they are illustrative fragments and
reference names that only exist inside an ``if installed`` block); they are
parsed, not executed, and that is unchanged.
…lly called

``EdgeTableDefinition`` is the CREATE PROPERTY GRAPH definition; ``EdgeTable``
is the catalogue object it names. The test imported the catalogue object under
the definition's name and constructed it with definition arguments, which only
passed while both were spelled similarly. The class was renamed for exactly this
reason, and the test follows.

Evidence: ``test_property_graph_query_format.py`` -> exit 0.
…istry

The kind-hygiene check compared this backend`s kinds against
``default_registry().node_kinds()``. That registry is process-global and the
matrix registers this backend`s classes into it, so the comparison answered
differently depending on which module imported first -- the test failed when the
matrix ran before it, and passed alone. Core`s own package is the stable source,
and walking it is the same mechanism the matrix already uses to decide what
counts as a kind.

Evidence: the matrix + domain round-trip + kind hygiene + the expression
directory in one run (matrix first) -> exit 0.
PostgresDomainMixin renders two nodes the core kinds do not carry: the legacy sql= argument and the ALTER DOMAIN action words PostgreSQL spells. Both reach the dialect through format_method, so each is a member the mixin serves -- and a protocol is the declaration of that surface (§7.1.1). Declared them on PostgresDomainSupport; the reverse-coverage conformance test is now clean.

Test evidence: test_postgres_protocol_conformance.py -> 559 passed (was 558 passed, 1 failed).
Core split one name into two contracts: the relation object is EdgeTable in objects.relation, and the DDL node that declares an edge in CREATE PROPERTY GRAPH is EdgeTableDefinition in expression.graph (kind graph.edge_table_definition). The scenarios file still imported EdgeTable from the expression package, which no longer exports that name -- so the module failed to import at all, and the four DDL sites that carry source/destination keys would have been the definition node and the eleven GraphEdge sites the relation object.

Split them accordingly: EdgeTableDefinition where key lists are passed, and EdgeTableObject for GraphEdge, which is what core's format_graph_edge now type-checks (GraphEdge.table must be an EdgeTableObject).

Test evidence: module imports and compiles; the scenarios are capability-gated and inert without a live SQL/PGQ server -- the live-server assertions are recorded as 'to be verified in CI'.
Core split one name into two contracts: the relation object is objects.relation.EdgeTable, and the CREATE PROPERTY GRAPH node is expression.graph.EdgeTableDefinition, kind graph.edge_table_definition. The vendored harness still registered the constructor under graph.EdgeTable and built the removed class, so the property-graph row could neither be constructed nor matched against the gap table; the registration now names the kind core issues and the declared-gap row is what the matrix expects.

Verified: test_expression_roundtrip_all.py -> 1072 passed, 4 skipped (the pins skip by design); ruff clean on both files.
Core moved the introspection cache into a component exposed as
`self._cache`; the mixin no longer provides `make_cache_key`,
`get_cached`, `set_cached` or the `_cache_ttl` / `_cache_lock`
fields. Call the cache object instead, keeping the key arguments and
their schema/extra namespacing unchanged.

Sync and async postgres introspectors now accept the injectable
`cache` argument and forward it to the base class. The cache tests
follow the same move: keys come from `_cache.key`, the TTL is
`_cache.ttl` and the lock lives on the component.
… CLI types

Materialized views already existed as first-class typed methods on the
introspector, so the families nothing answered for get their own now.

Sequences are read from ``information_schema.sequences``, the SQL standard
view every supported server carries, with ``pg_sequences`` joined beside
it for the two columns the standard view omits (``cache_size`` and
``last_value``). That catalog view arrived in PostgreSQL 10, so the join
is gated on the server version: a 9.x server is asked the narrower
question and reports those two columns as absent, rather than failing on
a relation it does not have. Each row is a frozen ``SequenceInfo``:
``cycle_option`` is normalised from the server's YES/NO text to a bool,
and a sequence that never handed out a value reports ``last_value`` as
None instead of an error. The question is still assembled from
expression objects in ``expression/introspection.py`` and rendered by
``format_sequence_*_query_statement`` on the dialect mixin -- no query
text is ever concatenated.

Installed extensions become a first-class question of the schema
introspector, where the objects that live in a schema are read:
``list_extensions`` / ``get_extension_info`` / ``extension_exists``
answer from ``pg_extension`` joined to ``pg_namespace`` and cache under
a schema-scoped key. ``status.get_overview()`` keeps its own call for
the overview it builds.

The CLI answers for both families -- ``materialized-views`` and
``sequences``, each listing the schema or one named object -- through
``backend.introspector`` and the existing output provider. Both adapt
the dialect first, because the sequence query reads the server version
and an unadapted dialect has none to read.

Tests read a live server: the sequence/MV fixture creates a sequence
with non-default attributes and a populated materialized view, and the
CLI cases drive ``main()`` for the two new subcommands, asserting
non-empty output for a listing and a single object. A new example
(``examples/introspection_objects.py``) prints both families and then
measures the cache -- questions asked and wall time, cold versus warm --
by wrapping the introspector's executor; with no server it names the
environment variables to set and exits non-zero without inventing a
reading.
…at died

The materialized-view CLI tests failed on every CI job with
"Materialized view not found", preceded by setup errors about a duplicate
pg_class name and a missing relation. The cause was not the objects
themselves: a previous run that died before its teardown left
arx_cli_demo_tbl and arx_cli_demo_mv behind, the fixture's setup then hit the
duplicate part-way through, and every later assertion in the module read a
half-built schema.

Two changes. The drops run first and tolerate a leftover -- refusing to
continue is what turned one dead run into twelve failing jobs -- and the
table drop carries CASCADE so a straggler view cannot outlive its table.
The creates keep the privilege of failing loudly: if a CREATE fails, the
objects the tests assert on are genuinely absent and that must be an error
rather than a test that reads nothing.

The clause order in the drops is now the dependency order too, because a
view names its table and the table cannot go first.

Verified by poisoning the server first: leftover table and materialized view
in place, no sequence, then 8 passed and the two documented Windows
async-CLI skips.
…matrix

The first fix did not hold: CI still failed with the same duplicate
pg_class_relname_nsp_index on CREATE and the same "relation does not exist"
behind it. Forgiving drops cannot fix that, because the collision is on the
CREATE -- the matrix runs a dozen jobs against the same scenario servers, and
two of them reaching `CREATE TABLE arx_cli_demo_tbl` at once is a duplicate
regardless of how politely the preceding DROP behaved.

Each run now suffixes its three names with a per-process value, so a leftover
from a dead run and a concurrent job are both simply other rows. The fixture
yields the names and every assertion reads them from there instead of from a
module constant, which is what was wrong with the first attempt: the tests
kept the fixed names and the new ones were never looked at.

The drops stay forgiving and ordered by dependency (the view before the table,
CASCADE on the table), and the creates still fail loudly -- a failed CREATE
means the objects the tests assert on are absent, which must not pass quietly.

Verified: 8 passed plus the two documented Windows async-CLI skips, and the
whole introspection directory green for postgres_18. ruff clean.
…pability

The default listing path reads `cache_size` and `last_value` from
`pg_sequences`, which arrived in PostgreSQL 10. On a 9.x server the
introspector asks the narrower question and those columns are legitimately
absent -- the code was right and the test was not: it asserted
`cache_size == 5` unconditionally, so three jobs (9, 12, 19beta4) failed with
`assert None == 5`.

The assertions now read the same `_server_has_pg_sequences()` gate the
introspector resolves through, so a 9.x server is checked for the documented
absence rather than for a value it cannot produce. The narrow-query test,
which forces `include_pg_sequences=False`, already asserted the absence and
needed no change.

Verified against the reachable 18 server: 22 passed.
…e alone

The materialized-view read joined `pg_matviews m` to `pg_class c` on
`c.relname = m.matviewname` only. `relname` is unique per namespace, not per
database, so a same-named row in any schema could stand in for the one asked
about. On the parallelised suite (`-n 8 --dist=loadgroup`, eight workers on one
server) that substitute row frequently belonged to a relation another worker
had just dropped, and the server refused the whole query with "could not open
relation with OID" -- which is what the last two postgres jobs reported.

`pg_class` carries no namespace name, only `relnamespace`, so the namespace
test is a scalar subquery resolving that OID against the schema name
`pg_matviews` reports. The existing `pg_namespace` join keeps both of its own
comparisons.

Verified on the live 18 server: an MV created before the read is listed with
its definition, and `is_materialized` still arrives as a bool. Regression:
the materialized-view suite and the CLI-object suite both pass.
Per-run *names* were not enough. The suite runs `-n 8 --dist=loadgroup`,
eight workers against the same scenario server, and with every worker's
objects in `public` one worker's `DROP ... CASCADE` still landed on a
relation another was mid-read on: the server reported "could not open
relation with OID" during the fixture's own setup, which is what the last
postgres job showed.

Each run now creates a schema carrying its suffix, puts the three objects in
it, and asks the CLI for that schema (`--schema` is already honoured by every
subcommand). Nobody else drops a relation inside another worker's namespace,
so the "open relation" failure has no route to reproduce.

The cyclic fixture dependency is gone as well: `conn_args` used to be read by
`cli_objects`, which then had to be re-declared to point back at it. Both now
read one `_server_config()` helper, and the schema assertion reads the value
the fixture created rather than assuming `public`.

Verified on the live 18 server: the objects appear in the run's schema, are
absent from `public`, and the whole introspection directory plus the
materialized-view suite pass. Drops are forgiving; creates fail loudly, as
before, because a missing object means the assertion is about nothing.
The introspect subcommand accepted a list of types and nothing in the tree said what each one reads or where its answer comes from. This adds a README to each backend's cli package: the questions with the catalogue each reads, the options, the notes that keep an answer from being misread (estimate lengths, upper-casing, version gates, capabilities that are refused rather than empty), and examples.

The tables are generated from the code, not from memory -- every entry was checked against the statement it dispatches to.
Twenty backend-specific families had no capability switch, so a caller
asking "can I read this backend's sequences" had no method to call and had to
try the question instead. The switches are now declared, one per family,
each naming the relation it reads so the claim can be checked against the
catalogue rather than trusted.

The shared names are the point: `supports_sequence_introspection` is the same
method in Postgres, Oracle, MariaDB and SQL Server, and
`supports_partition_introspection` in MySQL and ClickHouse, so a caller that
asks once gets the same kind of answer everywhere the family exists.

Postgres: extensions, materialized views, sequences. MySQL: table options,
partitions. MariaDB: table options (sequences already had a switch).
Oracle: sequences, synonyms, packages. Snowflake: databases, schemas,
warehouses. ClickHouse: partitions, table engine. SQL Server: procedures,
sequences, schemas. BigQuery: datasets. Firebird: generators, domains.
Core no longer arbitrates a per-backend introspection vocabulary, so
``available_introspect_types`` is gone and this CLI could not even import.
The ``type`` argument goes back to ``choices=INTROSPECT_TYPES`` -- the
hand-written list this backend owns -- with a plain help string that spells
the same eleven families out. The membership is unchanged.

The ``--schema`` flag is removed as well, per the connection-layer rule: a run
already names one database, so the scope is fixed before the parser is built
and asking a second time can only disagree with the connection. Every call
site passes ``schema=None`` and lets the introspector resolve
``config.default_schema``, then ``search_path``'s first entry, then ``public``.

The epilog and the README lose their schema example and their "the schema is
explicit" note; the README says instead that the connection fixes the scope.
``--schema`` is now refused like any unknown option.

The CLI-object tests follow: their fixture puts the objects in the default
schema again (that is the only scope a run can reach), the connection args no
longer pass a schema, and two parser-contract tests pin what the parser offers
-- the flag is refused, and ``--help`` lists every type without it.
…ract

The row parsers are this backend's implementation of the base introspection
contract: the base declares them and every backend must supply them, so the
leading underscore described a visibility the code never had. It is also how
the next private helper gets justified, which is what this branch is trying
to stop.

default_schema becomes a property rather than a method, beside dialect and
cache: it states a fact about the database, it does not perform an action.

No aliases: the old names are gone.

Postgres side: sixteen row parsers -- the shared families plus
materialized views, sequences and extensions -- and two default_schema
properties, one on the introspector mixin and one on the dialect
capability mixin, fourteen call sites between them. Three tests follow.
supports_extension_introspection, supports_materialized_view_introspection
and supports_sequence_introspection answer the families this backend
declares, and the mixins have carried them since those families were named.
The protocols that mirror those mixins did not declare them, so the
reverse-coverage guard failed on all three, in every matrix entry.

Declared in PostgresExtensionSupport, PostgresMaterializedViewSupport and
PostgresSequenceSupport, beside the detection and the statements each of
them describes. The sequence switch keeps the name MariaDB uses, so the
family answers the same way on the two engines that share the question.
Two things kept the 19-era jobs from being a clean signal.

The 19 job asked for ``postgres:19beta4``, an image superseded by the release
candidate. The job name and the README's "CI-tested server versions" line now
say ``19rc1`` so the matrix and the documentation cannot drift apart.
Measurements recorded against 19beta4 in code and docs are left alone: they
describe what was observed on that build, not what CI runs now.

The other was a real failure, in three of the last run's four jobs, each
naming a different OID: a listing answers for every object of its type in the
schema the connection resolves, and describes each one by OID -- a
materialized view's definition, a sequence's last value. PostgreSQL resolves
that OID through the relcache, which is not part of the query's snapshot. The
suite runs eight workers against one server, so a sibling worker's DROP can
land between the snapshot that listed a row and the lookup that describes it,
and the server answers "could not open relation with OID" instead of leaving
the row out.

A retry in a fresh process reads a fresh snapshot, in which the dropped
relation is absent from the catalogue altogether. Only that error is retried,
at most twice more, and the last attempt's result is returned unchanged: a
persistent failure still fails the test with the server's own message.
"table" answered several catalogue questions at once -- the relation's row,
its columns, its indexes and its foreign keys -- and printed them as three
sections. Each is an item the introspector reports on its own and each already
has a type, so the aggregate was a second way to ask for the same rows and the
only way to get an unparseable answer: under "-o json" the sections are three
arrays one after another, which is not a document.

What remains is one type per item, and the help text is built from that list.
…ding

- the column statement selects character_maximum_length, numeric_precision, numeric_scale and datetime_precision from information_schema.columns; the LEFT JOIN keeps a relation that view does not list, and parse_columns maps all four
- parse_tables reports MATERIALIZED_VIEW, FOREIGN_TABLE and PARTITIONED_TABLE, and the table_type filter gains those spellings instead of folding partitions into BASE TABLE
- the CLI extensions answer comes from introspector.list_extensions() (pg_extension), not dialect._extensions, so it no longer grows with the registry
- supports_* predicates use version_at_least(): an unadapted dialect answers False instead of raising
- parse_database_info reads self.server_version, reporting 'unknown'/(0, 0, 0) when unresolved, and carries the size and table_count columns the statement returns
…robe answers false

The capability predicates now ask version_at_least(), so the direct-mixin test hosts must answer it as a dialect does. The JSON arrow probe is one of those predicates: an unadapted dialect says no rather than raising, and the test pins that contract.
- the database-info projection gains a scalar subquery, so the count still costs no second round trip

- a table is what the catalog calls one: pg_class rows with relkind 'r' or 'p'. Views, materialized views and foreign tables are not tables

- scope is the whole database, not the connection's default schema, because that is the question SQLite answers and the one the field's wording asks: the server's own namespaces (pg_catalog, information_schema, pg_toast, pg_temp*) are excluded, every schema the database owns counts

- parse_database_info coerces the cell to int and reports None for a missing or unreadable one instead of failing the whole answer; sync and async share the mixin parser
…ract

Core 5f000666 turned a second task's entry into an active outermost transaction
from a UserWarning into a TransactionError. The shared suite followed
(testsuite 3289103); this repository still carried its own full copy of the old
test, so its matrix kept asserting a warning that no longer happens.

Replace the copy with the shim the other backends already use: the fixtures
already come from the testsuite's conftest in this directory, and the tests
themselves should have one source.
- views NAME answers for one ordinary view through get_view_info(), the same
  object the listing describes, so a caller asks a single object the way it
  asks the family rather than filtering the listing itself
- extensions NAME reads get_extension_info(): the catalog is asked once and
  filtered there, and an extension this database does not have is named as
  such instead of dropping the argument and printing every installed one
- tables NAME and database NAME are refused with exit 2: neither type has a
  question for one object, and answering the family listing under a one-object
  question is a different answer than the one asked for
- the hasattr() guard around list_extensions() is gone; PostgreSQLIntrospector
  always has it, and whether a family can be asked at all belongs to the
  dialect's capability switch, not to a probe of the object
- _TYPE_CAPABILITIES maps each type to the dialect switch that answers for
  it, and INTROSPECT_TYPES is derived from that mapping, so a type cannot be
  advertised without saying what decides whether it can be asked
- the handler adapts the dialect once, at the entrance, and reads the switch
  before any branch runs; the per-branch introspect_and_adapt() calls in
  materialized-views and sequences are gone, because "can this be asked" was
  being decided by whichever branch happened to need the server version
- a switch that answers False exits 2 with the shared message, instead of
  running the query and printing an empty list: "this server cannot report
  sequences" and "this database has no sequences" are different answers
- partition metadata keeps its switch and gets no type: the introspector has
  no question for it, so there is no family to list

The gates pin the vocabulary in both directions (the advertised list is the
mapping; every switch the dialect answers is reachable, partition metadata
excepted), run every type through both the synchronous and the asynchronous
chain against a fake backend, and check that the switch is read only after
adapting.
- the "table" type was deleted on 2026-10-27 but stayed in the CLI README and
  in both introspection guides: the row, the examples that ran it, and the
  "table name required" column it was counted in are gone
- the type table now lists the ten types the parser offers, copied from
  INTROSPECT_TYPES, with a NAME column saying which take one: required for
  columns/indexes/foreign-keys, optional for triggers and for the four
  single-object families, refused for tables and database
- both guides state the exit codes the parser now uses: 1 when the run could
  not finish, 2 when the request cannot be served here, and that an empty
  listing is a successful answer rather than an error
- the README examples ask for one view and one extension, the two questions
  that used to have no command line
The handler now adapts once at the top, before it asks any question, so a
stand-in backend has to answer `introspect_and_adapt()` -- the same way the
fakes in the other backends do. Without it the two TestExtensionsAnswer cases
exited 1 before reading a row, which the full matrix caught and the targeted CLI
run did not.
`fix/column-constraint-gates` was squashed into `release/v1.0.0.dev30` as
`57439841`, so the branch reference this workflow used to resolve core through
no longer names anything ahead of the release. Pointing at the release branch
makes each run test what a user installs.

The testsuite stays on `fix/column-constraint-gates` on purpose: its own branch
carries the two `pytest_compat` shims that keep pytest 9.2 working (the
benchmark fixture's `_nodeid` read and xdist's loadgroup suffix), and those are
not on the testsuite's release branch yet. That line is repointed when the
testsuite merges.
Named after the pull request once it had a number, which is the order
the fragment check requires: 4 entries, one per kind of change.
@sonarqubecloud

Copy link
Copy Markdown

@vistart vistart added the enhancement New feature or request label Oct 31, 2026
@vistart vistart self-assigned this Oct 31, 2026
@codecov

codecov Bot commented Oct 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.03407% with 117 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.58%. Comparing base (646a251) to head (ce9fac5).

Files with missing lines Patch % Lines
...tiverecord/backend/impl/postgres/cli/introspect.py 75.00% 22 Missing and 10 partials ⚠️
...erecord/backend/impl/postgres/expression/values.py 84.74% 9 Missing ⚠️
...record/backend/impl/postgres/functions/pgvector.py 62.50% 9 Missing ⚠️
...erecord/backend/impl/postgres/mixins/ddl/domain.py 61.90% 5 Missing and 3 partials ⚠️
...activerecord/backend/impl/postgres/backend/sync.py 12.50% 7 Missing ⚠️
...ackend/impl/postgres/introspection/introspector.py 96.17% 4 Missing and 3 partials ⚠️
...impl/postgres/introspection/status_introspector.py 94.33% 4 Missing and 2 partials ⚠️
...ord/backend/impl/postgres/backend/async_backend.py 37.50% 3 Missing and 2 partials ⚠️
...iverecord/backend/impl/postgres/backend/backend.py 37.50% 3 Missing and 2 partials ⚠️
...ctiverecord/backend/impl/postgres/adapters/base.py 42.85% 4 Missing ⚠️
... and 13 more
Additional details and impacted files
@@                   Coverage Diff                    @@
##           release/v1.0.0.dev17      #63      +/-   ##
========================================================
+ Coverage                 75.93%   77.58%   +1.64%     
========================================================
  Files                       349      353       +4     
  Lines                     16343    17043     +700     
  Branches                   2731     2752      +21     
========================================================
+ Hits                      12410    13222     +812     
+ Misses                     2952     2834     -118     
- Partials                    981      987       +6     
Flag Coverage Δ
postgres-18 77.58% <90.03%> (+1.64%) ⬆️
python-3.14 77.58% <90.03%> (+1.64%) ⬆️
unittests 77.58% <90.03%> (+1.64%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@vistart
vistart merged commit b6729ca into release/v1.0.0.dev17 Oct 31, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant