Skip to content

feat(clickhouse)!: storage clauses as nodes, and one introspect question per family - #6

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

vistart merged 51 commits into
release/v1.0.0.dev1from
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.dev1.

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. 50 commits, 7 of them marked breaking.

Headline changes:

  1. CREATE TABLE's storage clauses are expression nodes — engine, ORDER BY, PARTITION BY and TTL — which is also what made partitions and table-engine answerable (feat(clickhouse)!: declare CREATE TABLE storage clauses as expression nodes).
  2. The introspect CLI offers partitions and a table's storage definition, and reports the relation kind as the server answers it (feat(introspection): read ClickHouse's partitions and a table's storage definition).
  3. introspect --async refuses instead of raising out of the placeholder. AsyncClickHouseBackend is a fail-fast stub, and the command used to call it unguarded, ending in a traceback; it now says so in one sentence, and the five *_async call sites that named methods no introspector has were corrected (fix(cli): introspect --async refuses…).
  4. The type list excludes the two families this engine does not have, and derives the rest from _TYPE_CAPABILITIES.

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 partitions and introspect table-engine exist; introspect table is gone.
  • introspect ... --async now answers "--async is not supported by the ClickHouse backend" and exits 1, instead of a traceback.

Impact on Backend Developers (Custom Implementations):

  • Storage clauses are nodes; the namespace is named after the engine, not the slot; parse_* lost its leading underscores.
  • The dialect surface declares the type positions it decides.

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-clickhouse c6295e1 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 (c6295e1) as run 41092221103 — 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.
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, 93 files, 826 occurrences, no SQL moved):

W2, statement-level classes -> *Statement (core 34e1fb45). The largest:
CreateTableExpression->CreateTableStatement 196, DropTableExpression
->DropTableStatement 127, QueryExpression->QueryStatement 93, InsertExpression
->InsertStatement 59, AlterTableExpression->AlterTableStatement 20,
CreateMaterializedViewExpression->CreateMaterializedViewStatement 18,
TruncateExpression->TruncateStatement 18, SetOperationExpression
->SetOperationStatement 16, RefreshMaterializedViewExpression
->RefreshMaterializedViewStatement 15, CreateViewExpression->CreateViewStatement
13, CreateTableLikeExpression->CreateTableLikeStatement 13, CTE-family below,
CreateTableCloneExpression->CreateTableCloneStatement 11, BeginTransactionExpression
->BeginTransactionStatement 11, and the rest of the 57-class list this
package touches (Create/Drop/Alter Database, Index, Trigger, Type, Domain,
JsonDualityView, MaterializedView, View, Explain, Delete, Update, Merge,
CommentOn).

Three class names were resolved against the core source rather than the wave
guide's prose, because the guide names neither: CreateTableLikeExpression ->
CreateTableLikeStatement (NOT CreateTableFromTemplateStatement, which is what
CreateTableUsingTemplateExpression became -- the two are different nodes, both
present in core), CreateTableAsExpression -> CreateTableAsStatement and
CreateTableCloneExpression -> CreateTableCloneStatement.

W1, CTE family (core 683a183e): CTEExpression->CTEClause 12,
WithQueryExpression->WithStatement 6, and the call-site keyword
main_query=->query= 3 (examples/query/cte.py; every occurrence in this package
was an argument of WithQueryExpression).

W3a, predicate nodes (core 890aaaa3): ILIKEExpression->ILikePredicate 1,
XMLExistsExpression->XMLExistsPredicate 1. Both were module paths pinned as
strings in test_expression_roundtrip_all.py.

W3b, value / function / introspection families (core 5dc64fd5):
SQLValueExpression->SQLValue 24, CastExpression->CastFunction 4,
CaseExpression->CaseFunction 3, CollateExpression->CollateFunction 2,
TrimExpression->TrimFunction 2, JSONDocumentExpression->JSONDocumentValue 3,
JSONTextExpression->JSONTextValue 3, UUIDCastExpression->UUIDCastFunction 1,
datetime._TemporalValueExpression->_TemporalValue 1, and the six introspection
containers that take the prefix: TableInfo 5, ViewInfo 5, ForeignKeyInfo 4,
DatabaseInfo 3, TriggerInfo 3, ColumnInfo 3 (all in introspector.py).
IndexInfo and IndexColumnInfo keep their names -- core did not rename them.

Override points renamed (functional; not renaming would drop the override):

- mixins/cte.py: format_cte_expression -> format_cte_clause, with the
  super() call and the :meth: reference in supports_materialized_cte. This is
  the one that matters most on this backend: the override is what refuses
  ``AS NOT MATERIALIZED``, and leaving the old name would silently fall back
  to core's spelling and emit a statement the server rejects (Code 62,
  measured on 26.7.3.19).
- mixins/set_operation.py: format_set_operation_expression
  ->format_set_operation_statement.
- mixins/transaction.py: format_begin_transaction
  ->format_begin_transaction_statement.
- test_expression_roundtrip_all.py: the two formatter names it asserts the
  dialect does NOT declare, format_ilike_expression->format_ilike_predicate
  and format_xmlexists_expression->format_xmlexists_predicate.

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

Behaviour: none. Every edit is a name; no SQL spelling moved. The only
behavioural consequence is the one above, and it is a rename that restores
intent rather than a change of intent.

Deliberately not renamed:

- The backend's own statement classes (ClickHouseInsertExpression,
  ClickHouseCreateMaterializedViewExpression, ShowExpression,
  ClickHouseDropPartitionExpression, ...). Core renamed only its own nodes;
  the M1 naming work for backend-local classes is not part of this wave.
