Repository navigation
feat(sqlserver)!: the catalogue as statements, and the extended-property family - #9
Merged
Merged
Conversation
The shape check now lives where the flag has to be honoured rather than where it is declared, so a value that would silently render the wrong DDL is named instead. The attribute is read directly: whether the field exists is settled by the class, so probing for it with a default would only turn a wrong class into a quiet signed column. CI installs the core library from fix/column-constraint-gates so this backend runs against the branch it depends on.
… with getattr Each of these sits behind a parameter whose annotation names a class, and each field is in that class's constructor signature. The IndexDefinition and Index reads in particular: those attributes are what the class was built to carry, so the default was a claim that a well-formed declaration might arrive without them, which never happened. Verified by running the backend suite against both the changed tree and the tip it came from: the same 24 failures either way, all of them needing a live SQL Server. 562 tests pass on the type and DDL modules that exercise these paths without a server.
Wave 1 of the backend conformance sweep: W2 + W3a + W3b + W1, mechanical
renames only. 71 files, no behaviour change.
Statement family (W2). Core split "an expression that happens to be a
statement" from "a statement", so the class names moved with it:
QueryExpression -> QueryStatement
Insert/Update/Delete -> Insert/Update/DeleteStatement
MergeExpression -> MergeStatement
SetOperationExpression -> SetOperationStatement
BeginTransactionExpression -> BeginTransactionStatement
CommitTransactionExpression -> CommitStatement (no "Transaction")
RollbackTransactionExpression -> RollbackStatement
Savepoint/ReleaseSavepointExpression -> ...SavepointStatement
Create/Alter/Drop {Table,Index,View,Schema,Sequence,Type,Function,
Trigger,Domain,FulltextIndex,PropertyGraph,MaterializedView,
Database}Expression -> ...Statement
CreateTableAs/Like/CloneExpression -> ...Statement
TruncateExpression -> TruncateStatement
CommentOnExpression -> CommentOnStatement
ExplainExpression -> ExplainStatement
RefreshMaterializedViewExpression -> RefreshMaterializedViewStatement
Functional formatter renames (renamed because an override that keeps the old
name stops being an override and silently falls back to core's spelling):
format_set_operation_expression -> format_set_operation_statement
format_begin_transaction -> format_begin_transaction_statement
format_commit_transaction -> format_commit_transaction_statement
format_rollback_transaction -> format_rollback_transaction_statement
format_savepoint -> format_savepoint_statement
format_release_savepoint -> format_release_savepoint_statement
CTE family (W1):
CTEExpression -> CTEClause
WithQueryExpression -> WithStatement
format_cte_expression -> format_cte_clause
main_query= -> query=
Value family (W3b): SQLValueExpression -> SQLValue, the eight
{JSON,Binary,XML,UUID,Numeric,Integer,String,Boolean}ValueExpression ->
*Value, BooleanLogicExpression -> LogicalValue, QualifiedIdentifierExpression
-> QualifiedIdentifier, JSONDocument/JSONTextExpression -> *Value,
ArrayValueExpression -> ArrayValue, WildcardExpression -> AllColumnsWildcard
(its two call sites are select-list positions; the COUNT side is untouched).
Function family (W3b): Cast/Case/Trim/Repeat/Lpad/Rpad/CollateExpression ->
*Function, UUIDCastExpression -> UUIDCastFunction.
Predicate family (W3a): Any/All/Exists/ILIKE/XMLExistsExpression ->
*Predicate, FilterClauseExpression -> FilterClause, FulltextMatchExpression ->
FulltextMatch.
Introspection family (W3b): the Introspection prefix is added, keeping the
Info suffix the old guide table dropped:
DatabaseInfo/TableInfo/ColumnInfo/ViewInfo/TriggerInfo/ForeignKeyInfo
-> Introspection*Info
IndexInfo and IndexColumnInfo keep their names -- verified against
backend/introspection/types.py, which is what core actually ships.
Not in this commit, by design: the constraint surfaces. Core's S3 wave
deleted ColumnConstraintType / TableConstraintType /
normalize_{column,table}_constraint_type, and 21 modules in this package
import one of them, so the package does not import yet. Those surfaces
follow in the next commit.
Verification:
- import smoke over every module in the backend package: 102 imported,
21 failing (was 69 ok / 24 failing). All 21 remaining failures are the
deleted constraint enums; none is a rename miss.
- rg over src + tests: zero residual references to any W1/W2/W3 old name
and zero to the renamed formatters.
- ruff check on the 71 changed files: 185 findings, identical to the HEAD
baseline for the same file set (compared per file and per rule). No new
finding of any class.
Known gap, and not this repository's to fix: the shared testsuite
feature/ddl contract still imports the deleted ColumnConstraintType, so it
cannot run until the testsuite agent finishes S3.
Core's S3 put the kind inside the clause: ``ColumnConstraint`` and
``TableConstraint`` are productions that carry a **branch**, and the branch is
a node in its own right. ``ColumnConstraintType`` / ``TableConstraintType`` are
gone, and with them ``normalize_column_constraint_type`` /
``normalize_table_constraint_type`` / ``_call_constraint_capability``. There is
no tag left to switch on; every call site now names the alternative it means.
Construction sites migrated (28 files; src 13, tests 15):
ColumnConstraint(d, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)
-> ColumnConstraint(d, branch=ColumnPrimaryKeyConstraint(d, is_auto_increment=True))
ColumnConstraint(d, ..., check_condition=p) -> branch=ColumnCheckConstraint(d, p)
ColumnConstraint(d, ..., default_value=v) -> branch=ColumnDefaultConstraint(d, v)
ColumnConstraint(d, ..., foreign_key_reference=(t, cols)) ->
branch=ColumnForeignKeyConstraint(d, (t, cols))
NOT_NULL / NULL / UNIQUE tags ->
branch=ColumnNotNullConstraint / ColumnNullableConstraint / ColumnUniqueConstraint
TableConstraint(d, TableConstraintType.PRIMARY_KEY, columns=[...]) ->
TableConstraint(d, branch=TablePrimaryKeyConstraint(d, [...]))
GeneratedColumnExpression -> ColumnGeneratedConstraint
The deleted ``normalize_*`` calls at ``dialect.py`` (both the column and the
table half) were the first thing removed: they were imports of symbols core had
already deleted, so the module did not import before this change. No residual
reference to any deleted name remains in ``src`` or ``tests``.
Override points, re-cut against the new API:
* ``SQLServerDialect.format_column_definition`` no longer walks a tag switch.
PRIMARY KEY and UNIQUE are still spelled here, because ``NONCLUSTERED`` rides
the clause and the branch formatter is dispatched with a single argument, so
there is no way to hand it the flag without smuggling it in as dialect state.
``IDENTITY(1,1)`` now comes off the PRIMARY KEY **branch**'s own
``is_auto_increment`` flag. Everything else (NOT NULL, NULL, DEFAULT, CHECK,
GENERATED, the column REFERENCES that T-SQL refuses) goes through core's
production formatter.
**A defect this commit fixes while it is here.** The first draft of this
migration let the PK/UNIQUE shortcut bypass ``_refuse_constraint_traits``,
which meant an explicitly requested ``ENFORCED`` / ``DEFERRABLE`` on a PRIMARY
KEY or UNIQUE column constraint was **silently dropped** -- the exact failure
the old ``format_column_definition`` refused with a ``ValueError``. The
refusal now runs first and unconditionally, before either shortcut or the
delegation, so no path can drop a clause T-SQL does not parse.
* ``SQLServerDialect.format_table_constraint`` keeps the two reasons it exists:
the memory-optimized spellings (``PRIMARY KEY NONCLUSTERED`` /
``UNIQUE NONCLUSTERED``) and the trait refusals. The branch class says which
kind this is; the ``CONSTRAINT <name>`` prefix, the branch bodies for CHECK
and FOREIGN KEY, and the REFERENCES clause with its referential actions are
core's, reached through ``super()``.
* ``SQLServerTypeDDLMixin`` (``mixins/ddl_type.py``) reads the branch class in
all four places it used to read the tag: the memory-optimized column and
table allow-lists, the key-constraint count for the index limit, and the
"needs a key constraint" check. The old per-kind ``ValueError`` guards for
cross-wired fields (a key with a check condition, a CHECK with columns) are
gone with the fat node -- the branch cannot hold a field its kind does not
have, so those shapes are now unrepresentable rather than checked.
* ``SQLServerConstraintMixin`` (``mixins/constraint.py``) gains the narrowing
seams and the measured evidence. The trait refusals live at the two public
seams: ``format_column_constraint`` for the column level and
``SQLServerDialect.format_table_constraint`` for the table level, both going
through one ``_refuse_constraint_traits`` helper so the two cannot drift
apart. ``format_column_fk_constraint`` refuses the column-level ``REFERENCES``
clause, which is the one real narrowing: core gates it on
``supports_foreign_key_constraint``, the *table-level* probe, and SQL Server
needs that probe to be True for the table form, so the probe cannot be
flipped to turn the column level off -- the coupling is recorded in the
docstring.
The probes are ``(self)``: ``supports_add_constraint`` / ``supports_drop_constraint``
are True by measurement, and ``supports_constraint_enforced`` /
``supports_deferrable_constraint`` / ``supports_alter_constraint_enforced`` /
``supports_validate_constraint`` / ``supports_constraint_novalidate`` /
``supports_enable_disable_constraint`` / ``supports_rename_constraint`` /
``supports_fk_match`` are False. Every docstring quotes the statement and the
server's own message number (Msg 102 / 156 for the parser refusals, 3728 /
3727 / 15248 / 15225 / 15249 for the ALTER-side ones), measured on SQL Server
2019 15.0.4465.1 at 127.0.0.1:11433. Wave 3 folds this in rather than
splitting it, because the probes, the ``_require_*``-style narrowing and the
formatters that call them are one unit (wave guide section 5, detail 3); the
evidence and its guard test are what the next commit adds.
* ``alter_table_modifier.py`` records the live measurement behind the two
``IF EXISTS`` switches, and says which of the two is a documentation claim
rather than a measurement.
Not in this commit, by design: the roundtrip matrix's LIKE/ILIKE and
``xml.PassingClause`` rows, which are the wildcard-split follow-ups and belong
with the wildcard wave (the same split mysql made between ``3d643d6`` and
``a611b65``).
Verification (no live SQL Server in this environment -- Docker is not running,
so 11433/11435 refuse connections; every check below is render-level, and the
measured values come from the draft's recorded server observations):
- import smoke over the backend package: 140 modules imported, 0 failures
(examples excluded -- they dial a server at import time).
- testsuite shared feature/ddl contract (core worktree): exit 0.
- test_clause_pair_guard_sqlserver.py + test_ddl_source_mixin.py +
test_master_probes_guard_sqlserver.py: 170 passed, exit 0.
- test_expression_roundtrip_all.py: 782 passed, 8 skipped, 4 failed -- the four
are the LIKE/ILIKE/PASSING rows deferred to the wildcard commit above, not
S3 regressions.
- ruff over the 28 changed files: 111 findings (E402 69, F401 36, F541 2,
F811 3, E501 1), against the same files' 112 at the parent commit (E402 69,
F401 36, F541 2, F811 3, E501 2). One fewer E501, no new finding of any class.
The code side of the constraint-capability reconciliation went in with the S3 migration (`906d31b`, wave guide section 5 detail 3: the probe signatures, the narrowing and the formatters that call them are one unit, so splitting them would put a commit on record that contradicts core's API). This commit is the half that reads as evidence: the module of guards that keeps every declaration tied to the statement which decided it, plus the live measurement recorded behind the two ALTER-side ``IF EXISTS`` switches. New: ``test_sqlserver_constraint_capabilities.py`` walks one table of ten switches and asserts four separate things. * **the value** -- what ``sqlserver_2019`` (15.0.4465.1) was measured to do, and that 2019 / 2022 / 2025 agree, so no value is a version gate. * **the locality** -- each switch resolves to a class in *this* package, with an MRO-precedence assertion that the answering class precedes core's ``ConstraintMixin``. A switch that fell through to core could have its default moved by a commit in another repository with nothing failing here. * **the signature** -- ``(self)`` and nothing else, checked on the bound method so a defaulted parameter cannot slip back in. A probe answers one question and the kind is not part of it. * **the evidence** -- each switch's docstring still quotes the server's own message number (Msg 102 / 156 for the parser refusals; 3728 / 3727 / 15248 / 15225 / 15249 for the ALTER-side ones) and names the release it was measured on. A verdict that cannot be traced to an observation is a guess. The render-path tail is asserted through the **two public seams**, so a seam that stopped consulting the shared ``_refuse_constraint_traits`` helper fails here rather than quietly dropping a clause: * column level, ``ColumnDefinition`` path -- the column-level ``REFERENCES`` refusal (and the table-level probe that stays True, which is why the refusal needs an override rather than a probe), plus the six trait spellings on a PRIMARY KEY / UNIQUE column. That last pair is the defect ``906d31b`` fixed: those two kinds are spelled inside ``format_column_definition`` rather than through the production formatter, so they are the path that would drop a request without a word. * table level, ``TableConstraint`` path -- the same six spellings on a table PRIMARY KEY, which is a kind that can carry both traits. The node's own construction-time refusal of the deferral trait on a CHECK is pinned separately so the two layers stay distinguishable. * the ALTER side -- ``VALIDATE`` / ``ALTER CONSTRAINT`` (both spellings) / ``NOT VALID`` refused by name, ``ADD`` / ``DROP CONSTRAINT`` and the ``IF EXISTS`` idempotent form rendered. ``alter_table_modifier.py`` gains the measured evidence for its two switches without changing either value: ``DROP CONSTRAINT IF EXISTS`` was measured accepted as a silent no-op on 2019 (the plain form is Msg 3728 followed by 3727), while the "since 2016" floor on both switches is a documentation claim carried rather than a measurement, and says so where it is stated. Verification (no live SQL Server in this environment -- Docker is not running, so 11433 / 11434 / 11435 refuse connections; the values come from the draft's recorded server observations and the guards pin them): - test_sqlserver_constraint_capabilities.py: 80 passed, exit 0. - with test_master_probes_guard_sqlserver.py and test_clause_pair_guard_sqlserver.py: 207 passed, exit 0. - ruff over the two changed files: clean.
Core split the single asterisk in 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, which is why they are separate nodes
and not one node with a flag. There was no intermediate ``Wildcard`` to pass
through, so each call site moves straight to its position's node.
Call sites migrated by position, not by name (2 files; src 1, tests 2):
select-list / RETURNING WildcardExpression -> AllColumnsWildcard
tests/.../test_backend_explain.py, tests/.../dialect/test_returning_output.py
Both are the select-list / RETURNING positions, so both take the
all-columns node. No COUNT argument position is built through an expression
node in this backend -- the ``COUNT(*)`` occurrences that remain are raw SQL
strings handed to ``backend.execute`` (``protocol_support.py``'s OUTPUT helper
and the examples), which are not expression nodes and need no migration.
**No backend-specific wildcard class is warranted here**, and the reason is the
one the guide gives for keeping it out of core: ``COUNT(t.*)`` is a
postgreSQL / snowflake affair (a different row-count question under a join),
and SQL Server's own asterisk spelling is ``OUTPUT inserted.*`` / ``deleted.*``
-- a plain string in the OUTPUT column list, not a wildcard node. SQL Server's
``format_column_definition`` / ``format_returning_clause`` build their
asterisk-bearing fragments from strings, so there is nothing to derive.
No ``format_wildcard`` override existed here (``WildcardSupport`` is inherited
from the core protocol, and the dialect's two wildcard formatters come from
``ExpressionMixin``), so none needed renaming. The one thing the split did
change in this package is the refusal in ``format_returning_clause``: the
comment claimed both wildcard classes were refused by name while only the
all-columns one was checked. ``RowCountWildcard`` now shares the refusal, so an
asterisk in an OUTPUT list is named as such instead of falling through to the
generic "only column references" message.
Verification (no live SQL Server in this environment -- Docker is not running,
so 11433 / 11434 / 11435 refuse connections; these are render-level checks):
- import smoke over the backend package: 140 modules imported, 0 failures
(examples excluded -- they dial a server at import time).
- test_backend_explain.py + dialect/test_returning_output.py: 12 passed,
7 skipped, exit 0.
- ruff over the three changed files: clean for returning.py and
dialect/test_returning_output.py; test_backend_explain.py keeps the five F401
findings it already had at the parent commit (pytest, pytest_asyncio, Column,
Literal, WhereClause -- none introduced here).
…ip matrix The roundtrip matrix walks every expression class core and this package define, so two of the wildcard-split's consequences land here rather than in the refactor commits. **LIKE / ILIKE need dedicated constructors.** Core's commit calls it out: ``LikePredicate``'s operator slot is annotated ``str`` and its two operands are string forward references, while ``ILikePredicate``'s column is annotated ``Any`` -- so the introspective guess hands each of them a plain string and the instance cannot render (``'str' object has no attribute 'to_sql'``). ``LikePredicate`` is worse than that: the operator is a *parameter*, so the guess's ``"x"`` would land in the operator slot. Both factories are the ones core registers in its own dummy2 matrix -- ``LikePredicate(d, "LIKE", Column(d, "a"), Literal(d, "%x%"))`` and ``ILikePredicate(d, Column(d, "a"), "%x%")`` -- so the matrices agree on the shape. **``xml.PassingClause`` joins the declared gaps.** The S1-c clause rides the SQL/XML querying expressions, and SQL Server declares no ``format_xml*`` method at all, so the clause's own ``format_passing_clause`` is absent too and the dispatch refuses it exactly like every other ``xml.*`` row. It is the same declared gap, recorded in the same entry with the same reason, so the ``test_unmodelled_formatter_list_is_exact`` walk stays exact in both directions. S3's own matrix side -- the ``ColumnClause`` / ``ColumnConstraintBranch`` / ``TableConstraintBranch`` bases and the ``_TemporalValue`` rename -- went in with the S3 migration (``906d31b``), because the table has to name the new bases in the same commit that introduces them. This is the last piece the guide's wave 6 asks for before pushing: CI runs ``pytest tests/ -n auto -x``, so the first error stops the session and the matrix has to be green in full, not just on the changed classes. Verification (no live SQL Server in this environment -- Docker is not running, so 11433 / 11434 / 11435 refuse connections; the matrix is render-level): - test_expression_roundtrip_all.py, full file: 786 passed, 8 skipped, exit 0. - the three new registrations are collected and asserted, not skipped: test_to_sql_roundtrip_classified and test_get_params_roundtrip each pick up ILikePredicate, LikePredicate and PassingClause (6 cases). - TestMatrixIntegrity: 8 passed, exit 0 -- both the declared-gap table and the unmodelled-formatter table are exact in both directions after the additions. - ruff over the one changed file: 6 findings, identical to the same file at the parent commit (pre-existing F401 / F811; none introduced here).
The shared testsuite contract moved onto the same branch as core and this backend -- its S3 adaptation, the predicate-placeholder fix and the W2/W3 renames all landed on ``fix/column-constraint-gates`` -- so the CI workflow no longer installs it from ``release/v1.0.0.dev17``. Every install line in ``.github/workflows/test.yml`` now names ``@fix/column-constraint-gates`` for the testsuite, matching the core line above it, which was already pointing at the branch. Nine occurrences, all inside the nine ``Install activerecord and testsuite from PyPI`` steps: the testsuite is installed once per matrix leg (the synchronous, asynchronous, version-specific and CLI legs). No test logic changes; the diff is the branch pin alone. Verification: - ``rg 'rhosocial-activerecord-testsuite .* @Release' .github/workflows/`` -- no match remains. - ``rg 'rhosocial-activerecord-testsuite .* @fix/column-constraint-gates'`` -- nine matches, one per install step. - The core install line still names ``@fix/column-constraint-gates`` in all nine steps as well, so the two packages come from the same branch.
Carry the W3c class renames, the R1 signature alignment and the R2a arithmetic/concatenation renaming through the backend sources and tests. Set operations take the required op word instead of the retired all_/distinct pair, so the clause-pair guard no longer models them; DDL default values move to the inline-strategy literal class.
The core removed the FQN-keyed deserialization index tonight (kind-registry
rewrite, core 8d474dc8/303dae60): register_expression_class,
registered_expression_classes and auto_register_builtins no longer exist,
which left this backend unimportable against the new baseline.
Migrate the type-DDL node assembly to the new API:
- expression/ddl/type.py registers its seven classes through
default_registry.register_node (registry.py's process-level instance,
the assembly point its own docstring gives to dialect packages; the kind
is derived from the class name, e.g. sql_server_alias_type_definition).
- test_type_expressions_are_explicitly_registered follows: the
registration assertion resolves each kind through the process registry,
and the write side is asserted on the bare {kind, params} artifact. The
three-channel deserialize leg is gone by design: the framework's
default read whitelist now admits only the packages built into the core
distribution (_FRAMEWORK_PACKAGES), so reading a backend distribution's
kinds is a reader-side decision that the upcoming backend registry wave
will settle; inventing that reader here would chase a moving core.
This is baseline tracking, not one of the four guided waves; it is a
prerequisite for any of them to be tested at all.
Evidence: backend + dialect import OK; targeted run
test_sqlserver_type_ddl.py: 27 passed, 1 skipped in 0.66s (ruff clean).
…attr Clear every getattr/hasattr probe on an expression receiver in src (form A direct reads and form B class dispatches, per expression-dialect 11.2 and the S2 precedent 101f71a5). The acceptance scan rg '(getattr|hasattr)\(\s*(expr|node|clause|item)\b' src now matches nothing. 21 sites, by file: - dialect.py (8): table_options.comment read directly off the declared options object (None-guarded; the field is on CreateTableOptions); edge_constraints / graph_table_kind via isinstance dispatch on SQLServerCreateTableExpression (those fields belong to that node, not to the generic statement); index_type direct read (declared Optional[str] on the core CreateIndexStatement); MERGE output/output_action/holdlock via isinstance dispatch on SQLServerMergeExpression; func_name direct read (declared on the core FunctionCall). - mixins/ddl_type.py (5): DROP TYPE cascade/restrict via isinstance dispatch on SQLServerDropTypeExpression; definition_kind (twice) and action_kind read directly (abstract properties on the TypeDefinition / TypeAlterAction ABCs, so every concrete subclass declares them). - mixins/ddl_view.py (2): options.schemabinding / options.check_option read directly (fields of the ViewOptions dataclass; CreateViewStatement normalizes options to a non-None instance, so the old None-guard went). - mixins/partition.py (1): partition_scheme via isinstance dispatch on SQLServerPartitionByRangeClause (the generic PartitionClause carries no such field). - mixins/protocol_support.py (1): select_into_table read directly (declared on SQLServerSelectIntoExpression). - mixins/routine.py (1) and mixins/trigger.py (3): _get_routine_name / _get_trigger_name read the declared name; create-trigger refuses the SQL:1999 statement by class (UnsupportedFeatureError, unchanged) instead of probing for body; trigger table read directly. Behavior tightening (recorded in the roundtrip matrix, committed with this wave as instructed): the shadowed core CreateFunctionStatement and DropTriggerStatement carry no name field, so rendering them through this backend's formatters now fails with AttributeError "has no attribute 'name'" where the probes used to manufacture an empty name and raise ValueError. Both LEGITIMATE_NON_RENDERS rows are updated to pin the AttributeError and its message; the matrix module itself currently cannot execute against tonight's core baseline (the testsuite round-trip harness it depends on was deleted in testsuite e05edb0), so the two rows were verified by reading the flow, not by running the file. Evidence (targeted, with an empty local scenario set so the session autouse fixture does not spend ~93s dialing three closed ports): test_sqlserver_type_ddl.py 27 passed 1 skipped; test_p2_coverage_features.py 63 passed; test_sqlserver_partition.py 21 passed; test_sqlserver_ddl_improvements.py 9 passed; test_p1_coverage_features.py -k "SelectInto or Tablesample" 10 passed; test_dialect_comprehensive.py -k "TestSQLServerDialectMerge or TestSQLServerDialectDDL" 4 passed. ruff on the seven changed src files and the matrix file reports the same 25 pre-existing findings as HEAD (17+1+1+6), zero introduced by this wave.
Correction to da73e38: the process-level default registry that deserialization actually consults is serialization.default_registry(), the lazy singleton the artifact reader resolves kinds through. Registering into registry.default_registry (the bare instance) kept the import fix but fed a table no read path consults yet, and left the deserialization leg of the type-DDL coverage untestable. Follow the same target the mysql harness settled on (mysql 8961ea1): the classes -- and the module's enum, which an artifact encodes under its short class name -- go into default_registry(), where re-registering a class the framework already populated is a no-op and the backend's kinds are the addition. The three-channel round-trip leg of test_type_expressions_are_explicitly_registered is restored accordingly and passes through the default path again. Evidence: test_sqlserver_type_ddl.py 27 passed, 1 skipped in 0.39s (exit 0, ruff clean on both files).
…spellings Wire the core TypedValueMixin into the dialect and spell the kinds where T-SQL's grammar disagrees with the portable defaults (guide wave V, per core 473b8e37 + 5ba491d5 + 1549380b; implementation mirrors the SQLite reference mixin and the in-flight PostgreSQL one). New mixins/typed_value.py, composed ahead of SQLDialectBase in the dialect base list (that position is what makes the intercept win): - format_binary_value: 0x0102, the unquoted hex constant (data position still binds bytes; only the DDL position spells). - format_uuid_value: the bare quoted canonical text -- UNIQUEIDENTIFIER converts string literals; no cast syntax to add. - format_interval_value: deliberately absent. T-SQL has no interval literal, so the core mixin's explicit NotImplementedError is the shipped answer; the wiring test pins that resolution. - format_literal: the boolean branch is intercepted to 1/0, because T-SQL's literal grammar has no TRUE/FALSE keywords -- unquoted TRUE parses as an identifier, and BIT's constants are numeric. This is the tier-1 audit's outcome: the default spelling does NOT apply here, so the single spelling point (format_literal) is overridden, which keeps the typed node's inline branch, raw-scalar DEFAULT positions and CHECK conditions in agreement; the data position is untouched (pyodbc binds True/False to BIT natively). Quoted ISO dates and the other tier-1 kinds were audited against the documented T-SQL grammar and keep the base spelling. Not yet server-verified: the scenario ports (11433/11434/11435) were refused all session (~31 s per dial), so the spellings rest on the documented grammar and the module header says so; the live round-trip class (sentinel + CREATE TABLE with embedded DEFAULTs + INSERT ... DEFAULT VALUES read-back, following the core execution_testing discipline) rides for CI's live scenarios. Follow-up: measure the docstring answers against a reachable server and record the versions. New test_sqlserver_typed_values.py pins both positions of every spelled kind -- the DDL position through test-local subclasses declaring inline_params = True (the same class-level strategy flip DdlLiteral applies to Literal; the core has shipped no DDL variants of the value nodes and a dialect must not invent nodes) -- plus the wiring (MRO resolution, the interval refusal, the format_literal intercept) and the live class. Evidence (offline, empty local scenario set): the new file -k "not Live" 15 passed, 2 deselected, exit 0; feature/backend -k "(typed or literal) and not Live" 28 passed, 5 skipped, exit 0 (the two harness-importing roundtrip files excluded from collection -- wave IV vendors the retired testsuite harness, mysql 8961ea1 precedent). ruff clean on the new files; dialect.py carries the same 17 pre-existing findings as HEAD.
… VALUES cells
Follow the core waves this backend had not digested (guide wave M1, per core
9b559030 + c5210245 + 8e2e4d82):
Classed entries -- the standard functions no longer ride FunctionCall:
- functions/aggregate.py: max_/min_/avg build MaxFunction / MinFunction /
AvgFunction, whose kinds dispatch to their own formatters.
- functions/utility.py: coalesce builds CoalesceFunction; the factory's
argument conversion (a bare string names a column, a number becomes a
literal) is kept, so only the node changed.
- functions/string.py: trim builds TrimExpression, which fixes a real bug:
the old two-argument FunctionCall("TRIM", [expr, chars]) rendered
TRIM(expr, chars) -- a shape T-SQL does not have -- where the node's
formatter spells TRIM(chars FROM expr), the form SQL Server 2017+ parses.
- format_char_length_function is overridden on the dialect body and spells
LEN: SQL Server counts UTF-16 code units and excludes trailing blanks,
which is why the core docstring names this backend as needing the
override. It lives on the body (not a late mixin) because the core method
rides into the MRO early through ExpressionMixin -> FunctionCallMixin,
ahead of every SQL Server mixin block; a test pins that resolution.
- mixins/datetime.py: NOW / CURRENT_DATE / CURRENT_TIME keep their T-SQL
spellings through the classed formatters format_now_function /
format_current_date_function / format_current_time_function
(SYSDATETIME() / CAST(GETDATE() AS DATE) / CAST(GETDATE() AS TIME)).
A precision argument has no T-SQL spelling on any of the three, so it is
refused explicitly instead of dropped. CURRENT_TIMESTAMP keeps the
standard niladic keyword the core already spells.
- dialect.py: the _NILADIC_FUNCTION_EQUIVALENTS and
_FUNCTION_NAME_EQUIVALENTS tables and the format_function_call override
are deleted -- every entry in them was a classed function's rename, and
FunctionCall is the UDF escape hatch now, rendering through the core
default. The now-unused _clone_with_func_name helper and copy import go
with them.
Bare VALUES cells -- mixins/dml.py's INSERT override delegates its VALUES
branch to the core's format_insert_values_source, which owns the per-cell
decision (core 8e2e4d82): an expression renders its own SQL, a bare host
value becomes a bind parameter. The hand-rolled loop used to call
val.to_sql() unconditionally, so a bare scalar cell failed with
AttributeError instead of binding. The T-SQL OUTPUT / DEFAULT VALUES /
SELECT-source paths are unchanged.
New test_classed_function_entries.py pins all of it offline: the classed
factories, the LEN spelling and its MRO resolution, the niladic spellings
and their precision refusals, the trim node's operand order, the kept
coalesce conversion, the UDF passthrough, and INSERT VALUES rows mixing
bare and expression cells.
Evidence (offline, empty local scenario set):
test_classed_function_entries.py 19 passed;
test_functions.py + test_dialect_function_support.py 40 passed;
test_dialect_comprehensive.py -k "TestSQLServerDialectDML or
TestSQLServerDialectMerge" 6 passed;
test_p1_coverage_features.py + test_p2_coverage_features.py 118 passed.
ruff: the changed files introduce no new findings (dialect.py keeps the
same 17 pre-existing ones as HEAD; datetime.py keeps its 1).
Core replaced the TypedValueMixin and the twelve *Value classes with a single Literal(dialect, value, inline=) node and folded the tier-2 spellings into format_literal (239678e5, "give a literal its job and nothing else"). Wave 2's committed TypedValueMixin form therefore no longer imports; this is that form's replacement, not a revert. * mixins/literal.py replaces mixins/typed_value.py: SQLServerLiteralValueMixin overrides format_literal and claims exactly the three types whose inline spelling differs from the portable ones -- bool as 1/0 (T-SQL's literal grammar has no TRUE/FALSE keywords and BIT's constants are numeric), bytes as the unquoted 0x hexadecimal constant, UUID as the quoted canonical text a UNIQUEIDENTIFIER converts. Everything else falls through to SQLDialectBase.format_literal, which is where the three-level construction, the escape helper and the finite-number guard live; the escaping is not copied here. * INTERVAL is deliberately absent. T-SQL has no interval type and no interval literal, so a timedelta at an inline position reaches the base's ladder and fails loudly naming its type -- the structural absence is the answer, and the mixin's docstring says so. * The mixin is the dialect's first base, ahead of SQLDialectBase: a mixin placed after the class it overrides never wins. The old position (after the core's TypedValueMixin, which no longer exists) is gone with it. * The wave-2 test file follows: the DDL-position subclasses that flipped inline_params per class are replaced by Literal(..., inline=True), the inline flag now being the caller's per-node decision. The data-position assertions, the tier-1 spellings, the MRO pin and the live round-trip class are unchanged in substance; the live cases' server-measured notes are carried over as written (not server-verified: the scenario servers were unreachable this session, so CI's live scenarios exercise that class). Verified: import smoke (dialect imports, format_literal(True) == "1", format_literal(b"\x01\x02") == "0x0102"); test_sqlserver_typed_values.py 12 passed, 6 live cases deselected (no reachable server); ruff clean on the new and changed files.
…enames the core settled
Four core-side waves reached this backend's node contracts and had not been
followed through here; each is a reading of a field the node declares, not a
probe.
* ``SQLServerTable`` named its identity parameters through a ``**slots``
catch-all. ``get_params()`` now reads every constructor parameter under its
own name, so a name a ``**kwargs`` accepted without declaring cannot travel
in an artifact: the parameters are declared and passed through, and the
docstring says why they are named.
* ``mixins/collation.py`` read ``expr.collation_options`` -- a catch-all bag the
core node no longer has. ``CollateFunction`` declares ``collation`` (a string
or an enum member) and ``schema``, so the mixin reads those two and unwraps a
member through its value; the schema-qualified refusal that used to be one
key of the bag stays a refusal.
* ``SQLServerCollation`` becomes a ``(str, Enum)`` with ``__str__ = str.__str__``.
A member rides inside ``CollateFunction``'s params, and a param that travels
in a plain-JSON artifact must be a scalar: a plain ``Enum`` is the one thing
the serializer names and refuses. MySQL and ClickHouse spell their enums the
same way.
* ``SQLServerTypeNullability``'s registration is gone with the enum
sub-registry: the class normalises the declaration to a bool at construction,
so no member ever reaches an artifact, and ``register_enum`` itself no longer
exists in core.
* The action renames ("name an action by what it is"): ``AlterColumn`` ->
``AlterColumnAction`` in ``expression/alter_column.py`` and the
``examples/schema_diff/add_column.py`` that constructs it, and the
``AddColumn`` / ``AddTableConstraint`` / ``AlterConstraint`` /
``DropTableConstraint`` / ``ValidateConstraint`` call sites in the tests that
imported the retired spellings.
* ``CollateFunction`` no longer takes a ``keyword`` option (the options bag is
gone), so ``DATABASE_DEFAULT`` is passed as the plain collation name it
always was, and the keyword-refusal case becomes the name-refusal case the
whitelist gives: one validator, one message.
* ``SetTransactionExpression.isolation_level`` was renamed
``with_isolation_level``; the transaction test follows.
* Value nodes no longer carry an operation surface, so a collation off a
``Literal`` goes through the module-level ``collate`` factory, and the
type-definition CHECK builds its condition as the ``ComparisonPredicate``
node with a ``ComparisonOperator`` member rather than as an operator between
two values.
Verified: test_collation_expression.py, test_alter_table_if_exists_backend.py,
test_sqlserver_constraint_capabilities.py, test_dialect_comprehensive.py,
test_table_type_definition_roundtrip.py -- 5 files, 410 passed, 5 skipped (the
skipped ones need a reachable SQL Server, which this repository has none of;
the literal round-trip class is offline-pinned in the next wave's matrix).
The lineage audit (core's ``scripts/expression_lineage.py``, run against a
temporary copy that registers this backend's package and dialect class -- the
script reads the running code, and adding a backend to it is a core-repo change,
reported rather than made here) settles two facts:
* **SQL Server overrides no kind core ships**: 0 overrides, 58 customs. That is
the healthy shape -- a kind pointing at two classes is the one defect the
audit exists to find, because deserialization could not then decide which
class a name means. Every SQL Server kind is a name core has never issued, and
every one of them starts with the backend's name, as the rule requires.
* The **custom kinds spelled the backend's name as two words**:
``sql_server_bit_type``, ``sql_server_table``, ... The generic
consecutive-capital rule reads ``SQLServer`` as ``SQL`` + ``Server``, which is
the same defect SQLite had (``sq_lite_blob_type``) and MySQL and ClickHouse
cleaned out (``my_sql_*``, ``click_house_*``). The fix is the same *data* fix
they used: ``KIND_RESERVED = ("SQLServer",)`` on every node in this package.
No class overrides ``kind()``, so one naming rule holds for the whole backend
and the kinds now read ``sqlserver_bit_type``, ``sqlserver_table``,
``sqlserver_partition_by_range_clause`` -- parallel to core's
``sqlite_integer_type`` / ``mysql_big_int_type`` form, with the ``_type``
suffix kept on both sides of the core/backend seam.
Three of the backend's own enums ride inside those nodes' parameters, and an
enum parameter is the other thing the audit caught: ``SQLServerGraphDirection``,
``SQLServerGraphTableKind`` and ``SQLServerPartitionRangeDirection`` were plain
``Enum`` members, and a plain enum is the one thing a plain-JSON artifact cannot
carry -- the serializer names the type and stops. Each becomes a ``(str, Enum)``
with ``__str__ = str.__str__`` (the spelling both MySQL and ClickHouse use), and
each node's constructor normalises what it is handed: an artifact hands back the
word the enum's value is, the formatter reads a member, and one spelling lives
inside the node either way. The refusal for an unknown word names the closed set
instead of guessing.
Verified: the audit re-run now reports 0 overrides / 58 customs, all of them
``sqlserver_*``; the round-trip matrix, the graph and partition renderers and
the DDL type tests all pass against the new kinds (the matrix resolves every
class it covers through the kind whitelist in both directions).
…rtifact
Reconciled against core HEAD 550ae740. Three things had drifted, and each is
what the matrix is for: the shared harness it imported no longer exists, the
artifact stopped carrying markers, and the columns it classifies changed shape.
* **The harness is this repository's own now.** The testsuite dropped
``utils/expression`` in ``e05edb0`` with "round-trip coverage moves to the
owning repository", which left ``testsuite.utils.parse_contract`` -- and with
it the type-protocol sweep -- importing a deleted module. Both are vendored
beside this repo's own copy (``expression_roundtrip_harness.py``,
``expression_parse_contract.py``), with the drift fixed deliberately rather
than re-imported: the harness's ``assert_params_equal`` reads an enum member
through its *value* (a plain-JSON artifact carries the value, and a
constructor may hand back either the member or the word) and tolerates a
tuple that came back as an array.
* **The artifact is bare ``{kind, params}``.** The enum and dataclass
sub-registries the matrix filled existed only to resolve ``__enum__`` /
``__dataclass__`` markers; both are gone, so those registrations go with them
and the matrix says why. ``DdlLiteral`` is gone too: a DDL position passes
``inline=True`` to the one ``Literal`` class instead, and the two test files
that used the old spelling follow.
* **The columns are re-derived.** ``values.*`` left with the typed-value nodes
and ``Literal`` came in with the sixteen value kinds, so the matrix now pins
every kind's artifact shape as a flat JSON scalar -- including the two kinds
for NaN and +-Infinity, whose payloads travel as words precisely so they
never reach SQL text. The declared-result annotations, core's generic PIVOT /
UNPIVOT (which render here now), the ``format_edge_table`` seam, and the three
pad/repeat methods are re-decided class by class: renders, pinned non-render
with the exception type and message fragment, or unmodelled formatter with
the feature that is absent.
* **What the artifact cannot carry is named, not swallowed.** A node whose
parameter is a plain ``Enum`` or a dataclass cannot be written as JSON at all
-- the serializer names the type and stops -- and one channel can fail where
the other two succeed: the XML codec spells an enum member as ``Class.MEMBER``
and no reader turns that back into a member since the enum sub-registry went.
:data:`ARTIFACT_GAPS` records each such class by channel, type and message
fragment, and the matrix pins that table in both directions: a class that
starts travelling fails until its row goes, and a class that stops travelling
with no row fails the per-class assertion. The rows that are *this* backend's
fault (``SQLServerGraphDirection``, ``SQLServerGraphTableKind``,
``SQLServerPartitionRangeDirection``, ``SQLServerCollation``) were fixed in
the kind audit; the rest are core-side and are recorded with the channel so
the day core spells them, the rows go.
* Constructors replace guesses that cost a real assertion: a
``ComparisonPredicate`` whose operator is a member rather than a word the
constructor normalises, CHECK / generated constraints comparing two columns
(a DDL condition may not carry a bind parameter), a foreign key whose
referential actions are words, and the ``inline=True`` literal the DDL
position now wants.
The matrix runs 955 cases: 951 pass, 4 skip -- the skips are the two XML
spelling rows' per-channel assertions, which are pinned in ARTIFACT_GAPS rather
than passed.
The org test rules (expression-dialect §12) ask for three things of the expression area: no real connection in an expression test, parallel by default, and a directory the rules can name. This commit makes all three true, and the third one first -- the rules talk about "the expression test directory", which did not exist here until now. * ``feature/backend/expression/`` is that directory. It holds the round-trip matrix, the literal spellings test, the SQL Server expression-formatting tests, and the two harness modules the matrix and the type sweep load. The live round-trip class -- the one that creates a table, inserts a default row and reads it back -- leaves the literal test file and becomes ``feature/backend/test_literal_default_execution.py``, beside the scenario-driven integration tests, which is where a live connection belongs. Its docstring still says what was written and that no reachable server has confirmed it. * Two test files outside the area load the harness modules. They now load them by path -- the same technique the type-protocol file already used for the matrix -- so a move inside the tree does not turn a passing import into a NameError, and neither file depends on a sys.path entry the run did not promise. * ``tests/plugins/expression_xdist.py`` is the parallel default. xdist reads the worker count before any conftest loads, so a conftest cannot inject ``-n``; the plugin is registered from ``pytest.ini`` with ``-p`` and appends ``-n auto --dist=loadgroup`` only when every argument names a path inside the expression area. A whole-repository run, a mixed run, and any other directory keep the serial default; an explicit ``-n`` is never overridden, and ``--dist=loadscope/loadfile`` are refused outright because they bind whole modules together and hide case-to-case dependencies. The plugin enables nothing at all when xdist is not installed. * ``pytest.ini`` also sets ``pythonpath = tests``. That is load-bearing, not decoration: a ``-p`` plugin is imported *by name*, and pytest applies ``pythonpath`` immediately before it imports those plugins -- without it the registration only works for an invocation that already put ``tests`` on ``PYTHONPATH`` (as CI does, and as a local run often does not). With it a bare ``pytest`` works too. ``pytest-xdist`` joins the dev dependencies, since the plugin's narrowness includes declining to install itself. Verified: the plugin's scoping was checked case by case -- expression directory and single file inside it get ``-n auto --dist=loadgroup``; explicit ``-n 4`` keeps its count and gains ``loadgroup``; explicit ``loadscope`` is refused; the parent directory and a mixed run are left alone. End to end, the matrix now runs under the plugin as `created: 16/16 workers`, `16 workers [955 items]`, 951 passed and 4 pinned skips -- the same verdicts as the serial run, so the parallel default changed nothing about what is asserted.
The harness moved into the expression area with the tests that use it, and two of its callers load the *test module* by path rather than letting pytest collect it: the type-protocol sweep imports the matrix to read its registry, and the matrix and the parse contract import their sibling harness. A by-name import only resolves when the sibling's directory happens to be on ``sys.path`` -- true for a collected module, false for one loaded by path -- so the move turned a passing import into ``ModuleNotFoundError`` on that path. Loading the sibling by path is what the type-protocol file already did for the matrix; the same technique now covers the harness, from both the matrix and the parse contract, and each module keeps one identity no matter which caller reaches it first. Verified: the type-protocol file and the table-type round-trip file -- 221 passed, 3 skipped (the sweep covers the whole type registry through the vendored parse contract); the matrix -- 951 passed, 4 pinned skips.
…its live half The previous commit cut this file at the first decorative rule it found instead of at the live section's own header, which took the offline assertions with it. Restored from the version the matrix commit holds, and the split is now made at the section that actually moved: the file keeps the data-position bindings, the inline spellings and the interval refusal, its docstring points at the live half's new home instead of describing cases it no longer contains, and the ``execution_testing`` import those cases used goes with them. Verified: 12 offline cases pass (the wiring pin, the five data-position bindings, the six inline spellings), and the live file collects its two scenario-parameterised cases beside the integration tests.
The FOR UPDATE refusal read ``clause.strength.value`` -- the artifact word -- so the message said "does not support no_key_update", which is a serialization spelling nobody reads SQL in. Core split the word from the SQL text when it made the comparison operator an enum (the artifact carries the word, the phrase carries what the clause says), and the same split applies here: the phrase is what a refusal names. The UPDATE branch is unaffected -- SQL Server spells it as table hints and never prints the strength. Verified: each strength's message now reads the phrase (FOR NO KEY UPDATE, FOR SHARE, FOR KEY SHARE, LOCK IN SHARE MODE) and the UPDATE branch still renders ``WITH (UPDLOCK, ROWLOCK)``; the dialect test files that exercise the clause pass.
The live leg of the literal spellings ran for the first time in CI, on nine legs, and each leg's only failure was this one assertion: the server hands a ``UNIQUEIDENTIFIER`` back in its own canonical spelling, which is upper-case hex, and comparing it as text against a ``uuid.UUID`` -- whose ``str`` is the lower-case form -- failed on the casing rather than on the value. The answer is parsed instead of compared as text, so what travels is the value. Everything else in the run is green: the round-trip matrix, the value-kind table, the kind registry and the testsuite integration pass on all nine legs.
The plain-JSON reconciliation left six names behind in the matrix: the kind audit turned the partition enums into words and the artifact stops naming classes at all, so `Tuple`/`Type` from typing, `Function` from the catalogue objects and the three sibling partition node classes have no reader left in the file. Ruff flags them (F401 twice, F811 once for `Type` shadowed by the objects import, F401 three times in the partition constructor), and the lint gate is part of the finishing steps. Verified: the matrix plus the table-type round-trip file re-run green after the removal (951 passed, 4 pinned skips in the matrix; 8 passed in the table-type file), and ruff on the expression area, the plugin, the live execution file and the two harness callers is 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 catalogue question this backend asked was spelled as text: nine
``SELECT`` statements inline in ``introspection/introspector.py``, twenty in
``introspection/status_introspector.py`` (ten shapes, twice over for the sync
and async halves), and the probes in ``backend/backend.py``,
``backend/async_backend.py``, ``mixins/backend_mixin.py`` and
``mixins/concurrency.py``. A SQL string is a formatting artefact, so it
belongs to the dialect formatter, not to the layer that executes the query
(org guide 16.2): the node collects the parameters, the formatter writes the
text, and the caller only constructs and executes.
New ``query_nodes.py`` states each construction once -- a column reference, a
value, a catalogue relation, a conjunction, a projection into a SELECT -- and
both families of caller use it: ``SQLServerNodeBuilderMixin`` for the
introspectors, which reach their dialect through the core introspector mixin,
and the module-level functions for the backend classes, which do not share a
base with them. All twelve schema builders and all eight status queries are
now ``QueryStatement`` trees over ``INFORMATION_SCHEMA`` / ``sys`` relations,
joined with ``JoinClause`` and filtered with ``ComparisonPredicate``,
``InPredicate``, ``IsNullPredicate`` and ``LogicalPredicate``.
Baseline diff (twelve ``_build_*_sql`` shapes, captured before and after):
- projection order, aliases and parameter tuples are identical in every
one of the twelve; the only differences are the dialect's spelling
(``[TABLE_NAME]`` for ``TABLE_NAME``, ``[INFORMATION_SCHEMA].[TABLES]``
for ``INFORMATION_SCHEMA.TABLES``) and the parenthesisation the join
node adds around a left-deep chain, which is the same tree.
- ``COLUMNPROPERTY(OBJECT_ID(...))`` keeps its 3-argument shape; the
catalog's one-string object name is an arithmetic ``add`` because SQL
Server spells concatenation with ``+``.
Values kept where the text kept them: ``'BASE TABLE'``, ``'PRIMARY KEY'``,
``'IsIdentity'``, ``'dbo'``, ``'max worker threads'``, ``'user connections'``,
``'sysadmin'`` and the ``IN ('S','U','G')`` list stay inline literals, so
callers keep the parameter tuples they already had; schema and table names
stay bound.
One behavioural point, stated rather than hidden: the performance-counter
query capped its rows with ``SELECT TOP 50``, which has no node -- TOP lives
in the SELECT head and this dialect spells it ``format_top_n_clause``, not a
clause the query statement carries. The cap is now the pagination node
(``OFFSET 0 ROWS FETCH NEXT 50 ROWS ONLY``), which returns the same rows in
the same order but needs 2012+. The limit is therefore chosen against the
dialect version: ``None`` before 2012, where the old statement worked and the
new spelling would raise and be swallowed into an empty report. Both shapes
are the dialect's; the choice between them is this backend's.
The SHOW module builds no SQL and never did: it is the inbound half of the
MySQL vocabulary -- MySQL's row names translated into the typed results the
shared ``show()`` API returns. It keeps that translation and its docstrings
now say so, because the deliverable there is a row shape, not a statement.
Offline evidence: ``test_introspection_sql_builders.py`` re-asserts the
parameterization against the rendered statement (26 shape/parse tests) and
gains ten status-query tests covering each projection, the pagination cap and
its 2012 gate, and the niladic ``SYSTEM_USER``. All 36 pass. Live-database
legs are not runnable here (no local SQL Server): they go to CI.
SQL Server has no CREATE TABLE ... AS statement. Its population spelling is SELECT ... INTO <table>, which is a different statement shape, so the portable one cannot be rendered in a form the server accepts. The dialect inherited supports_create_table_as() from core, where it answers True, so the framework rendered the portable spelling and the server refused it -- "Incorrect syntax near the keyword 'SELECT'", with the statement rejected whole. Measured on SQL Server 2022 (port 11434): CREATE TABLE [t] AS SELECT 1 AS c rejected CREATE TABLE [t] AS SELECT 1 AS c WITH DATA rejected SELECT 1 AS c INTO [t] accepted The probe is now declared here and answers False, so the request is refused by the statement name instead of rendered in a form the server rejects. The guard file's sentinel -- "a genuinely inherited probe must not pass the check" -- used supports_create_table_as as its example and said this backend "renders SQL Server CTAS through SELECT INTO instead". It does not: no SELECT INTO renderer exists here, so the portable form is what was being rendered. The sentinel now uses supports_create_table_clone, which is still inherited, so the check stays able to fail. The two CTAS entries in the load-bearing walk are gone with it: with CTAS refused as a statement, the WITH [NO] DATA gate has no consumer for this dialect, exactly as materialized view create and refresh already had none. A clause-level refusal for a statement that does not exist would be a fiction. The measurement stays declared and is pinned by its own case, so it cannot drift.
The registry says a row goes "the day core spells it as the value", and that day has come: core spells an enum parameter as its own value on every channel now, so the XML codec no longer carries ``Class.MEMBER`` where the constructor wants the word. Eleven rows were pinned on the XML channel for exactly that -- the EXTRACT family, the interval arithmetic and the two XML document-type expressions -- and every one of them was reporting that the channel had started travelling: ARTIFACT_GAPS pins ValueError on 'xml' but the xml channel carried the node -- the row is stale Twelve cases were failing on this branch before this change: the eleven rows above and the integrity check that the registry matches reality. The rows are gone, so the classes assert normally on all three channels. Nothing here is about SQL Server's SQL. It is the registry catching up with a core-side change, which is what the file's own rule asks for -- "a class that starts travelling fails until its row goes". The comment above the registry is rewritten to say what happened rather than describe a gap that no longer exists: the XML channel used to spell an enum parameter as ``Class.MEMBER`` while the dict and JSON channels spelled it as the value, and the rows recorded that until core closed it.
The introspect subcommand asked get_tables/get_views/get_columns (and the rest of the get_* family), which exist nowhere in the backend: core spells those questions list_tables/list_views/list_columns. Every advertised type therefore failed against a live server while the offline fakes answered the misspelled names. The type-to-method mapping now lives in _steps alone, and is guarded twice: the offline dispatch test checks every type against the real introspector classes, and a live test runs the command against SQL Server. table and database <name> render through get_table_info, whose one query already fills columns, indexes and foreign keys; triggers no longer iterate every table swallowing each one's error, since list_triggers takes an optional table. Alongside the fix, the catalogue questions core has no shape for get their own methods on both managers with identical signatures: list_schemas, list_extended_properties (MS_Description and friends, sql_variant value cast to text), list_procedures/list_functions (one builder parameterized by the sys.objects type codes) and list_sequences (sql_variant bounds cast to text and parsed back to exact Decimal). Every query is built from expression nodes and rendered by the dialect -- no string SQL -- and each family names itself in its cache key so two families never answer each other.
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.
A capability switch that says False for a backend which fully introspects is a false negative, and this backend produced eight of them: it never declared the shared introspection switches, so they resolved to core's default of False. `supports_introspection()` returning False while the CLI offers twelve introspection types is not a cosmetic mismatch -- a caller gating on the switches would have concluded SQL Server has no introspection at all. The default is conservative on purpose and stays: a backend that cannot answer says so, and that is not an error. What was wrong is a backend that can answer but says it cannot, which is the direction that silently removes a working capability from whoever asks. Declared one per family, each answering what SQL Server actually reads: catalog views for tables and columns, sys.indexes with sys.index_columns for the ordering, OBJECT_DEFINITION for routine and trigger bodies, sys.views for views with their definitions, sys.foreign_keys for constraints. Placed in a mixin of its own and registered alongside the other backend answers, before the core bundle each replaces -- the ordering the dialect file already documents, because a mixin placed after the class it overrides never wins. Tests: the introspection test files pass.
`supports_generator_introspection` on the sequence mixin, beside the statements that create them; `supports_domain_introspection` on the introspection mixin, since a domain's descriptor is the same catalogue row a column's type reads.
The CLI called `available_introspect_types(SQLServerDialect(), INTROSPECT_TYPES)` to have core trim the list. That module is gone -- core deleted `backend/cli/introspect_capabilities.py` because a shared name table makes core the arbiter of a vocabulary it does not own, and cannot express a per-backend difference (structurally absent, version-gated, differently scoped) anyway. The parser is built before any connection exists, so it cannot read the version either. The candidate list is therefore what it always was: this backend's hand-written `INTROSPECT_TYPES`, twelve entries, each one a family SQL Server really answers. `source` now imports again. `--schema` goes with it. A run names one database and the connection fixes the scope; SQL Server's default schema is `dbo`, and a login that changes it changes it for the connection. Asking a second time asked the user to restate what the connection already decided. Every call site passes `schema=None`, which the introspector already accepted as "resolve it the way the connection does". The flag was removed rather than defaulted-and-hidden, so a stale command line fails at parse time instead of quietly reading a different scope. Also: `supports_procedure_introspection` was defined twice, once in the introspection mixin and once in the routine mixin, from two separate edits. MRO picked the routine one silently -- correct by luck, not by construction. It stays in `routine.py` beside `supports_function_introspection` and the routine DDL switches, because the same catalogue row answers whether this engine has procedures at all; `introspection.py` keeps the eight standard switches and the schema switch, which is the one place "what can I ask" is read. Tests: the parser test that asserted the flag's default and override now asserts it is refused; the dispatch tests assert `schema=None` reaches the introspector.
Inserting supports_schema_introspection and supports_sequence_introspection right after their neighbours' docstrings pushed the neighbours' returns into the new bodies, so supports_catalog and supports_sequence_as_data_type fell through to None. None reads as false, which made supports_catalog lie about the catalog level SQL Server has and left both switches untrustworthy.
…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. SQL Server side: twelve row parsers -- schemas, extended properties, routines and sequences among them -- plus default_schema and its twelve call sites. The SQL-builder test that calls the parsers directly follows.
The introspector still spelled eight query families itself -- table and view
lists, columns, indexes, foreign keys, view info, triggers, database info --
while the statement classes that name them already existed in core. MySQL,
MariaDB and PostgreSQL had each moved those renderings down into the dialect;
SQL Server was the last backend where the question and its spelling lived in
the same method of the introspector.
Each ``format_*_query_statement`` now sits in ``SQLServerIntrospectionMixin``
beside the ``supports_*`` answers it belongs to, and the introspector builds
the statement and executes what comes back. The parameter positions are the
ones the concatenated form had: a caller-supplied schema or table name stays
bound, and a value that is part of the question ('BASE TABLE', 'PRIMARY KEY',
'IsIdentity') stays an inline literal, so every caller keeps the parameter
tuple it already had. That is what ``test_introspection_sql_builders`` pins --
it asserts both halves, so a rendering that quietly changes either one fails
there rather than at the first live server.
``SQLServerNodeBuilderMixin`` now binds its nodes to ``_node_dialect``, which
defaults to ``self.dialect`` for the introspector and is overridden to
``self`` where the dialect itself is the object building the tree. Without
that seam the mixin would hand every node the introspector, whose ``format_*``
methods belong to a different family.
``TableInfoQueryStatement`` and ``TriggerInfoQueryStatement`` have no caller
that renders them: the table-detail path assembles its answer from the column,
index and foreign-key statements, and triggers reach the server through a
family core does not model. They join MariaDB's precedent in
``LEGITIMATE_NON_RENDERS`` rather than being given a formatter nothing calls.
``DEFAULT_SCHEMA`` moves into the mixin module so the introspector's
``default_schema`` and the statements cannot disagree about which schema an
unqualified name resolves in.
Two tests carried assumptions this change makes false: ``test_cli`` asserted a
positional object filter the CLI no longer takes, and the sequence probe
list did not know ``supports_sequence_introspection`` is a capability rather
than an option to probe.
"table" answered several catalogue questions at once -- the relation's row, its columns, its indexes and its foreign keys. This backend builds that answer in two places: ``_steps`` mapped the type to ``get_table_info`` (and let ``database <name>`` reach the same method), and ``_value_sections`` expanded the returned object into three sections. Both go with the type, and so does the alias: ``database`` answers the database's own facts and no longer reads a name at all. The types that remain each map to one introspector method, which is what the existing "every type maps to a real method" guard asserts -- it now covers a list without the aggregate.
Two of them still did: the offline dispatch test expected the recorded call that ``database <name>`` used to make, and the live test asserted the three-document rendering of ``table``. The first now checks the payload (a name changes nothing), and the second goes with the type; the helper that split three documents out of one stream was only there for it.
Deleting the document-splitting helper also deleted the ``@pytest.fixture`` decorator that preceded the probe fixture, so every live test errored at setup with "fixture 'probed_backend' not found" -- and the import of pytest went with it as unused. Both are back; the helper it replaced was the only user of the three-document shape.
`INFORMATION_SCHEMA.TABLES` spells the kind as "BASE TABLE" or "VIEW" and omits every `is_ms_shipped` row, so a system table could not be told from a user table and the CLI's `--include-system` flag changed nothing: the parser had one distinction to make and the question never carried it. The table list now reads `sys.objects` joined to `sys.schemas`, projects `type` (`U` user table, `V` view, `S` system table) and honours `include_system` by admitting `S` and dropping the `is_ms_shipped` filter. `parse_tables` maps the code onto `TableType`, so the CLI's filter and its output now agree. The database report takes its version from `server_version` (never raises, reads the adapted dialect or the backend's cache) instead of refusing to describe a database nothing had adapted yet, and spells an unknown version as "unknown" with `(0, 0, 0)`. The statement answers no size or table count, so those stay None rather than becoming a second query. Capability predicates now ask `version_at_least(...)` instead of comparing `self.version`, which raised before adaptation and told the caller nothing it could act on; the renderers that raise `UnsupportedFeatureError` keep the strict property, because a formatter gating SQL must know the version. The introspect CLI adapts right after connecting, before the first question.
The shim already imports the shared tests, so it followed the contract change (testsuite 3289103) by itself; only its docstring still promised a UserWarning.
…eeps it `format_view_list_query_statement` projected `TABLE_NAME` alone and `parse_views` therefore wrote `definition=None` on every row, so `introspect views` answered with a null definition for every view while the CLI README promised "views, with their definitions". The single-view question right below it had always projected `VIEW_DEFINITION` and `IS_UPDATABLE`, and nothing called it: the type accepted a NAME and ignored it. The list now projects the same two columns, and `views NAME` is wired to `get_view_info` -- one named view is a different question from the list, not a filter over it. Both read `INFORMATION_SCHEMA.VIEWS`, so the two answers cannot disagree; the formatter documents that the column is NULL when the caller may not read the view's text, which is where it differs from `OBJECT_DEFINITION()`. A name that names no view leaves with 1 rather than printing an empty list.
`format_trigger_list_query_statement` selected `is_disabled` and `parse_triggers` never read it, so every trigger reported the dataclass default `status = "ENABLED"` -- including the disabled ones. Its `events` were `[]` for the same kind of reason: SQL Server keeps the firing events one table over, in `sys.trigger_events`, and nothing joined them. The list now left-joins `sys.trigger_events` on the trigger's `object_id` and projects `type_desc` as `event`, ordered by trigger name and then event so a trigger's rows are contiguous. `parse_triggers` folds those rows back into one trigger per name, collecting the events and reading `status` from `is_disabled`. The join is a left join because a trigger row without an event of its own is still a trigger: dropping it would turn an empty event list into a missing trigger. A `NULL` event from that join is the absence of an event, not an event, so it does not enter the list.
`sys.extended_properties` is the only place SQL Server keeps an object's comment, and nothing asked it: `list_extended_properties` took an `object_id` that the CLI had no way to obtain from a name. The family therefore had no entry point at all, and every comment in every other output was null. The statement now names its object one of two ways, and exactly one of them: `major_id`, as before, or `table_name`, which the dialect resolves through `sys.tables`/`sys.schemas` in the same query -- one round trip, not "find the id, then read". Both selections share one projection, so they cannot drift. A table name only names `OBJECT_OR_COLUMN` properties; any other class is refused rather than answered with no rows, matching how the formatter already refuses a schema on a family that has none. `supports_extended_property_introspection()` declares the family, beside the other capability switches rather than folded into the table one: "can this server list tables" and "can it read the properties hanging off them" are two questions. The CLI type `extended-properties NAME` requires the table name, like `columns`/`indexes`/`foreign-keys`. An empty answer is checked against the catalogue before it is reported, so a table with no properties prints nothing and a name that names nothing leaves with 1 -- the two used to be indistinguishable from the query alone.
…ey live SQL Server has no COMMENT clause: a comment is an `MS_Description` row in `sys.extended_properties`. `TableInfo.comment` and `ColumnInfo.comment` were therefore permanently None, because neither read joined that catalogue -- declared fields no backend could fill. The table list now left-joins the object-level property (`minor_id = 0`, so a column's description cannot be mistaken for the table's) and projects it as `table_comment`. The column query joins `sys.columns` to reach the object id and column id the property is keyed by -- `INFORMATION_SCHEMA.COLUMNS` carries neither -- and adds a second left join for the column-level row, projected as `column_comment`. Both are left joins: a table or column without a comment keeps its row, answering NULL rather than disappearing. Both statements keep their existing parameter tuples: the property name and class are part of the question, so they stay inline literals. `parse_tables` and `parse_columns` read the new aliases, and `get_table_info` inherits the table comment from `list_tables` without another query.
The advertised type list was a hand-written list with nothing behind it: a family could be added to the command and left undeclared on the dialect, or declared and never offered, and no test noticed. `_TYPE_CAPABILITIES` now maps each type to the dialect switch that answers for it, and `INTROSPECT_TYPES` is derived from that mapping, so the two cannot drift. The gate runs after `introspect_and_adapt()` -- the switch answers for the server in front of us, not for the newest release the backend knows -- and a family the server does not answer is refused with exit 2 and "Error: <type> introspection is not supported by this server", rather than run and answered with an empty list. `NAME` is now decided per type instead of being left to each branch. It is required for `columns`/`indexes`/`foreign-keys`/`extended-properties`, optional for `views` (a view name) and `triggers` (a table name), and refused for the rest: `tables users` exits 2 instead of listing every table, because a silently ignored name reads as a filter that matched everything. The refusal happens before a connection is opened -- whether a name belongs to the type is a question about the request, not the server. `get_supported_introspection_scopes()` is overridden: core's default is `[]`, which told a caller this backend introspects nothing while it answers eleven families. It now names the eight core scopes the catalogue covers, `SEQUENCE` included.
The type table still taught `table`, which was removed on 2026-10-27, and it had no column for `NAME` -- the one part of the interface that differs per type, and the part a reader has to guess. `views` claimed definitions the list did not carry (it does now), and `tables` named `sys.tables` where the query reads `sys.objects`. The table now lists the twelve types with a `NAME` column (required, optional or refused), the catalogue each reads, and what the row carries -- `extended-properties`, the trigger events and status, and the view definition among them. The prose below it spells out the three rules, that `views` takes a view name while `triggers` takes a table name, and which exit code each kind of refusal leaves with. The examples lose `table` and gain the types that replaced what it used to answer.
… skipped The round-trip matrix pins the set of expression classes the generic constructor cannot build, in both directions: a class that starts failing to build without being named fails the run rather than disappearing into a skip. The new extended-properties statement joined that set -- it names its object one of two ways, a catalogue id or a table name, and refuses a statement that names neither -- so the matrix went red on a class nobody had registered. Registered rather than pinned unconstructible, because it constructs fine once its own slot is filled: the table-name form is what a caller holding only a name uses, and it is the form the CLI asks. That also brings the statement into the round-trip coverage it would otherwise never have had.
`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.
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 Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## release/v1.0.0.dev3 #9 +/- ##
=======================================================
+ Coverage 74.57% 77.87% +3.29%
=======================================================
Files 139 144 +5
Lines 7783 8306 +523
Branches 1298 1289 -9
=======================================================
+ Hits 5804 6468 +664
+ Misses 1541 1408 -133
+ Partials 438 430 -8
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.




Description
Lands
fix/column-constraint-gatesonrelease/v1.0.0.dev3.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. 59 commits, 6 of them marked breaking.
Headline changes:
commentfields.introspect extended-properties TABLEasks by table name, and the table and column reads joinsys.extended_propertiessoMS_Descriptionis where the type says it belongs (feat(introspection): the extended properties, asked by table name).VIEW_DEFINITIONandIS_UPDATABLE, andviews NAMEanswers for one view (fix(introspection): a view's definition is read where the catalogue keeps it).eventscomes fromsys.trigger_eventsandstatusfromis_disabled, instead of[]and a constant (fix(introspection): a trigger says what fires it and whether it is on)._TYPE_CAPABILITIES(feat(introspection): a system table is not a base table).Type of change
feat: A new featurerefactor: Internal restructuring with no intended behaviour changetest: Adding missing tests or correcting existing testsdocs: Documentation onlyperf/chore/ci/build/revert(not relevant)Breaking Change
Impact on End-Users:
introspect extended-properties TABLEis new, andintrospect columns/tablesnow carry comments.introspect tableis gone;views NAMEanswers for one view with its definition.Impact on Backend Developers (Custom Implementations):
parse_*lost its leading underscores.column_info_statement()gainedinclude_hidden, defaulted to False.Related Repositories/Packages
python-activerecord57439841pytest_compatpython-activerecord-testsuite1991449python-activerecord-sqlserverd992c02Core is merged (
rhosocial/python-activerecord#127, squashed as57439841); this branch's CI now installs core fromrelease/v1.0.0.dev30. The test suite is still referenced by branch, because itspytest_compatshims are not on its release branch yet; that reference is repointed when the test suite merges.Testing
Test Plan: The full matrix ran green on this exact head (
d992c02) as run41092206630— every Python version and every service container the backend tests against. A local full run is skipped deliberately: the organisation caps a single task at 180 seconds, and the CI result on this SHA is the stronger evidence. The offline CLI tests (parser surface, NAME rules, capability gate) run in under a second and are part of that run.Checklist
README.md, the CLI guide).format_*).Additional Notes