Skip to content

feat(snowflake)!: account objects as statements, and one table's storage facts - #7

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

vistart merged 48 commits into
release/v1.0.0.dev2from
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.dev2.

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

Headline changes:

  1. A table's billed storage is a question of its own. introspect table-storage-metrics TABLE reports active, time-travel, failsafe and retained-for-clone bytes with the row count and clustering key, and says when None means "not visible to this role" rather than "no such table" (feat(cli): one table's storage facts…).
  2. Account objects are expression statements: databases, schemas and warehouses have their own statement classes and dialect formatters (feat(introspection)!: account objects as expression statements).
  3. The table kind is the one the catalogue named, and include_views no longer hides managed tables (fix(snowflake): keep the table kind the catalogue named).
  4. The introspect CLI derives its types from _TYPE_CAPABILITIES, offers views NAME, and records why there is no status command (docs(snowflake): why there is no status command).

Type of change

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

Breaking Change

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

Impact on End-Users:

  • introspect table-storage-metrics TABLE is new; introspect table is gone.
  • There is no status subcommand: this backend has no status introspector, and the guide now says so instead of leaving it to be discovered.

Impact on Backend Developers (Custom Implementations):

  • Account objects are statements; parse_* lost its leading underscores.
  • The catalogue's own spelling of a table kind is preserved.

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-snowflake f5d1ad1 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 (f5d1ad1) as run 41092214031 — 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).

vistart and others added 30 commits October 11, 2026 23:55
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.
…ng with getattr

The materialized-view and time-travel formatters reached for their
expression's fields with `getattr(expr, name, default)`. The expressions
declare those fields in their constructors, so the defaults only turned a
wrong-type argument into a silently wrong statement instead of the
AttributeError the reader can act on.

Four sites dropped:

* `with_data` / `no_data` and `column_aliases` are set by the core
  `CreateMaterializedViewExpression`, so both the Snowflake expression
  and the core one this formatter also accepts carry them;
* `options` is a required argument of `TemporalOptionsExpression`,
  and `format_temporal_options` has exactly one caller, which builds
  that expression itself.

`or_replace`, `if_not_exists`, `cluster_by` and `comment` keep
their probes. They exist only on the Snowflake expression: the core
expression reaching this formatter does not have them, so the probes
implement the documented interop rather than hiding a defect. Rendering
is unchanged either way -- verified against the pre-change dialect over
the materialized-view, core-interop, time-travel and type paths.
Mechanical adaptation to the core renames (W2 + W3a + W3b + W1 + S1-c):
33 files / 420 line touches across src, tests and the README.

- W2 statement classes: the forty the core renamed and this package
  names -- QueryStatement, InsertStatement, MergeStatement,
  CreateTableStatement and its As/Like/Clone/FromTemplate siblings,
  DropTableStatement, the Create/Alter/Drop DDL family (database, schema,
  sequence, type, domain, index, function, trigger, view, materialized
  view), TruncateStatement, CommentOnStatement, BeginTransactionStatement,
  CreateMaterializedViewStatement, RefreshMaterializedViewStatement.
  Backend-owned statement classes (SnowflakeCreateWarehouseExpression and
  the rest of the Snowflake* family, SetTransactionExpression,
  PivotExpression) are outside the core's mapping and keep their names,
  as do the nine core classes the rename left alone (BaseExpression,
  TableInfoExpression, ForeignKeyExpression, the XML* family, ...).
- W2 formatter (functional): format_begin_transaction ->
  format_begin_transaction_statement, override and super() call alike --
  without both the override is dead and Snowflake's BEGIN refusals for
  DEFERRABLE / WAIT disappear.
- W3a: ILIKEExpression -> ILikePredicate, and the functional formatter
  rename format_ilike_expression -> format_ilike_predicate.
- W3b: SQLValueExpression -> SQLValue, TrimExpression -> TrimFunction,
  CaseExpression -> CaseFunction, CollateExpression -> CollateFunction,
  UUIDCastExpression -> UUIDCastFunction, _TemporalValueExpression ->
  _TemporalValue.
- W1: CTEExpression -> CTEClause, and the docstring token
  format_cte_expression -> format_cte_clause in the clause-pair guard.
- Introspection: the six data classes take the Introspection prefix
  (IntrospectionDatabaseInfo, IntrospectionTableInfo,
  IntrospectionColumnInfo, IntrospectionViewInfo, IntrospectionTriggerInfo,
  IntrospectionForeignKeyInfo). The six *InfoExpression query nodes and
  IndexInfo / IndexColumnInfo keep their spellings -- core renamed the
  containers, not those.

Also updates the LIKE/ILIKE and XML formatter tokens in the roundtrip
matrix's unmodelled table, and the README's CreateTable sample (the class
name only; its ColumnConstraintType argument follows with the constraint
surfaces).

Constraint surfaces still reference the removed ColumnConstraintType /
TableConstraintType enums and follow in the next commit, so this commit is
an expected-red window: the package import smoke has exactly the eight
failures that are those surfaces plus the pre-existing `named_query` module
typo in examples/; compileall src+tests is exit 0; ruff on the 32 changed
files is at parity with HEAD (19 findings both sides, same rules and lines).
Core's S3 split the constraint nodes: ``ColumnConstraint`` and
``TableConstraint`` are now productions that carry a **branch**, the branch
names the kind, and the ``ColumnConstraintType`` / ``TableConstraintType``
enums are gone. This commit migrates every construction site in this package
onto that shape, lands the capability mixin the earlier draft left untracked,
and folds the draft's mechanical part in as the wave guide requires.

**The draft, absorbed.** The untracked ``mixins/constraint.py`` declared every
constraint capability this backend answers, read off the two official grammar
pages. It was written against the pre-S3 API, so what is kept is its semantic
content -- the probe answers, the verbatim grammar transcriptions and the
per-kind/per-level split -- and what is rewritten is its old form:

* the two parameterised probes, ``supports_constraint_enforced(constraint_type)``
  and ``supports_alter_constraint_enforced(constraint_type)``, are ``(self)``
  now. A probe answers one question and the kind is not part of it; core's
  ``_call_constraint_capability`` (and its ``inspect.signature`` sniffing) no
  longer exists, and the docstring that named it is rewritten rather than left
  describing a symbol nobody can import;
* ``supports_constraint_naming`` is added as the flat, load-bearing probe core
  actually consults. The contract listed Snowflake's answer as a "待核" item;
  it is answered from the reference -- ``[ CONSTRAINT <constraint_name> ]``
  opens all three column-level and all three table-level productions, and the
  ALTER/RENAME verbs depend on the name being there -- so ``True``, with the
  per-level/per-kind ``supports_{column,table}_naming_*`` probes kept beside it
  as the same fact written kind by kind;
* the ``supports_column_collation`` copy is dropped. It is a column-attribute
  answer whose runtime home is ``SnowflakeCollationMixin``, which precedes this
  class in the dialect's MRO; a second declaration would be dead code, and two
  owners for one switch is what the local-declaration rule exists to prevent;
* the ``_format_constraint_enforcement`` narrative is replaced: that private
  hook no longer raises a kind `ValueError`, and what governs the render now is
  the flat probe and the branch the kind lives on.