- WildcardExpression (3 call sites, all select-list, examples/insert/*.py and
  examples/ddl/view.py). Core's split has no intermediate state -- ``Wildcard``
  never shipped -- so the migration goes straight to AllColumnsWildcard /
  RowCountWildcard and belongs to the wildcard commit, not to this one.
- ColumnConstraintType / TableConstraintType, which core's S3 deleted. That is
  the wave-2 surface.

Note: the constraint surfaces still reference the enums core's S3 removed, so
this commit is an expected red window -- 10 modules still fail to import, all
of them on those two deleted symbols, plus 3 example modules on
WildcardExpression. Wave 2 migrates the construction sites onto the branch
nodes and wave 4 finishes the wildcard.

Verification:

- import smoke over every module in the backend package: 153 imported clean,
  29 failing -- 10 on the deleted constraint enums, 3 on WildcardExpression
  and 16 because their module body opens a connection and no ClickHouse is
  listening on this host (baseline before this commit: 73 clean / 109 failing).
- python -m compileall src tests docs: exit 0.
- ruff check on the 86 changed .py files: byte-identical finding profile to
  the HEAD baseline for the same file set (grouped per file and per rule),
  so no new class of finding; the one delta is an E501 whose measured length
  moved from 124 to 123 characters in tests/providers/fixtures/basic.py.
- tests/.../backend/expression/test_expression_signatures.py: exit 0.
- tests/.../backend/expression/test_json_expressions.py +
  tests/.../backend/test_identifier_quoting.py: exit 0.
…odes

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, 39 files):

  ColumnPrimaryKeyConstraint  ~60   ColumnDefaultConstraint    ~25
  ColumnNotNullConstraint    ~100   ColumnNullableConstraint     4
  ColumnUniqueConstraint        2   ColumnCheckConstraint        1
  TablePrimaryKeyConstraint     4   TableCheckConstraint         1
  TableUniqueConstraint         1   TableForeignKeyConstraint    3

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``.

Two dispatch sites that read the deleted tag now read the branch class:

* ``ClickHouseTableMixin.format_table_constraint`` narrows on
  ``TablePrimaryKeyConstraint`` / ``TableCheckConstraint`` /
  ``TableUniqueConstraint`` / ``TableForeignKeyConstraint`` /
  ``TableExcludeConstraint``. PRIMARY KEY and CHECK render (both measured
  accepted in a column list); UNIQUE / FOREIGN KEY / EXCLUDE refuse by name
  with the measurement quoted, as they did before. The production-level
  DEFERRABLE / INITIALLY traits now refuse explicitly too -- ClickHouse has no
  spelling for either, and the previous code dropped them silently.
* ``ClickHouseTableMixin.format_column_definition`` reads
  ``constraint.branch.is_auto_increment`` instead of the production's deleted
  field, because ``is_auto_increment`` belongs to the PRIMARY KEY branch.

``ClickHouseDDLColumnMixin`` keeps no ``format_table_constraint``: the old
column-side override existed only to carry the narrowing, and it now lives on
the table mixin where core dispatches table constraints. That also retires the
unconditional ``format_add_table_constraint_action`` refusal, whose message
("ClickHouse does not support table constraints") had stopped being true: the
server accepts ``ADD CONSTRAINT ... CHECK`` and ``ADD CONSTRAINT ... ASSUME``.

``supports_drop_constraint_if_exists`` flips to ``True`` on the strength of a
live measurement (``DROP CONSTRAINT IF EXISTS nope`` is a silent no-op, while
the plain form is Code 36). Core renders the clause itself as of its
``4875ea3b`` fix, so the draft's override that spliced ``IF EXISTS`` in is
gone -- keeping it would emit ``DROP CONSTRAINT IF EXISTS IF EXISTS <name>``.

``format_add_table_constraint_action`` and ``format_drop_table_constraint_action``
are now inherited; ``ClickHouseTableMixin`` gained the two overrides above and
``ClickHouseConstraintMixin`` carries the switch values.

``test_create_table_like`` keeps its ``NOT NULL`` render assertion: the draft
dropped it, but ``ColumnNotNullConstraint`` renders on this dialect
(``format_column_not_null_constraint`` consults ``supports_not_null_constraint``,
which is ``True``), so the assertion still holds and dropping it would have
lost coverage silently.

Tests: the new ``test_clickhouse_constraint_capabilities.py`` pins every switch
to its measured value and checks that each one is declared by this package
rather than inherited from a core default.

Verification:

- tests/.../backend/adapters/test_adapters_table_mixins.py, exit 0.
- tests/.../backend/ddl/test_create_table_like.py, exit 0.
- tests/.../backend/ddl/test_ddl_source_mixin.py, exit 0.
- tests/.../backend/dialect/test_dialect_security.py, exit 0.
- tests/.../backend/test_clickhouse_constraint_capabilities.py, exit 0.
- testsuite feature/ddl shared contract, exit 0.
- import smoke over the backend package, 131 modules, 0 failures.
- ruff on the changed src files: clean.
…be protocol

The switches already took ``(self)`` and already answered their measured
values; what this commit adds is the part that makes that claim checkable, and
it removes three pieces of prose that had gone stale.

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

- ``test_the_declaring_class_lives_in_this_package`` -- 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. The distinction matters more here
  than on most backends: core's ``ConstraintMixin`` defaults most constraint
  switches to ``True``, which is the opposite of what a live ClickHouse says
  about almost all of them, so an inherited answer would not merely be
  unpinned, it would be wrong. Not every switch is on
  ``ClickHouseConstraintMixin`` -- ``supports_drop_constraint_if_exists`` is
  declared by the DDL-column mixin, which reaches the MRO first -- so the guard
  is 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_docstring_quotes_the_measurement`` -- pattern 6, made mechanical: a
  switch docstring must contain the measured server version, a server error
  code, 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. This is the check that caught ``supports_generated_column``, whose
  docstring had described the absence without quoting the measurement; it now
  quotes the parser's own column-modifier list from 26.7.3.19.
- ``test_the_measured_engine_is_pinned_in_the_constraint_mixin`` -- the
  ``MEASURED_ENGINE`` constant and the docstrings have to name the same
  release, or the evidence stops identifying its source.

**No ``_require_*`` helper, and that is a measurement rather than an omission.**
Every switch on this backend is a statement-level fact that does not move with
the version (asserted by ``test_version_does_not_move_the_values``), so there
is no kind x version matrix for such a helper to carry. The per-kind judgement
lives in the two table-level formatters, where the branch class is in scope:
``format_table_constraint`` narrows the CREATE position and
``format_add_table_constraint_action`` narrows the ADD position, which accept
different kind sets on this engine (``ADD CONSTRAINT pk PRIMARY KEY (id)`` is
Code 62 while ``CREATE TABLE ... PRIMARY KEY (id)`` is accepted).

**Docstrings corrected against what the code now does** (pattern 10: the
measurements are not re-derived, only the claims that had drifted):

- ``supports_column_primary_key_constraint`` said the flat
  ``supports_primary_key_constraint`` "is also ``False``". It returns ``True``,
  and the two switches answer deliberately different questions -- the flat one
  gates the ``PRIMARY KEY`` column *modifier* (measured accepted), this one
  names the per-level *constraint* (measured absent). The note now says so
  instead of contradicting the code next to it.
- The per-level block's "Core S3 may rename them" is replaced with what S3
  actually did: it moved the constraint *nodes*, deleted the two enums, and
  added no protocol for these switches, so the names stand.
- Two "Not implemented here; see the task's scope note" asides about the
  column-``DEFAULT`` trap are reworded: the gate is a render-path concern, not
  a switch value, and the scope note they pointed at belongs to a task rather
  than to this module.

Verification:

- tests/.../backend/test_clickhouse_constraint_capabilities.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. All three were select-list
positions, so all three became ``AllColumnsWildcard``:

  examples/insert/single.py      select=[WildcardExpression(d)] -> AllColumnsWildcard(d)
  examples/insert/batch.py       select=[WildcardExpression(d)] -> AllColumnsWildcard(d)
  examples/ddl/view.py           select=[WildcardExpression(d)] -> AllColumnsWildcard(d)

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

Backend-specific: ``ClickHouseRowCountDistinctWildcard``, the
``COUNT(DISTINCT *)`` spelling. Spec 4.10.2 leaves it to ClickHouse ("only
ClickHouse supports it"), the expression catalogue names the node, and the
wildcard survey records it as row B8 -- the one backend of eleven that accepts
it. It extends core's ``RowCountWildcard`` and takes no relation, exactly as
that node does: there is no qualified form of this argument. The override is
``format_row_count_wildcard``, which is already in core's ``WildcardSupport``,
so no new public surface is added.

One placement decision is load-bearing: ``ClickHouseWildcardMixin`` has to
precede ``ExpressionMixin`` in the dialect's base list. C3 gives the earlier
name priority, and listed after ``ExpressionMixin`` the override would never be
consulted -- the node would still render, as core's bare ``*``, and the SQL
would look right while the server returned a different number. The comment in
``dialect.py`` and ``test_the_override_is_reached_before_core`` both record it,
mirroring the Snowflake layout.

No new capability probe. The nodes that carry the ClickHouse spelling exist only
in this backend, so there is no dialect that has the node and lacks the
spelling; what is gated is core's wildcard contract, which is core's
``WildcardSupport`` and which this dialect already satisfies.

Tests: ``test_clickhouse_wildcards.py`` covers both positions -- the bare and
qualified all-columns forms, ``COUNT(*)`` unchanged, ``COUNT(DISTINCT *)``
reached through the aggregate's ``is_distinct`` flag, the MRO ordering, and
core's aggregate contract that admits a row-count wildcard only to ``COUNT``.
The evidence is marked documented rather than re-measured: no ClickHouse
instance is reachable from this worktree, so the assertions verify the rendering
contract, not the server's acceptance.

Verification:
- import smoke over the backend package: 133 modules, 0 failures.
- tests/.../backend/test_clickhouse_wildcards.py, exit 0.
- tests/.../backend/protocol/test_protocol_conformance.py, exit 0.
- ruff on the changed files: clean.
…nd wildcards

The matrix is exhausted against every class in the two covered packages and
classifies each failure, so it is also the place where a rename or a new node
shows up as a stale entry. Four things had drifted:

**Stale keys from the W2 rename** (guide section 3, item 4). Eight entries
named classes core renamed to ``*Statement``: ``CreateSchemaExpression``,
``DropSchemaExpression``, the three sequence statements and the three property
graph statements. The entries were correct in substance and wrong in name, so
they read as "this class cannot be found" rather than "this class does not
render". Renamed to the classes core now defines.

**LIKE / ILIKE need real operands.** The introspective constructor hands every
unannotated slot a bare string, and both predicates call ``.to_sql()`` on their
operands, so the guess produced an ``AttributeError`` rather than a render.
Registered the same way core's own matrix registers them:
``LikePredicate(d, "LIKE", Column(d, "a"), Literal(d, "%x%"))`` and
``ILikePredicate(d, Column(d, "a"), "%x%")``. ILIKE then reaches its formatter
and is classified as unmodelled, which is the intended verdict -- ClickHouse
mixes in no ILIKE mixin.

**ADD CONSTRAINT needs a constraint node.** ``AddTableConstraint`` carries a
``TableConstraint``, and the guess supplied a string, which surfaced as
``constraint.branch`` on a ``str``. Registered with a table-level PRIMARY KEY
branch, the cheapest one this dialect renders in that position.

**Three new bases and the S1-c PASSING node.** ``ColumnClause``,
``ColumnConstraintBranch`` and ``TableConstraintBranch`` arrived with core's
two-level constraint nodes; each is a base that names no formatter (its concrete
members do), so all three are pinned in ``LEGITIMATE_NON_RENDERS`` with
``_NO_FORMATTER``, matching the entry the PostgreSQL matrix pins for the same
three classes. ``xml.PassingClause`` dispatches on ``format_passing_clause``,
which ClickHouse does not declare -- it mixes in no SQL/XML formatter at all --
so it joins the SQL/XML group in ``UNMODELLED_FORMATTERS``.

Verification:
- tests/.../backend/test_expression_roundtrip_all.py, exit 0. Coverage report:
  451 registered, 444 constructible, 7 pinned-unconstructible, 59
  pinned-non-render; rendered=264, unsupported=120, unmodelled=32, non-render=28.
The testsuite's release/v1.0.0.dev17 predates the S3 constraint nodes and the
wildcard split; this branch's tests need the matching one. The core pin already
names fix/column-constraint-gates, so only the testsuite line moved -- the same
change the other eight backends made (postgres bb7e28c, mysql edf1279).
Wave 1 renamed the override to format_cte_clause, which is what restores the
NOT MATERIALIZED refusal core's dispatch would otherwise bypass. The parameter
was left annotated as the generic BaseExpression; guide section 8 asks for the
node itself, and the override reads not_materialized off it, so CTEClause is
what it actually takes. Annotation only -- the refusal path was already
restored and is unchanged:

  plain            -> \c\ AS (SELECT \�\ FROM \	\)
  materialized     -> \c\ AS MATERIALIZED (SELECT \�\ FROM \	\)
  not_materialized -> refused by name (Code 62, measured)

Verification:
- tests/.../backend/expression/test_expression_signatures.py, exit 0.
- ruff on the file: clean.
…m contracts

Core retired the W3c class names and rebuilt the set-operation, arithmetic
and predicate construction contracts. This dialect and its tests follow.

Class names: CastFunction/CaseFunction/TrimFunction -> *Expression,
RawSQLExpression -> RawSQL, Pivot/UnpivotExpression -> Pivot/Unpivot, and
the three dialect hooks format_pivot_expression / format_unpivot_expression /
format_order_by_expression -> format_pivot / format_unpivot /
format_order_by_item.

Set operations: SetOperationStatement is gone, replaced by SetOperation,
whose constructor is (dialect, op, left, right) with op a closed word set
(union / union_all / intersect / except). ClickHouse's formatter maps each
word to the spelling it writes, and every spelling still carries the
ALL/DISTINCT modifier the grammar requires -- ClickHouse rejects a bare
UNION (measured Code 558), so `union` spells UNION DISTINCT and
`union_all` spells UNION ALL. The all_/distinct flag pair, and with it the
one intentional SQL collision the pair guard used to pin, is gone: the word
set has no duplicate member, and an unknown word is refused at construction.
The trailing ORDER BY / LIMIT / FOR UPDATE clauses no longer live on the
node; ordering, row limiting and locking are statement-level clauses, so the
two orphan supports_set_operation_order_by / supports_set_operation_limit_offset
probes go with them, and the Q01 guard now asserts both that the node cannot
carry a FOR UPDATE clause and that the statement-level one still fails fast.

Arithmetic: BinaryArithmeticExpression -> ArithmeticOperation, and `op` is
now the operation word rather than the character (add/sub/mul/div/mod). This
is a semantic change, not a rename: every "+" becomes "add" and every "*"
becomes "mul", because a symbol is refused at construction.

DDL: Literal(inline_literals=True) is replaced by the DdlLiteral class, which
is the class that owns the inline strategy at a DDL syntax position.

Serialization: ExpressionRegistry is no longer importable from
expression.serialization. Deserialization resolves a class through the
serialization class index, so the matrix registers with
auto_register_builtins() and checks resolvability against
registered_expression_classes().

The matrix also gains explicit constructors for the two nodes whose closed
word sets the introspective guesser cannot satisfy (ArithmeticOperation and
SetOperation), which keeps them rendered rather than skipped.
…tattr

Every formatter now reads the fields its node contract declares; the
getattr/hasattr probes are gone from the dialect layer (rg
'(getattr|hasattr)\(\s*(expr|node|clause|item)\b' src -> zero matches).

Migration surface (63 probe reads removed, 61-site checklist plus two
extra sites in mixins/ddl_column.py):
- mixins/json.py (21): alias/paths/pairs/values/path/path_value_pairs/all
  read directly off the ClickHouse JSON expression contracts; the column
  check isinstance()s against BaseExpression instead of probing for
  to_sql. expression/json.py declares 'paths' on
  ClickHouseJSONExtractExpression (keyword-only, default []) so the
  multiple-paths form is a constructor concern, and the test builds it
  that way instead of assigning the attribute after construction.
- mixins/materialized_view.py (32): the formatters dispatch on
  isinstance() between this backend's own nodes and the generic core
  statements, then read declared fields only -- the generic
  CreateMaterializedViewStatement path rejects its unsupported clauses
  (column_aliases/tablespace/storage_options/with_data/no_data) before
  raising the TO-or-ENGINE requirement; CASCADE/RESTRICT and
  CONCURRENTLY/WITH [NO] DATA refusals apply to the generic statements,
  which are the nodes that carry those fields. The _mv_target helper is
  gone (it probed to_table for the isinstance this code now does once).
- mixins/ddl_database.py (4): ENGINE/ON CLUSTER/SYNC are ClickHouse
  clauses living on this backend's own nodes, so the formatters
  isinstance() on those before reading them; the ALTER DATABASE error
  feature name comes from the enum-or-string union via isinstance(Enum).
- mixins/ddl_table.py (3), mixins/dml.py (2), mixins/partition.py (1):
  direct reads of table_options/comment, or_replace/or_ignore, and
  partition_id.
- mixins/ddl_column.py (2, beyond the checklist): the default-change
  operation is read from the enum-or-string union the way the core
  formatter reads it; a new_value that is an expression is recognised by
  isinstance(BaseExpression) instead of a to_sql probe.

Behavior changes:
- INSERT OR REPLACE / INSERT IGNORE are now actually refused: the old
  code probed 'replace'/'ignore', fields no InsertStatement carries, so
  those requests silently rendered as a plain INSERT.
- ADD COLUMN AFTER is no longer rendered: core's AddColumn carries no
  position field, so the probed branch was unreachable dead code; the
  comment now records why the clause is absent.
- CREATE/DROP DATABASE: the ClickHouse-only clauses (ENGINE, ON CLUSTER,
  SYNC) are rendered when this backend's node carries them, and the
  generic statements fail on their own declared fields instead of
  silently omitting what they never had.

Test evidence (central venv, PYTHONPATH=src;tests):
- functions/test_json_functions.py + dialect/test_materialized_view.py:
  76 passed, exit 0
- ddl/test_partition_maintenance.py + test_clickhouse_database_ddl.py:
  15 passed, exit 0
- ddl/test_ddl_generation.py + ddl/test_ddl_coverage.py: 22 passed, exit 0
- dialect/test_dialect_capabilities.py: 24 passed, exit 0
- ruff check on all changed files: clean
… spellings

Wire the core's TypedValueMixin and a ClickHouseTypedValueMixin into
the dialect (this backend's mixin ahead of the core's, as the MRO guard
in the test file pins), so the twelve expression.values nodes render
through ClickHouse spellings.

Tier 2 (the backend's own forms, measured on the scenario instance named
by tests/config/clickhouse_scenarios.yaml -- ClickHouse 26.7.3.19
official build, HTTP 127.0.0.1:18682, database test_db):
- binary: unhex('0102'). CREATE TABLE ... s String DEFAULT unhex('0102')
  accepted; a row inserted without the column read back hex(s) = '0102'.
- UUID: the bare quoted text. u UUID DEFAULT
  '01234567-89ab-cdef-0123-456789abcdef' accepted; read back verbatim
  through toString(u).
- INTERVAL: deliberately not implemented. The grammar's INTERVAL is
  date-arithmetic syntax (INTERVAL n UNIT, unit part of the spelling)
  while the node carries a bare timedelta; the measurements that close
  the question: a String DEFAULT INTERVAL '1' DAY accepted but stored
  '1' (the unit silently dropped), and INTERVAL '1' DAY + INTERVAL '2'
  HOUR answers toTypeName Tuple(IntervalDay, IntervalHour) -- a
  mixed-unit span is not one interval. IntervalDay columns exist and
  take DEFAULT INTERVAL '3' DAY (read back as 3), which makes this a
  real choice: the one working spelling ties the literal to the
  column's interval unit, and the value node carries no unit to tie it
  to. The method stays absent so the base's explicit failure is the
  answer.

Tier 1 audit (quoted ISO dates, TRUE/FALSE): measured accepted verbatim
as DEFAULTs on Date/DateTime/Bool columns on the same instance, so the
base spellings are ClickHouse's own and no override is added.

Test evidence (central venv, PYTHONPATH=src;tests):
- new test_clickhouse_typed_values.py: 12 passed, exit 0 (data-position
  placeholders, exact DDL texts, the INTERVAL NotImplementedError with
  its message, the structural absence of the override, the MRO order,
  and two live CREATE/INSERT/read-back round trips through
  CreateTableStatement + ColumnDefaultConstraint)
- feature/backend -k 'typed or literal or value' (matrix file excluded;
  its registry import is wave M2's to reconcile against the current core
  HEAD): 105 passed, exit 0
- ruff check on all changed files: clean
…e VALUES cells

Follow the core's classed function nodes (9b559030 and after) where
they change what this backend must answer, and the INSERT VALUES branch
that core now owns (8e2e4d82).

char_length: ClickHouseFunctionMixin overrides
format_char_length_function to render lengthUTF8. ClickHouse's LENGTH
counts bytes, so the core's default spelling answers a different question
than the node asks; measured on the scenario server (ClickHouse 26.7.3.19,
127.0.0.1:18682): length('héllo') = 6, lengthUTF8('héllo') = 5,
CHAR_LENGTH('héllo') = 5 (an accepted alias on this release; lengthUTF8 is
the spelling whose availability spans the versions this backend declares).

MRO repair: ClickHouseFunctionMixin now sits ahead of ExpressionMixin in
the dialect bases. ExpressionMixin inherits the core's FunctionCallMixin,
and the mixin was listed after it -- so the char_length override was dead
code from the moment it was added (the tests caught it: LENGTH(%) rendered
where lengthUTF8(%) was expected). supports_functions is unaffected (the
core default it shadows lives in FunctionMixin, which this dialect does
not compose).

COUNT(DISTINCT *): the core now refuses count(dialect, '*', is_distinct=True)
at construction (the row-count wildcard counts rows, which have no
duplicates) and its shared aggregate helper answers every RowCountWildcard
with the bare asterisk -- which would silently drop this backend's
distinct node's spelling and return a count of the wrong thing. The
extension is a different operand, not a modifier, so ClickHouseWildcardMixin
overrides the classed format_count_function entry to consult it:
count(dialect, ClickHouseRowCountDistinctWildcard(dialect)) renders
COUNT(DISTINCT *). Re-measured on the live server: the spelling is
accepted and answers the distinct combinations of every column (4 rows
with a duplicate pair -> count() = 4, count(DISTINCT *) = 3, answered as
countDistinct(a, b)); the previously 'documented, not re-measured' notes
in the mixin and the test are upgraded with that measurement.

INSERT VALUES: the ClickHouse format_insert_statement no longer
re-implements the rows; its VALUES branch delegates to the core's
format_insert_values_source, which is the one place that decides a cell is
an expression (rendered through its own to_sql) or a bare host value (a
bind parameter). The old loop called to_sql() unconditionally, so a bare
scalar cell crashed before reaching the driver. The ClickHouse-specific
parts of the statement (the into-Table check, the OR REPLACE / OR IGNORE /
ON CONFLICT refusals, the column list, RETURNING) stay.

FunctionCall audit (rg 'FunctionCall\(' src, 14 sites): the four
standard-aggregate construction sites in examples/query (SUM x2, AVG x2)
migrate to the classed factories sum_/avg, mirroring the mysql precedent
(a6a7389/037bc6c); the ten unclassed or window-bearing sites (ROW_NUMBER
and SUM with window_spec -- the aggregate classes carry no window_spec --
JSONExtractString/JSONExtract, DATABASE/VERSION/formatDateTime/
generateUUIDv4) stay on FunctionCall. There is no format_function_call
override in this package and no dead arithmetic-formatter rename to
migrate.

Test evidence (central venv, PYTHONPATH=src;tests):
- new functions/test_string_function_entry_points.py: 5 passed, exit 0
  (lengthUTF8 spelling + alias, live character-count answer, shared
  UPPER/LOWER defaults)
- new dml/test_insert_value_cells.py: 5 passed, exit 0 (expression cells,
  bare host cells, mixed row, multi-row, refusals still precede the source)
- test_clickhouse_wildcards.py updated to the core ruling: 10 passed, exit 0
- functions/test_dialect_function_support.py + dialect/test_dialect_capabilities.py
  (MRO-move regression): 35 passed, exit 0
- py_compile on the two migrated examples: exit 0
- ruff check on all changed files: clean
Core 239678e5 ('give a literal its job and nothing else') removed
TypedValueMixin and the twelve *Value classes and made
Literal(dialect, value, inline=) the one value node; the tier-2
spellings a backend owns moved into an override of
SQLDialectBase.format_literal. This follows:

- mixins/typed_value.py (ClickHouseTypedValueMixin,
  format_binary_value / format_uuid_value) is replaced by
  mixins/literal.py (ClickHouseLiteralValueMixin.format_literal):
  bytes -> unhex('...'), UUID -> bare quoted text, everything else
  super(). timedelta is deliberately left to the base's TypeError:
  the grammar's INTERVAL is date-arithmetic syntax with the unit in
  the spelling, and a host timedelta carries no unit. The live
  measurement records from the typed-value form carry over verbatim
  -- they are verdicts about the engine (26.7.3.19), not the API
  shape.
- The mixin sits first in the dialect's base list, ahead of
  SQLDialectBase: it overrides format_literal, and a mixin placed
  after the class it overrides never wins.
- The inline decision is the caller's, per node. The class-level DDL
  lift is gone; the DEFAULT clause is the one place that normalises,
  and the domain CHECK test states inline=True itself.
- INSERT VALUES cells are bare Literals (or host scalars), and
  ClickHouseJSONObjectExpression takes its members as 'data' only: a
  **kwargs parameter cannot be matched by name in an artifact.

Also follows core 5a613f3c ('name an action by what it is, drop
aliases'): the ALTER action renames land everywhere they surface
here -- AddColumn/ModifyColumn/ChangeColumn/AlterColumn/
AddTableConstraint/AlterConstraint/ValidateConstraint/
DropTableConstraint -> *Action, in expression/alter_column.py, the
dialect, both examples, the TYPE_CHECKING imports in mixins/column.py
and protocols/modify_column.py (aliased, the dialect's own precedent),
and the constraint-capability and DDL-coverage tests.

Migration surface: the draft's 13 modified + 3 new + 2 deleted, plus
five follow-up sites the draft missed (two TYPE_CHECKING import
lists, one prose class name, AlterColumn in ddl/test_ddl_coverage,
DdlLiteral in ddl/test_type_domain_negative).

Behaviour: rendered SQL is byte-identical to the typed-value form;
what changed shape is the binding decision (per node inline=, no DDL
auto-inline) and the JSON object class refusing **kwargs.

Tests: test_clickhouse_literal_values.py 20/20 including both live
round-trips on the 18682 scenario server; -k literal across
feature/backend (matrix file excluded -- its harness is the next
wave) 24/24; ddl/test_ddl_coverage.py + ddl/test_type_domain_negative.py
20/20; ruff clean at 120 on every touched file. Exit code 0
throughout.
…artifact

Reconciled against core 679d9817 ("make a node's kind its identity and its
payload plain JSON"): all markers gone, value kinds flat scalars, kinds
resolved through the framework's default registry.

Harness (vendored, repo-owned): testsuite e05edb0 dropped the shared
round-trip harness; the matrix now imports the repository's own
expression_roundtrip_harness.py (following the mysql/bigquery direction),
aligned to the current core -- register_all registers into default_registry()
via register_node, and the enum/dataclass vocabulary tables are not
referenced anywhere. The stale "graph.EdgeTable" special-constructor key
follows the class to EdgeTableDefinition, which is what core renamed it to.

values.* out of the table, Literal in: the twelve value classes are gone from
core, so the dynamically collected matrix drops their rows by itself; Literal
is the one class that now carries every value, and a new
TestLiteralValueKinds pins all fourteen value kinds as one flat JSON scalar
each (binary_value base64, interval_value ISO 8601, decimal_value exact
text, ...), per channel, with the restored host type asserted, plus a
closed-set pin that forces a fifteenth kind to be added here deliberately.

Fourth verdict, LEGITIMATE_NON_ROUNDTRIPS: the plain-JSON contract (组织规则
§10) puts conversion in the constructor, and every entry below is a class
whose own tree breaks it. Three shapes, each pinned per channel, each
asserted in both directions so a core fix forces its entry out:

- "raises": the datetime family (DateTimeField/IntervalUnit lack
  __str__ = str.__str__, so XML writes "DateTimeField.YEAR" and the
  constructor's own normaliser refuses it); CreateTableCloneStatement (the
  constructor isinstance-checks mode without normalising the serialized
  word); CreateViewStatement (a ViewOptions instance in a parameter slot --
  the artifact cannot carry it at all); GraphTableExpression (GraphColumn),
  ColumnGeneratedConstraint (GeneratedColumnType) and ForeignKeyConstraint
  (ReferentialAction), all plain classes or non-str enums in parameter
  slots. All core-side, read-only from here; reported upstream.
- "word": (str, Enum) members spelled properly but stored raw by their
  constructors -- GraphEdge, ForUpdateClause, AlterDatabaseStatement,
  CreateTriggerStatement, MergeAction, MergeStatement (loss one level down).
  The restore is value-faithful and type-lossy; asserted strictly-different
  and equal under member-=word normalisation.
- "enum-str": XMLParseExpression / XMLSerializeExpression -- their document
  type enums also lack __str__ = str.__str__, so the XML channel writes
  "EnumClass.MEMBER"; the pin names that corrupted spelling itself.

Backend nodes adapted to the same contract (the reconciliation forced them):

- admin/load_xml/maintenance enums (FlushOption, ResetOption, KillTarget,
  HandlerReadMode, LoadXMLPriority, LoadXMLConflictMode, NoWriteToBinlogOption,
  CheckTableOption, ChecksumTableOption, RepairTableOption) become
  (str, Enum) with __str__ = str.__str__, and their constructors normalise
  the serialized word back to the member.
- ClickHouseRefreshSchedule: dataclass -> expression node (a clause is an
  expression). A dataclass parameter has no artifact form, so a schedule
  buried in CREATE/ALTER MV parameters made those statements
  unserializable. It now renders through its own
  format_click_house_refresh_schedule; the create and modify formatters
  reach it via the node's to_sql(). depends_on is normalised to a tuple.
- ClickHouseLoadDataExpression: the LoadDataOptions dataclass is flattened
  into the statement's own parameters (the class was internal and unused
  outside this package, and the statement's formatter refuses LOAD DATA
  regardless).
- ClickHouseIntervalUnit spelled (str, Enum) + __str__ like its siblings
  (currently unread -- flagged for the kind-hygiene audit).

Matrix bookkeeping: graph.EdgeTable pin and format_edge_table entry retired
with the rename (format_edge_table_definition observed in its place); the
rendered bind-parameters comparison is sequence-tolerant (a tuple travels as
an array, §4.8 -- Subquery.query_params is the case in the tree); two admin
special constructors registered for the selectors that now validate their
words at construction.

Coverage after reconciliation (baseline 679d9817): 515 registered, 510
constructible, 5 pinned-unconstructible; rendered=327, unsupported=121,
unmodelled=32, non-render=30.

Evidence: the full matrix green (exit 0, 5 named skips pinned in
UNCONSTRUCTIBLE), test_materialized_view.py and test_unsupported_features.py
green (exit 0), ruff clean on every changed file.
Kind list generated with the core script (a copy with this backend's package
and dialect class filled into BACKENDS -- registering clickhouse there is a
change to the core repository and is reported, not committed here):

  core kinds 357, clickhouse override 0, clickhouse custom 121.

Override zero is the rule that matters most: a backend may replace the
rendering of a kind core ships, never the node class behind it, because two
classes under one kind leave the deserializer no way to choose. This backend
adds nodes and overrides formatters; it replaces nothing.

§11.1 audit, read per class and then mechanically over the 165-entry base
list: no mixin sits behind the base it overrides. Every one of the 178
overriding pairs in ClickHouseDialect's MRO is ordered so the earlier name
wins, and the one that reads as the classic trap -- ClickHouseLiteralValueMixin
ahead of SQLDialectBase, because it overrides format_literal -- is already
placed there with the reason written down. Nothing changes here; the check is
recorded because it is the check that has to keep holding.

The audit's findings, in the order the rules reach them:

- Compound-word kind, 121 kinds. ClickHouse is one word, and the general
  derivation -- a run of capitals counts as one -- splits at the camel-case
  seam instead, so this backend shipped click_house_flush_expression and
  click_house_load_xmle_xpression: two spellings of one identifier, neither of
  them the word the class is named after. The package now declares the token
  where it assembles its own expressions, the way the sqlite and mysql
  packages do, and the kind follows the declaration. KIND_RESERVED is data, not
  behaviour: no class overrides kind(), so class name, kind and formatting
  entry keep one source. The loop walks this package's own modules rather than
  its __all__, so the SHOW family and the private
  _ClickHousePartitionIdExpression -- neither re-exported -- stop keeping the
  split spelling too.

- Two class names that could not derive a kind at all. ClickHouseAddColumn
  dispatched through format_add_column_action: the identity suffix the node is
  an action by, missing from the class name (§3). Renamed
  ClickHouseAddColumnAction, and its ClickHouse-only AFTER <column> clause --
  declared since the class was introduced and read by nobody, so the example
  that passes after="name" and claims the new column shifts position never
  shifted it -- now renders through its own
  format_clickhouse_add_column_action. Core's AddColumnAction keeps
  format_add_column_action, which is what it should have been rendering all
  along. ClickHouseLoadXMLEXpression had the same split, "XML" + "Expression"
  reading as "XMLE" + "Xpression" however the capitals are grouped; renamed
  ClickHouseLoadXMLStatement, the statement it renders, giving
  clickhouse_load_xml_statement and format_clickhouse_load_xml_statement.

- Two enums that were (Enum) rather than (str, Enum) with
  __str__ = str.__str__: DualityViewDMLTag and OptimizerHintType. A member now
  spells as the word it carries, so an artifact names it as a plain JSON string
  instead of "DualityViewDMLTag.INSERT" (§11.3, and the same correction
  bebc428 applied to the admin family).

- One dead declaration. ClickHouseIntervalUnit was introduced with
  ClickHouseRefreshSchedule, read by no formatter, and exported from two places;
  removed. The interval a ClickHouse REFRESH clause carries is the literal the
  caller writes, which is what the schedule node now holds.

- Create/DropJsonDualityViewStatement -> ClickHouseCreate/
  DropJsonDualityViewStatement, with their two entries renamed to match. The
  backend name belongs at the head of the name (§3), and without it these were
  the only two nodes in this backend whose kind carried no backend name at all.

Matrix, reconciled with the audit's renames in the same batch (§11.4):

- ComparisonPredicate: core normalised its operator through
  normalize_comparison_operator, and the introspective guess hands Any the
  literal "x". A registered constructor rather than a sixth UNCONSTRUCTIBLE
  entry -- the class renders, and a constructor keeps it in the matrix.
- The DataType pin now names the dispatch's own spelling ("does not support the
  generic type None") instead of a message the class no longer produces.
- ForUpdateClause retires from LEGITIMATE_NON_ROUNDTRIPS: core now normalises
  the stored word back to the member, so the pin's subject is gone and leaving
  it would be a lie in the table.
- The value-kind set is sixteen, not fourteen: debde516 gave NaN and the
  infinities kinds of their own (nan_value, infinity_value) rather than letting
  them ride on float_value, which JSON cannot represent faithfully. Both are
  pinned as flat scalars -- "nan", "+inf"/"-inf" -- with the negative sign
  asserted separately so one wire kind carrying two spellings cannot lose the
  direction, and the NaN comparison is written to not trip over NaN != NaN.
- test_clickhouse_type_protocol.py: CustomType's raw is keyword-only in core
  now (two call sites), and the sweep that loads the matrix by file spec
  followed the matrix to its new home under expression/ -- and needs that
  directory on sys.path, which pytest's own collection was supplying.

Coverage after the audit (core baseline debde516): 515 registered, 510
constructible, 5 pinned-unconstructible, 61 pinned-non-render; rendered=327,
unsupported=121, unmodelled=32, non-render=30.

Evidence (central venv, PYTHONPATH=src;tests, exit 0):
- tests/.../feature/backend/expression/test_expression_roundtrip_all.py, full
  matrix under xdist: green
- tests/.../feature/backend/dialect/test_unsupported_features.py: 34 passed
- tests/.../feature/backend/types/test_clickhouse_type_protocol.py and
  tests/.../feature/backend/protocol: green apart from one case blocked
  outside this repository -- TestParseRoundTripSweep reaches
  testsuite.utils.parse_contract, which imports the .expression module
  e05edb0 deleted. Reported, not worked around.
- ruff on every changed file: the finding set is identical to HEAD (the four
  F841s in types.py and the F401s in the protocol modules are pre-existing).
The org rules (expression-dialect §12) say it in three sentences: expression
modules are independent of I/O, so their tests start no connection, create no
database and touch no file; every case carries its own dialect instance and
shares no mutable state; and therefore tests run in parallel. This repository's
expression area was violating all three at once, in the same file.

- feature/backend/test_clickhouse_literal_values.py grew two live round-trip
  cases in wave ② -- CREATE TABLE with a tier-2 literal as the DEFAULT, INSERT,
  read the stored value back, compare -- sitting beside the render-only cases
  for the same two spellings. They are the same statement twice: one proves the
  dialect writes unhex('...') and a bare quoted UUID, the other proves the
  server accepts them. The first belongs where expressions are tested and the
  second does not, so the two cases move to
  feature/backend/ddl/test_literal_default_readbacks.py, which runs on the
  scenario server (clickhouse_http, 26.7.3.19 on 127.0.0.1:18682) through the
  clickhouse_backend fixture the rest of that directory uses. The measured
  verdicts and the statements behind them are unchanged; only the room changed.
  What stays in the expression file is construction -> render, with the module
  docstring saying where the end-to-end half went and why.

- Parallel is now the default rather than something each run remembers to ask
  for (§12.1: "并行是测试目录的默认配置,不是每次跑测试时手加的参数"). The
  worker count is read by pytest-xdist before any conftest loads, so a conftest
  cannot inject -n; the default is a repository-local plugin,
  tests/plugins/expression_xdist.py, registered from pyproject.toml and
  therefore active for every run here. It is narrow on purpose: it enables
  nothing when pytest-xdist is absent, it acts only when every target names the
  expression area (a whole-repository run, a mixed run, or any other directory
  keeps the serial default -- the live scenario tests have their own
  --scenario-parallel runner and are not dragged into a pool they did not ask
  for), it never overrides an explicit -n, and it refuses --dist=loadscope and
  --dist=loadfile outright because those bind whole modules together and hide
  the case-to-case dependencies parallelism exists to expose. -n without
  --dist gets --dist=loadgroup, the one scheduling the rules allow.

- feature/backend/expression/conftest.py records the three properties and how
  the parallel default is wired one level out, so the next person to add a case
  there knows which of the three it has to keep. No case carries xdist_group:
  there is no I/O in the area to serialise around, and the mark is reserved for
  a case that genuinely cannot share a worker, with the reason beside it.

Evidence (central venv, PYTHONPATH=src;tests):
- tests/.../feature/backend/expression (the whole area, default scheduling):
  exit 0. The run reports "bringing up nodes", which is the plugin's own
  default firing -- no -n on the command line.
- tests/.../feature/backend/protocol --co: no worker pool, confirming the
  scope rule keeps every other directory serial.
- ruff on tests/plugins, the expression area and the ddl area: clean.

Not run here: the two migrated cases and the rest of the ddl directory need
the scenario server, which is not listening on 127.0.0.1:18682 in this
environment (connection refused, [WinError 10061]). They are reported as
unverified rather than as passing; CI runs the scenario matrix.
…unction waves

The closeout run against core ``debde516``, and the record of what the tables
now say. The reconciling edits themselves are small and went in with the kind
audit they belong to (``b9742c4``); what is missing here is the statement a
reader needs to trust the numbers -- which tree every table in the file is a
statement about, and which way each of core's four moves between the two
baselines pushed a row. That is the module docstring's new closing section,
because a matrix without a baseline is a matrix whose numbers quietly go stale.

What core moved between ``679d9817`` (the baseline the previous reconciliation
was written against) and ``debde516``:

- Value kinds went from fourteen to sixteen: NaN and the infinities are kinds
  of their own rather than travelling as ``float_value``, which JSON cannot
  represent faithfully. ``nan_value`` carries the text ``"nan"`` and
  ``infinity_value`` carries ``"+inf"``/``"-inf"``; the negative direction is
  asserted on its own, because one wire kind carrying two spellings is exactly
  where a sign can go missing, and the NaN comparison is written so that
  ``nan != nan`` does not read as a failed round-trip.
- ``ComparisonPredicate`` normalises its operator now, so the introspective
  constructor's placeholder stopped satisfying it. Registered constructor,
  not an UNCONSTRUCTIBLE entry: the class renders, and a constructor keeps it
  in the matrix rather than parking it.
- ``CustomType.raw`` is keyword-only, and the type root's refusal now speaks
  the generic dispatch's own spelling.
- ``ForUpdateClause`` normalises the word it stores, so its LEGITIMATE_NON_
  ROUNDTRIPS entry describes a loss that no longer happens and was retired.

Coverage at ``debde516`` (test_coverage_report prints this on every run): 515
registered, 510 constructible, 5 pinned-unconstructible, 61 pinned-non-render;
rendered 327, unsupported 121, unmodelled 32, non-render 30.

Evidence (central venv, PYTHONPATH=src;tests):
- tests/.../feature/backend/expression/test_expression_roundtrip_all.py, full
  run with no options beyond the repository defaults: exit 0. The run reports
  "bringing up nodes" -- the parallel default from the previous commit, on the
  one file whose runtime it was introduced to bound.
- the same file under ``-n 0 -s -k coverage_report``: exit 0, printing the
  coverage line above, so the figures in this message are the run's, not a
  transcription.
- ruff on the file: clean.
`ClickHouseAddColumnAction.after` has been declared, stored, serialised and
read by nobody since the class was introduced. The kind audit found it: the
parameter's only purpose is to change the rendered SQL (§4.4), and the SQL did
not change -- `ALTER TABLE t ADD COLUMN c UInt32` came out the same whether or
not the caller named a position. The example that ships with this backend
(`examples/schema_diff/column_order.py`) passes `after="name"` and then reports
the new column shifted into position; it never did.

The clause now renders, on its own entry. Two kinds means two entries (§3):
`format_clickhouse_add_column_action` for the derived node and core's
`format_add_column_action` for `AddColumnAction`, which is what core's entry
should have been rendering all along -- before this, one entry served both.

The name is an identifier, not a value, so it goes through `format_identifier`
and arrives quoted -- including when the name is a reserved word, which is the
case a bare f-string would turn into the server's syntax error rather than our
own. `after=None` renders byte-identically to the core entry: the clause is a
position the caller asked for and nothing else.

Covered by feature/backend/expression/test_add_column_action.py (five cases:
no position, the core node untouched, AFTER, AFTER composed with IF NOT EXISTS,
AFTER on a reserved word), and the matrix's constructor for the class now hands
`after` a value so the declared parameter is exercised on the artifact side
too -- the matrix pins serialisation and classification, the new file pins the
text, and between them the parameter is no longer one nothing reads.

Evidence (central venv, PYTHONPATH=src;tests, exit 0):
- tests/.../feature/backend/expression (whole area, default parallel
  scheduling): 1157 passed, 10 skipped
- the same area under -k add_column: 6 passed
- ruff on the two changed source/test paths and the new file: clean
…sted

The create formatter refused every generic CreateMaterializedViewStatement
wholesale, before it read the statement's clauses at all. That answered the
four-state contract wrongly on the "neither" state: with neither WITH DATA
nor WITH NO DATA requested there is nothing to refuse, yet the statement was
refused as a whole (feature name "CREATE MATERIALIZED VIEW") -- so the
unspecified state could not be told apart from a statement ClickHouse
genuinely cannot write, and core's gate round went red:
test_clause_pair_guard.py::TestFourStatesArePairwiseDistinguishable::
test_neither_set_renders_the_dialect_default[CreateMaterializedViewStatement.with_data]
expected the render 'CREATE MATERIALIZED VIEW `mv` TO `target` AS SELECT `id`
FROM `t`' and got a refusal (CI run 39312577539, every leg).

The duo is now gated the way core gates it and the way this backend's CTAS
formatter already gates it: a *requested* spelling is refused by its own name
(CREATE MATERIALIZED VIEW WITH DATA / ... WITH NO DATA), and with neither
requested the statement renders the dialect's default form, without the
clause. The whole-statement refusal survives for what it was actually for: a
statement carrying neither a TO target nor an ENGINE, which ClickHouse
requires (without TO the engine is mandatory), still refuses as a whole --
test_generic_create_without_engine_or_to_is_rejected pins exactly that.

to_table and engine are not fields of the generic node, so a caller that
carries them attaches them on the instance; the formatter now reads them
through getattr, and the clauses of the ClickHouse spelling likewise, which
keeps one render path for both node kinds. The siblings already worked this
way: a generic DROP MATERIALIZED VIEW renders DROP VIEW and a generic REFRESH
MATERIALIZED VIEW renders SYSTEM REFRESH VIEW, each refusing only the
clauses ClickHouse cannot spell. The create formatter was the outlier.

Evidence (worktree venv, PYTHONPATH=tests, exit 0):
- feature/backend/test_clause_pair_guard.py (whole file, 73 tests): passed
- the same file under -n 4 together with the whole types/protocol file: passed
- feature/backend/dialect/test_materialized_view.py (whole file): passed,
  including the generic-statement refusals and the no-TO/ENGINE refusal
- ruff on the changed path: clean
…ip harness

The testsuite's shared harness is gone: it deleted testsuite/utils/expression.py
(e05edb0, round-trip coverage moves to the owning repository), and its
parse_contract.py imports that deleted module, so the import from it dies too.
CI proved it on every leg of run 39312577539:
TestParseRoundTripSweep::test_every_rendered_type_parses_back_coherently never
ran -- it raised ModuleNotFoundError
("No module named 'rhosocial.activerecord.testsuite.utils.expression'") at
import time, so the sweep's verdict on this dialect was unknown rather than
green. Pointing the import at a fixed parse_contract is not an option: the
module that dies is the one the sweep imports, and the venv cannot see either
one.

Following the mysql precedent (35018b4) and this repository's own
vendored-harness precedent (eb45de1), parse_roundtrip_failures is now vendored
into expression_roundtrip_harness.py. It is the suite's sweep with its
make_instance dependency resolved to the harness's own make_instance (the same
function the matrix's dedicated constructors register through), and the two
scope decisions carried over verbatim: a class the registry cannot build or
cannot render belongs to the matrix's lists, and a CustomType answer is never
blessed for a rendered word. The test's class docstring already describes the
sweep as "the shared sweep across this backend's whole declared surface"; the
import now names the vendored module beside the matrix that registry comes
from.

With the import alive the sweep runs for the first time here, and it passes:
the WIDENING_ANSWERS table already covers every widening this dialect applies
and the backend's own classes parse back to themselves, so no gap the dead
import had been hiding was named (mysql's companion fix found one; this
dialect found none).

The same file also drops one unused function-local import (ClickHouseGeometryType,
ruff F401) so the changed path lints clean.

Evidence (worktree venv, PYTHONPATH=tests, exit 0):
- tests/.../backend/types/test_clickhouse_type_protocol.py (whole file):
  388 passed, including TestParseRoundTripSweep with the vendored import
- the expression area --collect-only: 1116 tests collected, no errors (the
  harness's added symbol does not disturb the matrix that imports it)
- ruff on the two changed test paths: clean
The LIKE family's operator became an enum (core f2bdf386): the parameter is
LikeOperator, the keyword is a spelling the dialect owns, and the constructor
coerces words and keywords alike (core 4774c502) -- but the wire form is the
member. These call sites still passed the SQL keyword as a string, so they
built whichever shape the coercion happened to produce; they now pass
LikeOperator.LIKE, which is what the artifact carries.

Verified: the LIKE predicate renders on this backend's dialect through the new
enum (left LIKE placeholder, the pattern bound); ruff on the touched files
matches the repository baseline.
Every catalog read this backend performs on its own metadata is now an
expression tree: `QueryStatement` + `Column` / `FunctionCall` /
`ComparisonPredicate` / `InPredicate` / `LogicalPredicate` / `WhereClause` /
`OrderByClause`, with `Literal(dialect, v, inline=True)` wherever the old text
wrote a fixed word into the statement. The dialect renders; the introspector and
the backend only construct and execute (expression-dialect §16.2).

Migrated formatters (counts are "string builds -> node builds"):

- `mixins/introspection.py`, all 8 `format_*_query`: the six that render plus the
  two that refuse (foreign key, trigger — unchanged refusals).
- `mixins/namespace.py`: `engine_kind_condition(offset=)` -> `engine_kind_predicate()`.
  The old form counted its own placeholders from a caller-supplied `offset`, so
  the correct index depended on how many conditions happened to precede it.
  `InPredicate` / its negation carry their own parameters, in order.
- `introspection/status_introspector.py`, 16 reads (8 sync + 8 async) plus the
  replica summary: 18 hand-built `SELECT`s -> 18 nodes in the new
  `expression/status.py`, rendered by the new `mixins/status.py`.
- `backend/backend.py`: the mutation pre-count was `f"SELECT count() FROM ...
  WHERE ..."`, built by rendering the WHERE clause and then stripping the word
  `WHERE` back off the front of the text; it is now `ClickHouseRowCountQuery`
  with the predicate the mutation already carries. The version read and the two
  liveness probes became `ClickHouseVersionQuery` / `ClickHouseConnectionProbeQuery`.
- `mixins/concurrency.py`: the `max_concurrent_queries` read became
  `ClickHouseSettingQuery` (sync + async).

New nodes: `expression/status.py` (20 classes, each with its own formatter —
no shared intermediate node, because a node-shaped base with no kind and no
formatter can never render); `mixins/status.py` (`ClickHouseStatusQueryMixin`).

Baseline diff (16 introspection shapes + 10 status steps, machine-compared):
**0 failures**. Projection order, every alias, and every parameter tuple are
identical. The differences that exist are the three the wave guide allows plus
two stated ones:

| difference | why |
|---|---|
| `system.tables` -> `` `system`.`tables` `` | the allowed `Table` reference form: the database is the object's `catalog_name`, not part of its name |
| `!= 'system'` -> `<> 'system'` | the comparison node normalises to `ne`, whose standard spelling is `<>` (both engines accept it) |
| `engine NOT IN (...)` -> `NOT (engine IN (...))` | the negation node; same parameter list, same order |
| `0 AS AUTO_INCREMENT` -> `CAST(0 AS Int64) AS AUTO_INCREMENT` | a literal cannot carry an alias (core: a bind parameter has no name to give), so constant cells are cast to the type the position declares and the cast carries the alias |
| `LIKE 'Nullable(%%'` -> `LIKE 'Nullable(%'` | the `%%` was escaping for clickhouse-connect's `%` interpolation, which the backend already applies to every statement it executes; writing it twice escaped it twice |

Evidence (central venv, no live server — B-class repo):

- baseline/after diff, introspection: 16 cases, 0 failures
- baseline/after diff, status introspector: 10 steps, 0 failures
  (baseline captured before any edit into
  `clickhouse-intro-baseline.json`, after into the same shape)
- `test_introspection_query_nodes.py` (new, offline): 13 passed
- `test_show_output_shape.py -k "not AgainstTheServer"`: 37 passed
- roundtrip matrix `test_expression_roundtrip_all.py`: exit 0
- ruff on the touched set: clean
- live-DB legs (`TestShowOutputAgainstTheServer`, `test_introspection.py`,
  `test_status_introspector.py`) are not run locally: no ClickHouse server on
  this machine. They go to CI.
The SHOW family already had nodes and dialect formatters, but a node was only
sometimes finished: `schema()`, `full()`, `like()`, `for_user()`, `for_table()`,
`limit()`, `global_vars()` and `global_status()` let a caller build an
incomplete statement and fill it in afterwards. `get_params()` could not say
which state an artifact had captured, and the three call sites that drove SHOW
(the functionality facade, the sync introspector, the async introspector) each
configured-then-rendered in their own order (expression-dialect §16.1).

- `expression/show.py`: every parameter is a constructor parameter, stored under
  its own name, and every fluent setter is gone. `schema` is declared on each
  subclass that can carry one, not only on the base, because `get_params()`
  reads the class's own signature and a base-only qualifier is invisible to the
  artifact. `ShowGrantsExpression` gains `user` / `host` as constructor
  parameters (it had only `for_user`).
- `show/dialect.py`: the formatters read the declared fields instead of
  `expr.get_params()` — one spelling, no dict round-trip. The legal formatters
  are kept as they are; nothing here changed what SQL is emitted.
- `show/functionality.py` (9 sites) and `introspection/show_introspector.py`
  (38 sites: 19 sync + 19 async): each builds the node complete and renders it.

Rendered SQL is unchanged for every shape. Verified by constructing and
rendering all 19 classes through the facade against a recording backend:

    SHOW CREATE TABLE `db`.`t`                     ()
    SHOW CREATE VIEW `db`.`v`                      ()
    SHOW FULL COLUMNS FROM `db`.`t` LIKE %s        ('i%',)
    SHOW INDEX FROM `db`.`t`                       ()
    SHOW FULL TABLES FROM `db` LIKE %s             ('t%',)
    SHOW DATABASES LIKE %s                         ('d%',)
    SHOW PROCESSLIST                               ()
    SHOW ENGINES                                   ()
    SHOW GRANTS FOR `root`                         ()

and by `get_params()` round-tripping every class (rebuild from its own
parameters, compare): 19/19 equal. The nine MySQL-only commands still refuse
with `UnsupportedFeatureError` naming the ClickHouse replacement.

Evidence (central venv, no live server — B-class repo):

- render/no-op smoke above: 9 emitted statements + 19 parameter round-trips, exit 0
- `test_show_output_shape.py -k "not AgainstTheServer"`: 37 passed
  (four assertions moved from the setters to the constructors; two new cases pin
  that `full=True` still never reaches `SHOW PROCESSLIST`)
- `test_introspection_query_nodes.py`: 13 passed
- roundtrip matrix: exit 0
- `rg '\.with_\w+\(' src`: no matches; `rg '\.(schema|like|full|for_user|for_table|limit)\('` in the SHOW modules: no matches
- ruff on the touched set: clean
- live-DB legs stay for CI (no ClickHouse server here).
…them, and double the LIKE percent

Two defects the first CI run found (`39486311884`, 3.10 / ClickHouse 25.8:
4 failed, 3725 passed). Both are mine, and neither could show up locally.

1. `mixins/concurrency.py` imported `ClickHouseSettingQuery` from
   `...expression.status` -- three dots, from `backend.impl.clickhouse.mixins`,
   resolves to `backend.impl`. The module is `backend.impl.clickhouse.expression`,
   so the import is `..expression.status`. CI reported it the way a swallowed
   exception does: `Failed to fetch concurrency hint: No module named
   'rhosocial.activerecord.backend.impl.expression'` on every connection, with
   the hint quietly falling back to `None`. The four sync/async probe sites are
   fixed; the hint now resolves.

2. `format_column_info_query` wrote `LIKE 'Nullable(%'` with a single percent,
   on the reasoning that the backend escapes `%` for the driver. It does -- in
   `Backend.execute`. The introspector executor does not go through it: it calls
   `cursor.execute(sql, params)` directly. So this statement alone reached
   clickhouse-connect's `%` interpolation with a bare `%`, which was read as the
   start of a format specifier:

       ValueError: unsupported format character ''' (0x27) at index 134

   That is exactly the failure the previous concatenated form avoided by writing
   `%%`, and the behaviour in `tests/.../test_show_output_shape.py` documents.
   The doubling is restored and the docstring now says why the doubling is
   load-bearing rather than cosmetic -- the executor asymmetry is the reason.

   The offline test pinned the repaired spelling, so the next edit that
   "simplifies" it back to a single `%` fails on a machine with no server:

       IF(`type` LIKE 'Nullable(%%', 'YES', 'NO') AS `IS_NULLABLE`

Evidence (central venv, no live server — B-class repo):

- `test_introspection_query_nodes.py`: 13 passed
- introspection baseline diff: 16 cases, 0 failures (`%%` normalizes to `%`)
- import smoke: `ClickHouseSettingQuery` resolves from both concurrency mixins
- ruff on the two touched source files and the test: clean
- the live legs these four failures belonged to
  (`test_introspection.py::test_get_table_info_returns_table`,
  `test_table_info_has_clickhouse_columns`, `test_column_data_types`,
  `test_list_columns`) go back to CI, which is where they failed.
…um fix

Two things changed under this table, both from the same core commit.

The nine datetime/interval classes were pinned on the XML channel because the
XML encoder spelled ``DateTimeField.YEAR`` through ``Enum.__str__`` and the
normaliser refused it. Core gave those families ``__str__ = str.__str__``, so
the channel writes the word and all three channels round-trip exactly. The rows
are gone; the comment block stays and says what the family used to be.

``ColumnGeneratedConstraint`` and ``ForeignKeyConstraint`` were pinned on all
three channels because ``GeneratedColumnType`` and ``ReferentialAction`` were
plain enums the artifact refused to name. They are str enums now: the writer
carries them, the dict channel hands the member through untouched, and JSON and
XML return the word -- which the constructors store raw. So the pins narrow to
``("word",)`` on those two channels, the same shape the other word-form rows
already had.

``XMLParseExpression`` and ``XMLSerializeExpression`` move from ``enum-str`` to
``word`` on the XML channel for the same spelling fix: the channel no longer
writes ``"XMLParseDocumentType.DOCUMENT"``, so the pin records a type loss
rather than a corrupted value. ``enum-str`` stays in the vocabulary because the
mode documents a real spelling, not because a row uses it today.

The ``LEGITIMATE_NON_RENDERS`` table is untouched: it records classes with no
formatter, which this fix does not affect.
…as no form for

ClickHouse's ALTER DATABASE takes a list of ALTER commands, and MODIFY
SETTING / MODIFY COMMENT are the two this branch spells. It has no
RENAME TO: the server's own answer to that spelling is a syntax error,
"Expected one of: ON, a list of ALTER commands, ALTER command, MODIFY
SETTING, MODIFY COMMENT". A database is renamed with RENAME DATABASE
<old> TO <new>, which is a different statement.

The mixin rendered the portable spelling anyway, on the belief -- stated
in its own comment -- that "RENAME TO is the one action core's enum
names and ClickHouse spells the same way". The same file's
supports_alter_database docstring said the opposite: "MODIFY COMMENT
only". The server settled it.

Measured on ClickHouse 26.7.3 (HTTP, port 18682):

  ALTER DATABASE `db` RENAME TO `db2`   syntax error, at "RENAME"
  ALTER DATABASE `db` MODIFY SETTING x = 1   accepted
  ALTER DATABASE `db` MODIFY COMMENT 'x'     accepted
  RENAME DATABASE `a` TO `b`                accepted

So the action is refused by name now, and the refusal names the
spelling that does work. Every other action was already refused; the
suggestion text said "ClickHouse supports ALTER DATABASE ... RENAME
TO", which was the same wrong belief, so it names MODIFY SETTING /
MODIFY COMMENT instead.

The round-trip case that pinned the rejected text now pins the
refusal. It asserted the statement's spelling and never asked the
server, which is how a form ClickHouse rejects could sit in the suite
for this long.

MODIFY SETTING and MODIFY COMMENT are what ClickHouse does accept, and
core's AlterDatabaseAction has no member for either, so they are still
not reachable from here. That is a missing capability rather than a
wrong rendering, so it is left for a decision rather than added here.
The dialect's surface is two kinds of member: capability switches
(``supports_*()``) and formatters (``format_*(self, expr)``).  This change
makes ClickHouse's code match that, and settles what a type position holds
while it is there.

**A formatter owns its production.**  Core's dialect mixins held six private
renderers serving families of thin ``format_*(self, expr)`` wrappers.  The
wrappers were formatters; the bodies were not, and being private they could
not be overridden.  ClickHouse's ``lengthUTF8`` -- which core spelled through
one of them -- now owns its production.

**A type-name position holds a type.**  The positions that carried a raw
string guarded it with a character class in the base class, which neither
matched what the engines accept nor kept the value inside its syntactic
position.  A position now holds a type node; a name the framework does not
model becomes the framework's name node, whose name the grammar validates.

**Binding is the caller's call.**  A DDL position that cannot bind says so
through ClickHouse's own capability switch, and a value the caller chose to
inline is inlined.  The row-source probe answers for ClickHouse too.

**The backend's own answers are the effective ones.**  ClickHouse's mixin
precedes the core mixin it overrides, and a test holds that in place.

**The mixin list says what it is for.**  The base-list convention is
written down where the list is, and the function examples reach their
constructors through ``functions as F``.
Core renamed the four statement-shaped nodes it still called *Expression:

  TransactionExpression     -> TransactionStatement
  SetTransactionExpression  -> SetTransactionStatement
  SQLiteAttachExpression    -> SQLiteAttachStatement
  SQLiteDetachExpression    -> SQLiteDetachStatement

This repository refers to them in its dialect signatures and its tests, so
the same names follow here.  Nothing changes but the names.
``ShowCreateTableExpression`` renders a whole ``SHOW CREATE TABLE`` statement,
so it is a ``*Statement`` now.  The dialect entry point keeps its name: the
SHOW family's twenty formatters all end in ``_expression``, and renaming one
of them alone would make it the odd one out.

Nothing changes but the class name: no behaviour, no rendering, no signature.
… nodes

A dict cannot carry the clauses' types, their canonical order, or their own
spellings, and every value in it was pasted into the statement verbatim. One
clause is now one `ClickHouseTableStorageOption`: an `option` member plus a
`value` whose shape that member decides, validated at construction and
rendered by `format_clickhouse_table_storage_option`.

- `ClickHouseEngineSpec` carries the engine and its parameter nodes, so
  `ReplacingMergeTree(version)` is expressible without raw text.
- `ClickHouseSetting` carries one `SETTINGS` pair; a scalar goes through
  `format_literal`, not through the caller's string.
- `ClickHouseCreateTableOptions` extends the core node with `on_cluster` and
  `table_storage_options`, and has its own entry -- class name, kind and
  entry stay three-way consistent instead of inheriting core's entry under a
  ClickHouse kind.
- Clauses are emitted in ClickHouse's grammar order whatever order they were
  declared in; a repeated option fails at construction; a single expression is
  not parenthesised.
- `ON CLUSTER` sits between the table name and the column list, where the
  grammar puts it.
- `ClickHouseStorageOptionKind` spells itself as its keyword, so the artifact
  carries `"ENGINE"` and not `"ClickHouseStorageOptionKind.ENGINE"`.

Removed without aliases: `format_table_engine_clauses`, `format_clause_list`,
`_format_table_storage_options`, and both `format_storage_options`
declarations. The core `CreateTableStatement(storage_options=...)` slot is
refused by name, with the replacement spelled out in the message.
The provider fixtures still handed a dict to ``CreateTableStatement``'s
generic ``storage_options`` slot, which ClickHouse refuses by name, so every
DB-backed feature fixture failed at table creation (566 errors): the statement
renderer was right and the fixtures were a release behind the contract they
exist to exercise.

``tests/providers/fixtures/_common.py`` gains ``default_table_options``, the
one place the shared clause run (``ENGINE = MergeTree``, the sort key, the two
lightweight-update settings) is built; ``order_by`` is the only part that
varies. The post-processing that existed only because the generic renderer
quoted option values -- ``_strip_storage_option_quotes`` and the
``ENGINE = InnoDB`` rewrite -- is gone with the dict it repaired.

Two structural consequences:

- ``ClickHouseTableMixin`` inherits ``ClickHouseTableEngineMixin``: the
  engine, sort-key and settings renderers are the tail of ``CREATE TABLE``,
  and the conformance suite pairs one table protocol with one mixin.
- the adapters and dialect-security tests exercise the nodes instead of the
  removed dict renderer, and the literal-readback probes declare their clause
  run the way the fixtures do.
Statement nodes that rendered a whole statement behind the name
``*Expression`` are now ``*Statement``, and every formatter gained,
dropped or respelled no word compared with the kind of the node it
renders.

The SHOW family forms the bulk of it, and the class name was the only
part disagreeing: these render statements. Their formatters are
``format_show_*_statement``, with the capability's own spelling and no
backend prefix, since MySQL, MariaDB and ClickHouse all spell SHOW the
same way.

CREATE SPATIAL INDEX is a statement and is named one. The JSON family,
MATCH AGAINST, the optimizer hint, the spatial literal and the eight
ST_* functions stay fragments and now spell their formatters with the
``_expression`` suffix their class names carry -- including
``format_st_as_geo_json_expression``, because AS_GEO_JSON is three
words and the class has said so all along.
Core moved ten cross-package helpers out from behind a leading
underscore: they are called from this backend, so the underscore was
claiming a scope the names never had. Each old name had exactly one new
name, checked against every public definition and call site in the ten
repositories first, so nothing is shadowed by the rename.
…ge definition

ClickHouse answers two questions the core introspection API has no statement shape for, and no other backend can answer at all: how a table's parts divide into partitions (system.parts, grouped by partition_id), and the storage definition a table was created with (system.tables).

Both are read as expression statements and rendered by the dialect, like every other query in this backend: two nodes in ..expression.introspection carry the database and table, and ClickHouseIntrospectionMixin writes each statement in full out of column, predicate, aggregate and clause nodes. No part of either statement is assembled from text, so the bind parameters stay in the tree and are emitted in projection order.

The reads are cached under IntrospectionScope.TABLE with extra='partition' / extra='engine'. The extra namespace is what keeps them apart: the same table also answers column, index and engine questions, and must not answer a partition question with a column answer. A missing table is not cached, because None is the cache's no-entry sentinel and caching it would make the next call answer from a miss anyway.

Two naming decisions worth stating rather than implying: the methods and nodes say database, not schema -- ClickHouse has no schema layer, its top-level namespace is a database, and the value is the same string system.parts.database holds; the core introspection API calls that slot schema because most backends have both, so this backend is the place a reader would otherwise guess wrong. And only *active* parts are aggregated: ClickHouse keeps a replaced part until its replacement is written, so summing every row would count the same rows twice -- active = 1 is what makes the row count mean what it says.

The results are frozen dataclasses, one row per answer, so a cached answer cannot be mutated by one caller and handed to the next. total_rows / total_bytes stay None rather than 0 for engines that do not track them: an absent estimate is not a zero, and reporting one would be a claim the server did not make.

The CLI reaches the same methods through introspect partitions and introspect table-engine, and an example exercises both plus the cache.
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.
The introspection CLI took a --schema flag on backends that have no schema.
MySQL, MariaDB and ClickHouse have exactly one namespace level and call it a*database; Firebird has a single unnamed namespace and qualifies nothing above the*table. Asking those engines for a --schema named a level they do not have, and*the flag's help text then had to explain that the value meant something else.

The flag is gone from those four. What is left is the flag the connection*already takes, which for MySQL, MariaDB and ClickHouse is --database -- so the*run connects to a database and asks about that database, which is the whole*answer when there is only one level. list_datasets() is the way to enumerate*databases; the run does not need a flag to ask about a different one.

BigQuery had both --schema and --dataset naming the same level, with a*fallback order between them. One flag now, --dataset, which the connection*takes.

The flags are removed rather than aliased: an alias lets a caller keep naming a*level that does not exist, which is the confusion this removes. The tests that*pinned the old flags now pin that they are gone.
…swer

`introspect` accepted `foreign-keys` and `triggers`, while the dialect
declares `supports_foreign_key_introspection() = False` and
`supports_trigger_introspection() = False` -- ClickHouse has neither. A CLI
that advertises a question the engine refuses is worse than one that does not
advertise it: the user learns from an error instead of from `--help`.

The two types are gone from `INTROSPECT_TYPES`, and the unreachable branches
that dispatched them went with them. One of those branches was itself broken:
it called `list_triggers(table=...)` where the parameter is `table_name`, so
`introspect triggers` failed with a TypeError rather than the capability
refusal it was written to produce.

Removing the `elif` headers left their bodies orphaned inside the
`table-engine` branch, where they would have run as part of it; both blocks
are deleted outright rather than left as dead code that looks reachable.

`get_table_info` still carries an `if info.foreign_keys:` section, because
that is core's shape for a table's own answer and the guard makes it inert on
this engine -- recorded rather than changed here, since touching it means
special-casing core's result in one backend.

Tests: the cli and introspection suites pass.
The same false negative as SQL Server's: the switches were left to core's
default of False while the backend reads the whole RDB$ catalogue and its
live tests cover generators and domains against a real server.

Declared on the existing dialect mixin rather than a new file, since it
already owns this backend's introspection concerns and a second home for them
would split the answer across two modules. Each answer names its relation.
Core no longer arbitrates a per-backend family vocabulary:
`backend/cli/introspect_capabilities.py` and its
`available_introspect_types()` are gone, so importing them here broke the
CLI outright. The parser is back to `choices=INTROSPECT_TYPES`, the list this
backend writes for itself, and the standing `ClickHouseDialect()` built only
to feed that helper is gone with it.

What the helper was asked to decide -- whether `foreign-keys` and `triggers`
belong on this engine -- stays answered, just in the place that owns the
fact: the two types remain absent from `INTROSPECT_TYPES` because ClickHouse
structurally has neither. The dispatch branches for them were already removed,
so nothing advertises a question the engine cannot answer.

The namespace rule is unchanged and re-checked: ClickHouse has one level, the
database, so there is no `--schema` flag and every call site passes
`schema=None` (or `database=None` for the ClickHouse-only reads). The README
said `triggers`/`foreign-keys` were "refused"; they are not offered at all
now, and it says so.
…ntract

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.

ClickHouse side: thirty-one parsers across the main, SHOW and status
introspectors, plus default_schema. The SHOW output-shape test that calls
the parsers directly follows.
"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.

The tests that asserted the three-section rendering (and the not-found path
that only the aggregate had) go with it; the types that remain each answer one
question.
Table introspection compared system.tables.engine against the words BASE
TABLE, VIEW and SYSTEM VIEW, none of which a ClickHouse server reports. Every
relation therefore came back as a base table, and the engine name stood in for
the kind in output. The kind is now derived from the engine through the
dialect's declared view-engine set (View/LiveView/WindowView -> VIEW,
MaterializedView -> MATERIALIZED_VIEW, system database -> SYSTEM_TABLE,
anything else -> BASE_TABLE), the projection names engine as ENGINE, and the
engine string travels in extra.

The table_type filter read the same value as an engine equality, so VIEW
selected nothing. It now reads the TableType vocabulary: VIEW and
MATERIALIZED_VIEW select their engines, BASE_TABLE is the complement of the
view set, SYSTEM_TABLE selects the system database, and a value that is not a
kind keeps working as an engine name.

parse_database_info reported 0.0.0 as the version and the database engine as
encoding. It now reads server_version (never raising, no I/O), uses the
unknown version convention when nothing knows it, and stops claiming a charset
and collation ClickHouse does not have per database; the engine is kept in
extra.

The introspect CLI calls introspect_and_adapt() after connecting, and the
supports_* version predicates use version_at_least() so an unadapted dialect
answers False instead of raising.
…aceholder

AsyncClickHouseBackend raises in its constructor -- clickhouse-connect has no
async mode -- and handle() called it unguarded, so introspect ... --async
ended in a traceback from a constructor rather than in a sentence. query
already refuses the same request with the same words; introspect now does too.

The async handler stays, and stays corrected: it is unreachable through the CLI
while the backend is a placeholder, but its five call sites were naming methods
no introspector class has, which is a trap for whoever connects a real async
backend. The note beside it says so.
`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.
@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 83.90129% with 274 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.81%. Comparing base (39df945) to head (ae42de3).

Files with missing lines Patch % Lines
...impl/clickhouse/introspection/show_introspector.py 38.29% 58 Missing ⚠️
...pl/clickhouse/introspection/status_introspector.py 53.75% 37 Missing ⚠️
...kend/impl/clickhouse/introspection/introspector.py 72.91% 26 Missing ⚠️
...ckend/impl/clickhouse/expression/storage_option.py 77.64% 10 Missing and 9 partials ⚠️
...ctiverecord/backend/impl/clickhouse/mixins/json.py 66.00% 9 Missing and 8 partials ⚠️
...cord/backend/impl/clickhouse/show/functionality.py 0.00% 16 Missing ⚠️
...backend/impl/clickhouse/mixins/ddl_table_engine.py 69.38% 12 Missing and 3 partials ⚠️
...verecord/backend/impl/clickhouse/cli/introspect.py 85.71% 8 Missing and 4 partials ⚠️
...iverecord/backend/impl/clickhouse/mixins/status.py 90.51% 11 Missing ⚠️
...record/backend/impl/clickhouse/mixins/ddl_table.py 81.81% 5 Missing and 5 partials ⚠️
... and 17 more
Additional details and impacted files
@@                   Coverage Diff                   @@
##           release/v1.0.0.dev1       #6      +/-   ##
=======================================================
+ Coverage                70.23%   74.81%   +4.58%     
=======================================================
  Files                      127      136       +9     
  Lines                     7438     7843     +405     
  Branches                   916      887      -29     
=======================================================
+ Hits                      5224     5868     +644     
+ Misses                    1923     1688     -235     
+ Partials                   291      287       -4     
Flag Coverage Δ
clickhouse-26.7 74.81% <83.90%> (+4.58%) ⬆️
python-3.14 74.81% <83.90%> (+4.58%) ⬆️
unittests 74.81% <83.90%> (+4.58%) ⬆️

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 3232e2e into release/v1.0.0.dev1 Oct 31, 2026
8 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