Repository navigation
feat(postgres)!: the catalogue as nodes, and one introspect question per family - #63
Merged
Merged
Conversation
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.
…n questions as statement factories
"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.
|
Codecov Report❌ Patch coverage is 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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Description
Lands
fix/column-constraint-gatesonrelease/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:
refactor(postgres): build the introspection catalog queries from their node parameters).refactor(postgres): name the materialized-view, sequence and extension questions as statement factories).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).table_countcomes 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 featurerefactor: Internal restructuring with no intended behaviour changetest: Adding missing tests or correcting existing testsdocs: Documentation onlyperf/chore/ci/build/revert(not relevant)Breaking Change
Impact on End-Users:
introspect tableno longer exists;extensions NAMEandviews NAMEanswer for one object.NAMEa type cannot use is an error (exit 2) rather than an ignored argument.Impact on Backend Developers (Custom Implementations):
parse_*lost its leading underscores, and the domain formatters this backend serves are declared.Related Repositories/Packages
python-activerecord57439841pytest_compatpython-activerecord-testsuite1991449python-activerecord-postgresf1492efCore is merged (
rhosocial/python-activerecord#127, squashed as57439841); this branch's CI now installs core fromrelease/v1.0.0.dev30. The test suite is still referenced by branch, because itspytest_compatshims are not on its release branch yet; that reference is repointed when the test suite merges.Testing
Test Plan: The full matrix ran green on this exact head (
f1492ef) as run41092190799— 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
README.md, the CLI guide).format_*).Additional Notes