**Construction sites** (5 files: src 3, tests 4 -- one test file shares):

    ColumnConstraint(d, ColumnConstraintType.NOT_NULL)
      -> ColumnConstraint(d, branch=ColumnNotNullConstraint(d))
    ColumnConstraint(d, ColumnConstraintType.DEFAULT, default_value=v)
      -> ColumnConstraint(d, branch=ColumnDefaultConstraint(d, v))
    TableConstraint(d, TableConstraintType.PRIMARY_KEY, columns=[...], name=n)
      -> TableConstraint(d, branch=TablePrimaryKeyConstraint(d, [...]), name=n)
    ... UNIQUE / CHECK likewise -> TableUniqueConstraint / TableCheckConstraint
    UseConstraint(ColumnConstraintType.DEFAULT, default_value=v)
      -> UseConstraint(ColumnDefaultConstraint, value=v)

Two call sites read the *kind* rather than construct it, and both read the
branch class now:

* ``SnowflakeAlterColumnModifierMixin.format_add_column_action`` -- the
  ``IF NOT EXISTS`` restriction set becomes a set of branch classes
  (``ColumnDefaultConstraint`` / ``ColumnPrimaryKeyConstraint`` /
  ``ColumnForeignKeyConstraint`` / ``ColumnUniqueConstraint``).
* ``SnowflakeCapabilityMixin.format_modify_column_action`` -- the MODIFY
  re-spelling reads ``isinstance(c.branch, ...)`` instead of comparing an enum,
  and the DEFAULT value off the branch (``branch.value``), not off the
  production. Behaviour is unchanged: NOT NULL / NULL / DEFAULT are re-spelled,
  everything else is skipped as before.

**Wiring.** ``SnowflakeConstraintMixin`` is imported and exported from
``mixins/__init__.py`` and placed in ``SnowflakeDialect``'s base list right
after ``SnowflakeCapabilityMixin``, well ahead of core's ``ConstraintMixin``
(index 25 vs 76) and ``DDLColumnMixin`` (45), so every declaration wins over
the shared default it would otherwise inherit.

**Behaviour changes** (all on the constraint surface, all narrowing):

* only the two probes whose signatures changed, so a parameterised call can no
  longer be made at all;
* the capability mixin's answers now come from this package. Three of them
  differ from what was inherited: ``supports_constraint_enforced`` and
  ``supports_deferrable_constraint`` are explicitly ``False`` (see the wave-3
  commit for the evidence and the policy), and ``supports_exclude_constraint``
  is the local ``False`` rather than a shared default.

**Tests.** ``test_expression_roundtrip_all.py`` registers branch-aware special
constructors for ``ColumnConstraint`` / ``TableConstraint`` /
``AddTableConstraint``; the stale comment naming ``constraint_type`` is
corrected. ``test_ddl_source_mixin.py`` rewrites its kind assertions to
``type(item.branch)`` and its value assertions to ``branch.value`` /
``branch.condition``. ``test_alter_table_if_exists.py`` and
``test_clause_pair_guard.py`` migrate their constructions, the latter to
``AlterConstraint(constraint_kind=TableCheckConstraint)``.

Verification:
- import smoke over every module in the backend package: 121 imported, 0
  failures (the pre-existing ``named_query`` module typo in
  ``examples/`` was fixed in the wave-1 commit).
- tests/.../feature/backend/{ddl,dialect,expression,protocol} plus
  test_clause_pair_guard.py: exit 0.
- testsuite feature/ddl from the core worktree: exit 0.
- ruff on the changed files: clean except one finding that is at HEAD too
  (``test_clause_pair_guard.py:73`` F811 on ``Sequence``).
…e protocol

Wave 3 of the plan.  The code half of this landed in the previous commit
(the probes, the `_require_*` seam and the formatters that call them are one
unit, and splitting them would put a commit on record that contradicts core's
API).  This commit carries the documentation and the guards.

**The probe protocol.**  `supports_constraint_enforced` and
`supports_alter_constraint_enforced` took a `constraint_type` argument in the
draft.  A probe answers one question -- "is this capability family present on
this server" -- and the kind is not part of it; a parameterised probe has two
call shapes depending on its own signature, which is how the draft ended up
with a switch whose answer depended on how it was called.  Both are `(self)`
now, and `test_probe_takes_no_argument` reads the signature off the bound
method so a defaulted parameter cannot slip back in.

**No narrowing helper was needed, and that is the reported decision.**  The
other backends put their per-kind narrowing in `_require_*` helpers called from
their own formatters.  Snowflake has none to put there: the flat probes are
`False`, so core's own render gate refuses every spelling before a per-kind
test could matter.  Adding a helper that nothing can reach would be dead code
with a plausible name -- exactly what the wave guide warns against.  The
per-kind grammar answers still exist, as the `supports_{column,table}_*` probes
below, and the guard test pins both families so neither can absorb the other.

**Docstrings.**  Every probe in `mixins/constraint.py` now ends with the
evidence level (`文档(待云验): grammar-only, not server-verified`), and the
module header states it once for the file: there is no Snowflake instance, so
nothing there is a measurement and a rendering assertion is not one either.
The paragraph that named `_call_constraint_capability` -- deleted from core in
P1 -- is rewritten: the render path reads declared fields and consults the flat
probe, and the private hook no longer raises a kind `ValueError`.  The three
places where the draft's grammar reading and this backend's render decision
differ are argued in the module docstring under "Modifier policy", and the
per-probe docstrings point back at it.

**New: `test_snowflake_constraint_switches.py`.**  Three obligations, each
walked over the full switch table:

- **value** -- every declaration's answer, including the two families that
  disagree on purpose (`supports_constraint_enforced` False for the render,
  `supports_{column,table}_enforced_{primary_key,unique,foreign_key}_constraint`
  True for the grammar, CHECK False at both levels);
- **locality** -- `test_switch_is_declared_by_a_snowflake_mixin` and
  `test_resolved_switch_is_owned_by_this_backend` require the answering class to
  be inside this package, and the MRO guard asserts `SnowflakeConstraintMixin`
  precedes `ConstraintMixin` and `DDLColumnMixin`.  A switch that fell back to a
  core default could have its value moved by another repository with nothing
  failing here;
- **probe shape** -- `(self)` and nothing else, on the bound method.

Plus the render assertions: every column and table kind spells its standard
form, `MATCH` renders (the one inherited answer that was refusing real
grammar), naming is gated by flipping the probe through a subclass rather than
by observing the prefix alone, EXCLUDE is refused by name, and each modifier
spelling -- `ENFORCED`, `NOT ENFORCED`, `DEFERRABLE`, `NOT VALID`,
`ALTER CONSTRAINT ... ENFORCED`, `VALIDATE CONSTRAINT` -- is asserted as a
refusal, so the fail-closed policy is a test and not a comment.

`test_add_column_if_not_exists_restriction_is_kind_based` walks all four branch
classes the `IF NOT EXISTS` guard refuses, so the migration off the deleted
enum cannot have silently emptied that set.

**not server-verified.**  Nothing here is measured.  These are rendering
assertions over grammar-derived declarations; a live Snowflake run would still
have to confirm every value, which is what the `待云验` markers say.

Verification:
- tests/.../feature/backend/test_snowflake_constraint_switches.py: exit 0.
- tests/.../feature/backend: exit 0.
- testsuite feature/ddl from the core worktree: exit 0.
- ruff on the new file: clean.
Core split the one 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.  This backend had no
`format_wildcard` override to rename, so the wave is two Snowflake-only
spellings that core leaves to supporting backends by design.

**`SnowflakeRowCountWildcard` (`COUNT(<alias>.*)`).**  Core's
`RowCountWildcard` accepts no relation by contract, because standard
`COUNT(*)` never qualifies the asterisk.  Snowflake does qualify it, and its
reference defines the result as a different number -- "the number of records
that don't contain any NULL values" against `COUNT(*)`'s "total number of
records" -- so it is a derived node, not a flag.  Nothing in core changes.

**`SnowflakeAllColumnsWildcard` (`* ILIKE` / `* EXCLUDE`).**  Both positions
accept a filter on this server:
`* ILIKE '<pattern>'` and `* EXCLUDE <col> | ( <col>, ... )`.  The node
extends core's, carries one filter or the other, and renders the asterisk half
by handing a plain `AllColumnsWildcard` to core's formatter -- so the
qualifier rule (the alias when the query uses one, the qualified name
otherwise) is core's and cannot drift.  A filter with both keywords is refused
at construction: the SELECT grammar states the two "can't be combined", and
the COUNT page repeats it.

**The mixin.**  `SnowflakeWildcardMixin` overrides both formatters, class-tests
the argument, and delegates everything else -- no `getattr` probe on `ilike`,
because core's node has no such attribute and rule 9.8 forbids asking.  A bare
`*` and a bare `COUNT(*)` therefore render exactly as before; the tests assert
that byte for byte.  No capability probe is added: the filter exists only on
this backend's own nodes, so a probe would answer a question no caller can ask.

**Two limits, recorded rather than worked around.**

- Core's shared `format_function_call` special-cases a `RowCountWildcard`
  argument to the bare asterisk *before* consulting a formatter, so
  `FunctionCall(dialect, "COUNT", SnowflakeRowCountWildcard(...))` still
  renders `COUNT(*)`.  The qualified spelling is reachable through the node's
  own `to_sql()`; closing the factory gap is a core change, out of this
  branch's scope, and PostgreSQL's derived row-count wildcard is in the same
  position.  `test_the_aggregate_factory_masks_the_qualified_form` pins the
  behaviour so a core change moves that test rather than silently changing
  what Snowflake emits.
- `REPLACE` / `RENAME` are the SELECT grammar's other two `*` modifiers and are
  **not** modelled.  They take expressions and aliases rather than a filter, so
  they are a different node's worth of work; recorded here so the omission is
  a decision rather than an oversight.

**not server-verified.**  Transcribed from
`docs.snowflake.com/en/sql-reference/functions/count` (including its worked
`COUNT(n.*)` example) and `.../sql/select` (whose own examples --
`* ILIKE '%id%'`, `* EXCLUDE department_id`, `* EXCLUDE (col1, col2)`,
`table_a.* EXCLUDE column_in_table_a` -- are the test fixtures).  No Snowflake
instance is reachable; a rendering assertion is not a measurement.

Verification:
- tests/.../expression/test_snowflake_wildcards.py: exit 0.
- tests/.../expression/test_expression_roundtrip_all.py: exit 0 (the two new
  classes are collected from this package and render).
- tests/.../feature/backend: exit 0.
- ruff on the changed files: clean.
The testsuite's S3 adaptation is on `fix/column-constraint-gates` (its
`117da8b` and `11b0f6c` close the shared DDL contract), and this repository's
own constraints are migrated in the same run of commits.  All three jobs now
install the testsuite from that branch instead of `release/v1.0.0.dev17`,
matching the core's own `e5e06895`.

The core install line was checked in all three jobs and already points at
`fix/column-constraint-gates`; unchanged.
`SQLValueExpression` became `SQLValue` in the core's W3b rename; the coding
standard's inheritance list and its worked example still named the old class,
which would send a reader to an import that no longer exists.  Backend
adaptation, same wave as the src/tests rename (`e23e55b`).

Only the two name occurrences change; the class name in the example, the
section and the rule it states are untouched.
Core renamed CaseFunction to CaseExpression and moved the PIVOT / UNPIVOT
node formatters from format_pivot_expression / format_unpivot_expression to
format_pivot / format_unpivot, so Snowflake's trim mixin now annotates
TrimExpression and the round-trip matrix pins the new formatter names --
Pivot and Unpivot still have no Snowflake renderer, because the dialect
spells the clause from its own SnowflakePivotExpression fields.

The matrix also registers special constructors for ArithmeticOperation and
SetOperation: both validate op against a closed word set at construction
(add/sub/mul/div/mod and union/union_all/intersect/except), so the
introspective guess of a bare string now raises ValueError before any
formatter is reached. The serialization class index moves from the
ExpressionRegistry class to auto_register_builtins /
registered_expression_classes.
…x functions

serialization's ExpressionRegistry class is gone; the deserialization class
index is now three module-level functions. Snowflake's data types and UDT
expressions register through register_expression_class, and the type-DDL
test resolves a name through registered_expression_classes() rather than
ExpressionRegistry.lookup. This index is not the rendering registry in
expression.registry -- the two are separate mechanisms.

Also drops the supports_set_operation_for_update assertion in the same
commit: that probe gated the for_update_clause trailing parameter the core
SetOperation node used to carry, the parameter is abolished, and core no
longer declares a default for it. Snowflake's mixin never declared its
own, so the test asserted a method the dialect does not have.
…attr

Fifteen probes dropped, in the five mixins the wave inventory lists:
ddl_database (transient, clone), ddl_type (comment, cascade, restrict,
definition_kind, action_kind), dml (overwrite, plus the docstring's
wording), file_format (the enum double-form on type_), materialized_view
(or_replace, if_not_exists, cluster_by, comment, query).

Each site falls into one of three shapes:

* fields declared only by this backend's expression (transient/clone,
  the TYPE comment, cascade/restrict, overwrite, the four materialized
  view options): the formatter now isinstance-dispatches to the
  Snowflake expression and reads the field there, instead of probing
  whatever arrived. The core statements this formatter also accepts
  declare none of those fields, so their rendering is unchanged.
* fields declared abstractly by the core ABC (definition_kind,
  action_kind): read directly; the old probe's class-name fallback only
  turned a contract violation into a mislabelled refusal.
* the enum double-form on type_: the current core pattern
  (expr.type_.value if isinstance(expr.type_, Enum) else expr.type_).

Behaviour tightening, listed per the wave: a bare object() handed to
format_type_alter_action now surfaces AttributeError('action_kind')
instead of an UnsupportedFeatureError built from the probe's fallback;
the test now pins the honest failure and keeps the refusal gate with a
well-formed but unregistered action. A hand-assigned or_replace or
cluster_by on a core CreateMaterializedViewStatement no longer leaks
into that statement's rendering -- those were never its contract, which
is the documented interop the 2026-10-12 probes implemented by
accident.

Also removed a pre-existing unused UnsupportedFeatureError import from
ddl_database.py so the touched files lint clean.

Evidence: rg '(getattr|hasattr)\(\s*(expr|node|clause|item)\b' src now
returns zero matches. Targeted suites, all exit 0:
feature/backend/test_snowflake_database_ddl.py, feature/backend/ddl/
test_type_ddl.py, feature/backend/dml/test_insert_overwrite.py,
feature/backend/extensions/test_file_format.py, feature/backend/
extensions/test_undrop_clone_materialized_view.py (66 passed), plus a
package import smoke; ruff clean on all six changed files.
…s index

Core 8d474dc8 abolished the deserialization class index: an artifact is a
bare {kind, params}, resolution goes through a registry the reader
supplies, and 'writing needs no registry'. The testsuite followed in
e05edb0 by dropping the shared round-trip harness with the direction
'round-trip coverage moves to the owning repository'. This repository
still imported both, so the dialect could not even be imported.

Migration surface (old -> new):
- expression/types.py and expression/ddl/type.py no longer self-register
  their classes at import. register_expression_class is gone; the
  framework's default registry walks only the packages the framework
  itself ships, and a reader that wants a Snowflake kind registers it.
  Both files keep exporting their classes; nothing in src reads
  artifacts, so no runtime behaviour depends on the deleted loops.
- mixins/collation.py: CollateFunction replaced its open
  **collation_options bag with a named schema parameter, so the refusal
  of schema-qualified collations now reads that field directly instead
  of dying on an attribute core removed.
- the round-trip harness is vendored at
  feature/backend/expression_roundtrip_harness.py from
  python-activerecord-testsuite@4ef2551, following the mysql worktree
  precedent: register_all now hands classes to the registry's
  register_node, and register_enums_and_dataclasses covers the backend
  enums that ride inside artifacts under their short class names.
- test_expression_roundtrip_all.py collects and registers through the
  vendored harness; its integrity test now checks kinds against the
  assembled registry instead of FQNs against the dead index. Three
  content repairs that the core waves had already invalidated:
  values.TypedValue is registered as the root-of-the-tree unmodelled
  entry beside TableSource (its format_method is the empty 'format_',
  which no dialect can declare), and the ArrayType special constructor
  spells element_type= keyword-only, as the core class now requires.
- test_type_ddl.py assembles the classes it round-trips into the
  framework's default registry at import and pins kind resolution
  instead of FQN index membership. Its per-FQN assertion is replaced by
  resolve_node(kind()) is cls for the same 24 classes.
- test_collation_expression.py drops the keyword-collation test: core's
  collate() no longer accepts a keyword option at all, so the refusal
  it asserted is now structural (a TypeError at construction) rather
  than a rendering answer.

Deliberately left for the matrix reconciliation wave, which owns the
verdict tables: the UNCONSTRUCTIBLE drift from the value and function
waves, the unmodelled format_edge_table_definition, and core's newest
_Annotation class. The matrix reports these honestly now that it can be
imported again; it could not run at all before this commit.

Tests (targeted, centralized venv, exit 0):
- feature/backend/ddl/test_type_ddl.py: 15 passed
- feature/query/test_collation_expression.py: 4 passed, 2 skipped
  (integration, no server -- this repository reaches no warehouse)
- feature/backend -k 'typed or literal': 33 passed
- TestMatrixIntegrity: pinned-non-render and unmodelled-set checks
  green for everything this commit touches; the three named drift
  failures above are the reconciliation wave's registration work
- ruff: clean on every changed file
…pellings

Wire TypedValueMixin into the dialect ahead of the generic mixins, the
same composed order sqlite uses, with the new SnowflakeTypedValueMixin
in front so the two spellings Snowflake owns win by position.

Tier 2, each spelling transcribed from the official documentation (this
repository reaches no Snowflake instance, so both new files carry the
not server-verified header and every docstring cites the page rather
than a measurement):

- binary: TO_BINARY('0102', 'HEX'). The one-argument form reads the
  BINARY_INPUT_FORMAT session parameter, which a dialect must neither
  read nor assume; naming 'HEX' keeps the literal deterministic across
  sessions. Uppercase hex matches what Snowflake itself prints. The
  digits go through format_literal, so the quoting stays the base's.
- UUID: the bare quoted canonical text -- 'the ANSI literal form of
  UUID is supported as input'; there is no ::uuid suffix to add.
- INTERVAL stays unimplemented on purpose. The interval constant is a
  date-arithmetic operand Snowflake refuses to insert into an interval
  column, the interval data types take a string whose shape depends on
  the column's qualifier, and a timedelta cannot spell year-month
  anyway -- so the base's explicit NotImplementedError is the answer,
  and this mixin defines no format_interval_value, which is what makes
  that refusal structural.

Tier 1 audited against the grammar and left to the base: NULL and
TRUE/FALSE keywords, numeric text verbatim, strings with doubled single
quotes, quoted ISO date/time/timestamp; the base's NaN/Inf refusal is
Snowflake's answer too, its FLOAT special values requiring quotes.

Tests (rendering assertions, not measurements): data positions bind the
host value behind %s; DDL positions assert the exact literal per kind
through the class-level inline_params lift, one Ddl*Value subclass per
kind exactly as core's own DdlLiteral performs; the INTERVAL refusal
asserts the base's NotImplementedError; tier 1 is pinned per spelling
so a base change that breaks Snowflake's grammar fails here rather than
in the matrix; and the MRO tests pin that the local mixin wins the two
overrides while the base answers everything else.

Targeted runs (centralized venv, PYTHONPATH=src;tests, exit 0):
- feature/backend/expression/test_typed_values.py: 20 passed
- feature/backend -k 'typed or literal': 33 passed
- ruff: clean on all four files
… VALUES cells

ARRAY_AGG is the one call point whose function core has classed:
the semi-structured factory now returns the classed
ArrayAggregateFunction node instead of a bare FunctionCall, delegating
to core's own array_agg factory. The node keeps the exact spelling the
call rendered -- ARRAY_AGG(operand) -- and gains what the call could not
carry: DISTINCT, the window clauses an aggregate takes, and an artifact
naming array_aggregate_function rather than a generic call. A bare
string operand still becomes a Column, as before.

Every other call point stays a FunctionCall on purpose, because core has
not classed its function: the ST_* geospatial set, ARRAY_CONSTRUCT /
ARRAY_SIZE, the OBJECT_* set, PARSE_JSON / TRY_PARSE_JSON, TO_VARIANT /
TO_ARRAY / TO_OBJECT, and FLATTEN. ARRAY_SIZE in particular is not
ArrayLengthFunction: Snowflake's spelling is ARRAY_SIZE and its LENGTH is
a different question, so pinning it to the classed node would render SQL
Snowflake rejects. array_construct and parse_json are pinned as the
stay-side witnesses in the new rendering test, so a future classing in
core changes that file rather than drifting silently.

char_length audited, no override: Snowflake's LENGTH counts characters,
which is the unit CharLengthFunction records, and the base formatter
already spells LENGTH; the backends that must override are the ones whose
LENGTH answers a different question (bytes on MySQL / ClickHouse, UTF-16
units on SQL Server).

INSERT audited, delegation confirmed, cells pinned: both paths of
SnowflakeDMLMixin.format_insert_statement hand the statement to the core
renderer -- the plain path outright, the overwrite path by rewriting the
prefix of the core's answer -- so the expression-versus-host-value
judgment lives in the one place core keeps it. New targeted cases pin
that bare scalar VALUES cells bind behind %s on both paths, and that a
mixed row binds in written order.

Tests (targeted, centralized venv, exit 0):
- feature/backend/functions/test_semi_structured_functions.py: 7 passed
  (new file, not server-verified header: rendering assertions only)
- feature/backend/dml/test_insert_overwrite.py: 9 passed
- ruff: clean on all three files
SnowflakeCreateMaterializedViewExpression caught the base statement's
options in a **core_kwargs catch-all and forwarded them blind. The bare-
node artifact reader validates parameters by name (spec 4.11.4) and
refuses a **kwargs-declaring kind outright -- a parameter whose identity
depends on how many extras arrived cannot be matched on read -- so the
kind could not be deserialized at all. The four options the base
declares (tablespace, with_data, no_data, storage_options) are now
forwarded by the names it knows, which is the same forwarding the
catch-all performed and the same refusal Snowflake's formatter gives
them: the engine has none of these clauses.

get_params is untouched -- it writes the core vocabulary only, and the
named options never entered the wire; the docstring's stale reference to
the catch-all is updated to the shape that remains.

The matrix's coverage walk surfaced this: it round-trips every class the
two packages define, and the new reader named the kind and the section it
violates where the old index read it back without asking.

Tests (targeted, centralized venv, exit 0):
- feature/backend full matrix rerun green with the fix in place (see
  the matrix reconciliation commit for the full-file evidence)
- ddl/test_object_kind_refused.py, extensions/
  test_undrop_clone_materialized_view.py, test_clause_pair_guard.py,
  test_snowflake_ddl_improvements.py: 202 passed
- ruff: clean
…nction waves

Reconciled against core e57c6702 (the HEAD at reconciliation time),
which the drift the integrity tests named: the value wave's classes, the
extract split, the scalar string set, the CustomType keyword, the
EdgeTable definition split, and the newest annotation base.

New special constructors -- a real value where the introspective guess
is a lie, so the matrix asks the render question instead of skipping:

- the seven typed values whose host types have no path from the guess's
  1 (Binary, Boolean, String, Date, Time, Timestamp, UUID) get real host
  values and render through the same bind path the five the guess does
  build already take (NULL carries none; Integer and Float are numbers;
  Decimal and Interval normalise it).
- ExtractFieldExpression, the field-named spelling the wave split out of
  ExtractExpression, and _ExtractBase, the shape both spellings share:
  the guess's 'x' is not a datetime field. Both render through the same
  format_extract_expression the classed spellings did.
- the scalar string set (CHR, LEFT, RIGHT, SPACE, SUBSTRING, OVERLAY):
  each normalises its counts through IntegerValue, and the guess's 'x'
  is not an int. All six render Snowflake's documented spellings.
- CustomType's factory spells raw= keyword-only, as the core class now
  requires; the stale comment about the empty-string default it used to
  lean on goes with it.

Retired from UNCONSTRUCTIBLE, both because the constructor now builds
them: ValidateConstraint (core made constraint_name a positional the
guess fills) and EnumType (the guess now supplies a real values list).
EnumType then had a verdict to give and gets one: the dialect's own
refusal, TypeError 'does not support the generic type', naming
VarCharType as the spelling to use -- pinned in LEGITIMATE_NON_RENDERS
beside the other generic types Snowflake spells differently.
_Annotation joins the same table: it names a formatter but carries no
surface, which is where the alias the formatter reads comes from, so
the base constructs and its render dies on the missing attribute while
every concrete annotation renders.

UNMODELLED_FORMATTERS: format_edge_table_definition joins the SQL/PGQ
group -- core split the edge table's declaration into its own
EdgeTableDefinition node and the need moved with it; format_edge_table
is dropped, named by nothing since the split.

The four verdicts, exhausted: renders is the default the green run
asserts (the twelve value kinds, the extract family, the scalar string
set, the aggregates, the math set, the session identity trio and the
wrapped-result classes all render); the unmodelled and non-render tables
above name every refusal by method and by class; the five UNCONSTRUCTIBLE
entries remain, each with its reason, and the integrity tests pin all
three tables in both directions.

Full-file evidence (centralized venv, PYTHONPATH=src;tests):
- test_expression_roundtrip_all.py, entire file: 910 collected,
  900 passed, 10 skipped (the five pinned UNCONSTRUCTIBLE entries,
  twice each), exit 0
- TestMatrixIntegrity: 10/10 green, including both-directions pins on
  all three tables and the coverage report
- ruff: clean
Core replaced the typed-value node family with one `Literal(dialect, value,
inline=)` whose kind is read off the host value, and folded the tier-2
spellings into `dialect.format_literal`. `TypedValueMixin` and the twelve
`*Value` classes are gone from `dialect.mixins`, so this backend's dialect
raised `ImportError` on import.

Migration (mechanical, per guide §10.1):

* `mixins/typed_value.py` -> `mixins/literal.py`, class
  `SnowflakeTypedValueMixin` -> `SnowflakeLiteralValueMixin`, overriding
  `format_literal` instead of `format_binary_value` / `format_uuid_value`.
  Only the two spellings this backend differs on are branches; everything
  else calls `super()`. The binary branch renders `TO_BINARY('<hex>', 'HEX')`
  through `self.format_literal(hex_text)`, so the quoting stays the base's.
* INTERVAL stays structurally unimplemented: no `timedelta` branch, so the
  base's explicit `TypeError` is the answer.
* The mixin moved to the first position of the dialect base list, ahead of
  `SQLDialectBase` -- the one base that defines `format_literal`. A mixin
  placed after the class it overrides never wins (rules §11.1).
* `TypedValueMixin` dropped from the base list and from the import.

Tests: `test_typed_values.py` -> `test_snowflake_literal_values.py`,
rewritten against `Literal` (bind position / inline position per kind). The
tier-1 audit and the INTERVAL refusal are unchanged in substance and keep
their "not server-verified" record -- the measurements do not expire with
the architecture. A new case pins that NaN / +-Inf never reach SQL text.

Evidence: `test_snowflake_literal_values.py` 23 passed; ruff clean on all
five touched files.
Kind list generated with the core script on a temporary copy whose BACKENDS
names this package and SnowflakeDialect (registering a backend there is a
change to the core repository, so it is reported, not committed here):

  core kinds 357, snowflake override 0, snowflake custom 33.

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 -- two classes
under one kind leave the deserializer no way to choose. This backend adds
nodes and overrides formatters; it replaces nothing. Read per class, the
rules section 11.1 audit holds: SnowflakeCreateMaterializedViewExpression
derives from CreateMaterializedViewStatement, SnowflakeCreateDatabaseExpression
from CreateDatabaseStatement, SnowflakeInsertExpression from InsertStatement,
SnowflakeCreateTableOptions from CreateTableOptions, the three type DDL
expressions from Create/Alter/DropTypeStatement, SnowflakeObjectTypeDefinition
and SnowflakeScalarTypeDefinition from TypeDefinition, the two comment actions
from TypeAlterAction, and each Snowflake<Type> from its core type -- every
class that specialises a core node inherits it. The concepts core has no node
for (STAGE, PIPE, TASK, STREAM, WAREHOUSE, FILE FORMAT, SHOW, UNDROP, the
Snowflake TYPE DDL, VARIANT, IDENTIFIER, SAMPLE, PIVOT) stand on the bare
bases, which the clause exempts.

Overrides that are never reached: measured 19, all `_is_runtime_protocol` --
an attribute every runtime_checkable Protocol re-declares for itself, answered
by the first protocol in the base list whoever defines it. The named protocol
classes are re-exported for isinstance, not as bases, so no backend method is
shadowed; every real override (529 backend method definitions checked) is
answered by the backend class that declares it.

Note on the 33: they are the nodes that declare a format_method. The other
backend nodes render through their own to_sql() and so have no kind of their
own yet -- the same shape mariadb's audit recorded for its SHOW family, and
converting them is a rename of their dialect entries rather than an audit
finding. Recorded, not changed.

The audit is now an executable assertion, test_kind_lineage.py: zero kinds
core already ships, every declared formatter present on the dialect, every
custom kind naming its backend first, and the literal mixin ahead of the base
whose format_literal it replaces.

Evidence (central venv, PYTHONPATH=src;tests):
  test_kind_lineage.py  5 passed, exit 0
ruff clean on the new file
…verrides

Kind hygiene per expression-kinds.md section four and the enum clause of
expression-dialect §11.3, audited against this backend's 64 expression
classes and 15 closed-set enums.

* **The enums take the org spelling.** All 15 were bare ``Enum`` over string
  values, so a member was not a string and ``str(member)``/an f-string wrote
  ``ClassName.MEMBER`` -- which is what the SQL formatters read and what the
  artifact writer refused by name ("X is not one of the value kinds this
  package can write"). Each is now ``(str, Enum)`` with
  ``__str__ = str.__str__`` pinned, the spelling mariadb/mysql/postgres use.
  Members are unchanged, so every rendered spelling is unchanged.
* **The constructor puts the member back.** A member now travels as the word
  it carries, and the reader only passes parameters by name (§10), so the
  conversion belongs in the constructor: new ``expression/coercion.py``
  ``as_member`` accepts a member or its word, refuses anything else by
  parameter name, and is applied at the nine nodes that hold one
  (SHOW object_type/in_scope, SAMPLE form/samplingMethod, ALTER PIPE/TASK/
  WAREHOUSE and COPY INTO mode, CREATE STREAM/UNDROP object_type, routine
  language/execute_as/routine_type). The alternative -- an isinstance check
  that refuses the word -- leaves each of these nodes unable to read back the
  artifact it wrote, which is the defect core's own datetime and graph enums
  are pinned for, not something a backend should copy.
* **No purpose-less alias, no missing nature suffix, no unread parameter.**
  Checked rather than assumed: kind, class name and format entry agree for
  every class that declares a formatter (measured: 0 disagreements); the
  parameters that read as unused are base-class forwards or normalised
  spellings, not silent drops.

Enum spellings that were read off ``.value`` keep reading ``.value``; the new
``__str__`` only changes what an f-string writes, and no formatter relied on
the old repr-shaped spelling.

Evidence (central venv, PYTHONPATH=src;tests, exit 0):
  feature/backend/snowflake/test_show.py
  feature/backend/query/test_sample_tablesample.py
  feature/backend/extensions/test_stream_task_pipe.py
  expression/test_kind_lineage.py, expression/test_snowflake_literal_values.py
  110 passed
ruff (line-length 120) on the expression package and collation.py: clean
expression-dialect §12 splits the two kinds of test: an expression case
constructs and renders, a case that needs a real server belongs to the
scenario-driven integration area.

The audit's result is that **nothing had to move** -- measured, not assumed:
no module under `feature/backend/expression` imports `snowflake.connector`, a
backend class or a connection config, and none calls `connect` / `execute` /
`cursor`. The live-leg work this release added (the literal spellings) is
pinned as rendering assertions with a "not server-verified" record, which is
exactly the split §12 asks for; there is no read-back case to relocate. The
check is now executable, in `test_expression_discipline.py`, so a future case
that does reach a warehouse fails there instead of quietly dragging a
connection into the area.

§12.1's parallel default is now configured, per the mariadb/mysql precedent:
the worker count is read before conftest loads, so it cannot come from one.
`tests/plugins/expression_xdist.py` is a repository-local plugin registered
from `pyproject.toml` (`-p plugins.expression_xdist`) that appends
`-n auto --dist=loadgroup` only when every target is inside the expression
area, never overrides an explicit `-n`/`--dist`, refuses
`--dist=loadscope`/`loadfile`, and does nothing when pytest-xdist is absent.
loadgroup is the scheduling the rules name: only a case explicitly marked
`xdist_group` runs serially, and none here is.

Evidence (central venv, PYTHONPATH=src;tests):
  test_expression_discipline.py  4 passed, exit 0
  test_snowflake_literal_values.py under the plugin: "bringing up nodes",
  23 passed, exit 0 -- the default really engages
ruff clean on the plugin and the new test file
…rtifact

Baseline: core `4774c502` (`LiteralKind` + `Literal`, `LikeOperator`, the
`*Action` renames, NaN/Infinity kinds, the `_need_quote` checks).

The matrix is now green: 436 classes collected, 431 constructible, 320
rendered, 27 legitimate non-renders, 50 unmodelled formatters, 34 unsupported,
5 pinned unconstructible.

What changed, and why each is a measurement rather than a concession:

* **The value nodes are gone, so their rows are.** Core folded the twelve
  `*Value` classes into one `Literal` whose kind is read off the host value;
  the seven registered constructors for them had nothing left to build. The one
  remaining value node needs none: the guess's `"x"` *is* a `string_value`.
  Their per-kind rendering stays pinned in
  `expression/test_snowflake_literal_values.py`, which is where the dialect's
  tier-2 spellings live.
* **The `*Action` renames followed through, in the matrix and in three sibling
  test files.** `AddColumn`/`DropIndex`/`AddIndex`/`AddTableConstraint`/
  `ModifyColumn`/`AlterConstraint` and the rest are `*Action` in core now; the
  registration keys had become dead code -- `make_instance` matches by
  `endswith`, so a stale key silently falls through to the introspective guess,
  which is exactly how these classes were being reported unconstructible.
* **`EdgeTable` -> `EdgeTableDefinition`** in the harness factory, following
  core's split of the graph edge definition out of the edge table object.
* **The harness dropped `register_enums_and_dataclasses`.** Core removed the
  enum/dataclass sub-registries along with the artifact markers they existed
  for; the call was raising `AttributeError` at import. It also takes the
  inline literal for `_literal`/`_comparison`, because a DDL condition accepts
  no bind parameters since core stopped lifting binding literals.
* **`SERIALIZATION_GAP` is new, and it is a measurement.** 24 classes cannot
  round-trip through all three channels, and every one of them fails inside
  core's writer or reader -- not in this backend. The table pins, per class,
  which channels fail and with what witness, in four groups: the writer
  refuses a non-scalar parameter (7), the constructor refuses its own wire form
  (1), the constructor does not normalise the word back into the member (7),
  and the XML channel spells a str-enum member as `str(member)` (9). A class
  that starts failing *without* a row fails its own assertion; a row whose
  class starts round-tripping fails
  `test_pinned_serialization_gap_really_fails` and is deleted. No row names a
  `Snowflake*` enum: those inherit `str` and their constructors coerce through
  `expression/coercion.as_member` (K2), which is the backend's own half of
  this problem solved rather than pinned.
* **`DataType`'s pin was stale.** The root of the type tree no longer says
  "does not declare a valid generic type name"; it asks the dispatcher for
  `format_data_type_None`, which refuses as `does not support the generic
  type`. Same type, new message -- the pin now names the message that is
  actually raised.
* **The typed-value root left `UNMODELLED_FORMATTERS`.** Its `format_` entry
  existed for the twelve value classes' shared base; with the family gone no
  class asks for it, and the bidirectional pin said so.
* **`ComparisonPredicate` got a constructor.** `op` normalises through the
  operator enum at construction, so the guess's bare `"x"` is refused there.
  Two columns over a table is the shape every DDL position also wants.

The SQL round-trip assertion now skips the channels a class has a gap row for:
a node whose parameter came back as a `str` cannot be asked to re-render, and
asserting that it fails would be restating the gap rather than testing the
renderer.

Evidence (central venv, PYTHONPATH=src;tests, exit 0):
  expression/ (whole area, 4 files) + test_clause_pair_guard.py +
  test_snowflake_constraint_switches.py + ddl/test_alter_table_if_exists.py
  0 failed
  the matrix alone: 436 collected / 431 constructible / 320 rendered
ruff (line-length 120) on every touched file: clean

Pre-existing failures left alone (measured on the untouched tree, not caused by
this branch): 9 in `introspection/test_introspection.py` and 2 in
`types/` -- they fail identically with this work stashed.
…ions

The two user-mandated rules, checked across this repository:

* **An expression collects every parameter at construction; no fluent
  builders.** `rg '\.with_[a-z_]+\(' src tests` finds nothing. No node here
  grows a parameter after the fact.
* **SQL text belongs to a dialect formatter.** Two sites were spelling it
  outside one, and each is a real violation rather than a style note:

1. `field.py` built `SELECT <seq>.NEXTVAL AS next_id` as an f-string and
   handed it to `execute`. Snowflake reads a sequence's next value as a
   pseudo-column on the sequence -- neither `NEXT VALUE FOR` nor PostgreSQL's
   `nextval('seq')` -- so it is a concept with no core node. New
   `expression/sequence.py` `SnowflakeNextvalExpression` carries the sequence
   name and the alias, and `mixins/sequence.py` renders it through
   `format_snowflake_nextval_expression` with the name quoted once by
   `format_identifier`. The mixin now knows the spelling; the field mixin
   collects a name and reads back a value.

2. `expression/ddl/warehouse_options.py` rendered its own `KEY = value` tokens
   with `dialect._escape_sql_string`, reaching into the dialect's escaping
   helper to build SQL from outside a formatter. The rendering moved to
   `SnowflakeWarehouseMixin.format_warehouse_options_expression` -- the same
   mixin that already spells the identical property set for CREATE and ALTER
   WAREHOUSE, which is where the duplication came from -- and the strings now
   go through `format_literal`, so the quoting is the dialect's one answer.

Both are rendering-preserving: `'WAREHOUSE_SIZE = 'MEDIUM''` and
`'My Seq'.NEXTVAL` are what the tests already asserted.

What is left as it is: the remaining `f"..."` SQL in `mixins/*` is `format_*`
bodies, which is where the rule says SQL text belongs. The one exception is
the `COMMENT = '<escaped>'` family, which deliberately uses
`_escape_sql_string` rather than `format_literal` because a comment is text the
statement grammar takes unquoted-vs-quoted by position; it is rendering, in a
formatter, and is not hand-assembled statement SQL.

Evidence (central venv, PYTHONPATH=src;tests, exit 0):
  expression/ (5 files) + snowflake/ + ddl/ + test_clause_pair_guard.py
  0 failed; new test_snowflake_nextval.py 5 passed (node, alias, quoting,
  empty-name refusal, and the node inside a QueryStatement)
ruff clean on every touched file
First CI run (`39480526873`) failed on every Python leg, all in the same
place: `ModuleNotFoundError: No module named 'plugins'`. The parallel default
this branch added registers `-p plugins.expression_xdist` from `pyproject.toml`,
and a `-p` plugin is imported by pytest itself before any conftest can arrange
`sys.path` -- so the registration needed `pythonpath = ["tests"]`, which the
repo did not declare (the local runs worked only because the shell had
`PYTHONPATH=src;tests` set). That is the CI-only half of §14 type H.

With that fixed, the rest of the suite's failures were real and are fixed
rather than papered over:

* **§16.1, fluent setters.** `introspection/test_introspection.py` configured
  eight nodes with `expr.schema("MY_SCHEMA")` after construction. Core retired
  the fluent setters (§16.1: an expression collects every parameter at
  construction), so the attribute is `None` and the call raised
  `TypeError: 'NoneType' object is not callable`. All eight now pass `schema=`
  to the constructor; the same SQL, the same params. This was the last
  `.schema(` / `.with_*` call in the repository (`rg` finds none now).

* **§14 type F.** `query/test_collation_expression.py` composed
  `Literal(dialect, "Alice").collate(...)`. A literal is a value node and
  carries no operation surface (§4.3), so COLLATE is now composed with the
  `collate(...)` factory over the literal -- the same fix postgres made in
  `07bbc24`, with the binding unchanged: `('%s COLLATE 'en-ci'', ('Alice',))`.

* **§14 type H/H', our own vendoring.** `types/test_data_type_parsing.py`
  imported `parse_roundtrip_failures` from
  `testsuite.utils.parse_contract`, and that module imports
  `testsuite.utils.expression`, which the testsuite deleted in `e05edb0`. The
  sweep is now vendored into this repository's harness (the same 120-line
  function mysql vendored) and the test imports it from there. Everything the
  sweep asserts is unchanged.

* **A stale count, measured.** `test_the_family_is_not_empty` claimed 18
  Snowflake type classes; there are 17. The count has been wrong since the
  commit that wrote it -- `SnowflakeDataTypeMixin` was removed as a class when
  every type started inheriting its core type directly, and the expected number
  never followed. Corrected to 17, with the arithmetic in the docstring.

Evidence: CI's exact invocation, from a shell with **no** PYTHONPATH:
  `pytest tests/ -k "not integration" --ignore=tests/snowflake_fakesnow` --
  0 failed, exit 0 (the plugin loads and brings up workers)
ruff clean on every file this commit touches (the 18 warnings remaining in
`introspection/` and `types/` are pre-existing and untouched)
CI's unit-only leg runs `pytest tests/` from the repository root, so the
configured `pythonpath = ["tests"]` put *that* directory on the path rather
than its parent: `plugins` resolved nowhere and the plugin registered by
`addopts` failed to import before a single test was collected. The other
backends register the same plugin without that setting because CI exports
`PYTHONPATH=src:tests`, which is what makes `tests/plugins` addressable as
`plugins`.

Removing the setting restores the layout the plugin name assumes; verified by
collecting the unit-only selection with the same PYTHONPATH CI uses.
The 3.8 leg of run `39483327157` was the only one left red, and it failed at
`-p plugins.expression_xdist`: `ModuleNotFoundError: No module named
'plugins'`.

The cause is the pytest version, not the plugin. That leg resolves to
pytest 8.3.5 (the newest 8.x with a 3.8 wheel); every other leg resolves to
8.4.x. On 8.3.5, `-p` is processed during argument preparse, before the
`pythonpath` ini option is applied, so the option added in the previous commit
covered 8.4+ only. The 3.9 leg passing with the same tree is the control.

The three test steps now export `PYTHONPATH=src:tests`, which is how the
sibling backends' workflows do it (mysql, mariadb and clickhouse all export
it). `pythonpath` in `pyproject.toml` stays: it costs nothing, it makes a bare
local `pytest` work without the variable, and the two agree on the same
directory.

Verified locally with the CI invocation and the variable set:
  `PYTHONPATH=src;tests pytest tests/ -k "not integration"
   --ignore=tests/snowflake_fakesnow` -- 0 failed, exit 0
The table comment reached for SQLDialectBase._escape_sql_string, which
names the base class explicitly and therefore cannot be overridden. A
backend that spells string escaping its own way had its policy dropped
there. Snowflake does not override the escape today, so the rendered SQL
is unchanged; the call now asks the dialect, which is what decides it.
The dialect's surface is two kinds of member: capability switches
(``supports_*()``) and formatters (``format_*(self, expr)``).  This change
makes Snowflake's code match that, and settles what a type position holds
while it is there.

**A string is escaped by the dialect that renders it.**  The escape policy
belongs to the dialect, because SQL dialects differ in how a string literal
represents a quote.  The base class's private copy is gone; each dialect
overrides the public one.

**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.
A routine's type names are claimed the same way, and a routine with no
return type keeps the rendering it had.

**The backend's own answers are the effective ones.**  Snowflake'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``.
vistart and others added 17 commits October 22, 2026 23:32
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.
Statement nodes are ``*Statement``; the fragments have formatters that
end in the ``_expression`` the class name carries. Snowflake's PIVOT,
UNPIVOT, SAMPLE, the VARIANT cast and path access are clauses, not
statements, so they are named for what they are rather than for the
clause a dialect spells them inside.

``format_snowflake_identifier_expression`` keeps its backend prefix on
purpose. ``format_identifier_expression`` is the capability core
declares for its generic ``Identifier``, and a string-taking method
under that name here answered every ``Identifier`` lookup meant for a
formatter. Sharing a renderer across nodes is fine; stealing a name
another node dispatches on is not.
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.
The account-object statements were built by string concatenation in
``introspection/queries.py`` (line 55 ``"SELECT DATABASE_NAME, ..."``, line
95, line 200) plus ``mixins/introspection.py:92`` for
``format_database_info_query_statement``. Four of them -- databases, schemas,
warehouses, storage metrics -- were new: no rule existed to stop them, and
they each re-implemented the clause assembly the dialect already owns.

``introspection/queries.py`` is deleted. Its four builders became statement
classes in ``expression/introspection.py``
(``SnowflakeDatabaseListQueryStatement``,
``SnowflakeSchemaListQueryStatement``,
``SnowflakeWarehouseListQueryStatement``,
``SnowflakeTableStorageMetricsQueryStatement``) whose ``to_sql()`` renders
through ``format_*_query_statement`` methods on the dialect mixin. The
projection is a list of ``Column`` nodes, the filtering a conjunction of
``ComparisonPredicate`` / ``InPredicate`` / ``LikePredicate`` /
``IsNullPredicate`` nodes, the ordering an ``OrderByClause``, and the whole
question a ``QueryStatement``; ``SHOW WAREHOUSES`` is rendered by the existing
``SnowflakeShowStatement``.

The remaining formatters in ``mixins/introspection.py`` -- database info,
table list, column info, index info, foreign key, view list/info, triggers --
were also text; they are now built from the same tree nodes through one
``_catalog_query`` helper, so the "INFORMATION_SCHEMA objects are spelled
unquoted" rule is stated once (and why it is correct, per the Information
Schema topic) instead of being repeated in eight string literals. The
rendered SQL is byte-identical to the concatenated form except for the
row-source aliases, which now read ``AS m`` / ``AS t`` / ``AS tc`` /
``AS kcu`` (the dialect's separator) rather than the bare ``m`` / ``t``.

Public surface (sync and async, identical names and signatures):

* ``list_databases()``
* ``list_schemas(database=None)``
* ``list_warehouses()``
* ``get_table_storage_metrics(table_name, schema=None)``

And three CLI subcommands -- ``databases``, ``schemas``, ``warehouses`` --
dispatched through ``backend.introspector`` and rendered by the existing
output provider.

BREAKING CHANGE: ``rhosocial.activerecord.backend.impl.snowflake.introspection.queries``
no longer exists. ``SHOW WAREHOUSES`` output is documented in lowercase
column names where ``INFORMATION_SCHEMA`` uses uppercase, so
``list_warehouses`` normalizes row keys before reading them.
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.
…ties

Eight switches were resolving to core's default of False while this backend
introspects fully -- catalog views, sys.objects for routine bodies, and a
CLI covered by tests against a live server. A caller gating on them would
have concluded SQL Server has no introspection at all.

Declared one per family, each naming what it reads. Placed in a mixin of its
own and registered before the core bundle each replaces, because a mixin
placed after the class it overrides never wins -- the ordering the dialect
file already documents.
The core helper is gone: core no longer arbitrates a per-backend
introspection vocabulary. Restore the hand-written INTROSPECT_TYPES as
the type argument's choices, so --help lists exactly what this backend
declares.

The connection already carries the schema (--schema / SNOWFLAKE_SCHEMA),
so a run must not name it twice: remove --introspect-schema and pass
schema=None, letting the introspector resolve the configured schema.
…tract

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.

Snowflake side: thirteen row parsers -- databases, schemas, warehouses,
storage metrics and account objects among them -- plus default_schema and
the call sites that read it. Three tests follow.
"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.
The introspect subcommand had no parser-level test, so the accepted type list, the dispatch branches and the output formats could drift apart silently. Add an offline suite that parses every INTROSPECT_TYPES entry, refuses a type the list does not carry, drives each sync and async branch against a fake introspector, and checks json/csv/table serialisation without a connection.

The help text still advertised the removed 'table' aggregate and showed an example that no longer parses; both are gone.

Catalogue adoption: parse_database_info now reports self.server_version (falling back to 'unknown' and (0, 0, 0)) instead of the backend's private _version, MATERIALIZED VIEW keeps its own TableType, and the RETURNING predicates use version_at_least so an unknown version answers False instead of raising.
TABLE_TYPE spellings the parser did not know were folded into BASE_TABLE with no trace, and include_views=False demanded TABLE_TYPE = 'BASE TABLE', which hid managed, event, Iceberg, hybrid and dynamic tables along with the views. Record the catalogue's own spelling on every table row in extra['snowflake_table_type'], map the recognised vendor kinds explicitly, exclude only the two view spellings, and expand core TableType names onto the catalogue's vocabulary so MATERIALIZED_VIEW and EXTERNAL select the right values.
`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 self-assigned this Oct 31, 2026
@vistart vistart added the enhancement New feature or request label Oct 31, 2026
The branch already held fragments named after an older pull request number that
never merged (10 on SQL Server, 21 on Snowflake). The numbering rule reads every
fragment a pull request adds and requires each to carry that pull request's
number, so the check failed on fragments describing changes that ship here all
the same.

Their text is folded into this pull request's own entries rather than dropped:
it is the detail behind the same work -- the catalogue questions core has no
shape for, and the CLI that used to call method names no introspector has.
@codecov

codecov Bot commented Oct 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.85913% with 131 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.65%. Comparing base (b2bcb01) to head (5c31cec).

Files with missing lines Patch % Lines
...record/backend/impl/snowflake/mixins/constraint.py 75.69% 35 Missing ⚠️
...iverecord/backend/impl/snowflake/cli/introspect.py 80.41% 11 Missing and 8 partials ⚠️
...ckend/impl/snowflake/introspection/introspector.py 89.87% 11 Missing and 5 partials ⚠️
...cord/backend/impl/snowflake/mixins/capabilities.py 36.36% 7 Missing ⚠️
...cord/backend/impl/snowflake/expression/coercion.py 68.75% 4 Missing and 1 partial ⚠️
...ord/backend/impl/snowflake/mixins/introspection.py 93.15% 5 Missing ⚠️
...nowflake/examples/introspection_account_objects.py 96.19% 3 Missing and 1 partial ⚠️
...ackend/impl/snowflake/introspection/table_types.py 80.00% 2 Missing and 2 partials ⚠️
...backend/impl/snowflake/mixins/materialized_view.py 73.33% 2 Missing and 2 partials ⚠️
...ctiverecord/backend/impl/snowflake/mixins/types.py 81.81% 2 Missing and 2 partials ⚠️
... and 16 more
Additional details and impacted files
@@                   Coverage Diff                   @@
##           release/v1.0.0.dev2       #7      +/-   ##
=======================================================
+ Coverage                80.18%   80.65%   +0.46%     
=======================================================
  Files                      100      117      +17     
  Lines                     3936     5013    +1077     
  Branches                   602      735     +133     
=======================================================
+ Hits                      3156     4043     +887     
- Misses                     585      744     +159     
- Partials                   195      226      +31     
Flag Coverage Δ
python-3.12 80.65% <87.85%> (+0.46%) ⬆️
unittests 80.65% <87.85%> (+0.46%) ⬆️

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 032f842 into release/v1.0.0.dev2 Oct 31, 2026
11 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