Skip to content

Commit ce1db88

Browse files
authored
feat(sqlserver)!: the catalogue as statements, and the extended-property family (#9)
* fix(types): refuse a non-boolean unsigned when the type is rendered 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. * refactor(sqlserver): read declared fields directly instead of probing 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. * refactor(sqlserver): follow the statement and expression renames 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. * refactor(sqlserver): migrate constraints onto the two-level branch nodes 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. * test(sqlserver): pin the constraint switches to their measurements 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. * refactor(sqlserver): split the wildcard by grammar position 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). * test(sqlserver): carry the wildcard-split follow-ups into the roundtrip 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). * ci: point testsuite at the matching branch 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. * docs(sqlserver): carry the renames into the README and docs * refactor(expression): follow the core renames and the split registry 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. * fix(sqlserver): register the type nodes with the kind registry 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). * refactor(sqlserver): read declared fields instead of probing with getattr 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. * fix(sqlserver): register the type nodes with the read-path registry 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). * feat(sqlserver): adopt the typed value nodes with SQL Server literal 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. * fix(sqlserver): follow the classed function entry points and the bare 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). * refactor(sqlserver): follow the literal architecture the core settled 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. * refactor(sqlserver): read each node's own parameters and follow the renames 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). * refactor(sqlserver): audit the kinds this backend overrides and owns 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). * test(sqlserver): reconcile the roundtrip matrix with the plain-JSON artifact 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. * test(sqlserver): move live-connection checks out of expression tests 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. * test(sqlserver): load the round-trip harness by path, not by name 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. * test(sqlserver): keep the literal spellings file whole when it loses 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. * refactor(sqlserver): name a lock strength by the phrase SQL would say 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. * test(sqlserver): read a UNIQUEIDENTIFIER back as the value it is 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. * refactor(sqlserver): drop the imports the matrix no longer names 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. * fix(sqlserver): pass the LIKE operator as its enum, not the SQL keyword 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. * refactor(sqlserver): build introspection queries from nodes, not text 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. * chore: ignore .worktree/, the branch toolchain directories * fix(sqlserver): refuse the CTAS spelling SQL Server has no statement for 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. * test(sqlserver): drop the artifact-gap rows whose gap has closed 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. * fix(sqlserver): the dialect surface, and the type positions it decides The dialect's surface is two kinds of member: capability switches (``supports_*()``) and formatters (``format_*(self, expr)``). This change makes SQL Server's code match that, and settles what a type position holds while it is there. **A formatter owns its production.** Core's dialect mixins held six private renderers serving families of thin ``format_*(self, expr)`` wrappers. The wrappers were formatters; the bodies were not, and being private they could not be overridden. SQL Server's ``LEN`` now owns its production. **A type-name position holds a type.** The positions that carried a raw string guarded it with a character class in the base class, which neither matched what the engines accept nor kept the value inside its syntactic position. A position now holds a type node; a name the framework does not model becomes the framework's name node, whose name the grammar validates. The ALTER COLUMN type is claimed the same way, and no longer stringified. **Binding is the caller's call.** A DDL position that cannot bind says so through SQL Server's own capability switch, and a value the caller chose to inline is inlined. **The backend's own answers are the effective ones.** SQL Server'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``. * refactor(expression)!: follow core's statement rename 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. * refactor(ddl)!: the SQL Server create-table node is a statement ``SQLServerCreateTableExpression`` derives from ``CreateTableStatement`` and renders a whole statement; only its name still said expression. Nothing changes but the class name. * refactor(expression)!: name statement nodes, kinds and formatters alike The nodes that render a whole statement are now ``*Statement``, and the fragments have formatters ending in the ``_expression`` their class names carry: the JSON family, SET literal, FIND_IN_SET, the SET membership check, the spatial literal, PIVOT/UNPIVOT, NEXT VALUE FOR, AS GRAPH TABLE, SHORTEST PATH, and the partition function and scheme. FULLTEXT is one word in the SQL Server statement names it appears in, but it is two words in the identifiers here (``full_text``), and the class names have said ``FullText`` all along. The catalog and index formatters now spell it the same way. This also frees four core capability names. The backend's full-text formatters are string-taking helpers that had taken over the *core* node formatter names, so core's generic ``CreateFulltextIndexStatement`` and ``DropFulltextIndexStatement`` dispatched into signatures they could not satisfy and raised ``TypeError``. Renaming the helpers to their own spelling lets those nodes reach core's renderers, which is why the matrix's pins for them are gone. ``format_pivot`` / ``format_unpivot`` stay as they are: they are core's capabilities, and ``SQLServerPivotExpression`` reuses the shared renderer the way ``IntegerColumn`` reuses ``format_column``. * refactor(backend)!: call the core helpers by their public names 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. * fix(introspection): take the cache from the manager, not the mixin * fix(introspection): call the introspector by the names core defines The introspect subcommand asked get_tables/get_views/get_columns (and the rest of the …
1 parent 50a03ff commit ce1db88

174 files changed

Lines changed: 11511 additions & 3107 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/test.yml‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ jobs:
6060
- name: Install activerecord and testsuite from PyPI
6161
run: |
6262
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30"
63-
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
63+
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates"
6464
6565
- name: Create test database
6666
run: |
@@ -150,7 +150,7 @@ jobs:
150150
- name: Install activerecord and testsuite from PyPI
151151
run: |
152152
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30"
153-
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
153+
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates"
154154
155155
- name: Create test database
156156
run: |
@@ -240,7 +240,7 @@ jobs:
240240
- name: Install activerecord and testsuite from PyPI
241241
run: |
242242
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30"
243-
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
243+
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates"
244244
245245
- name: Create test database
246246
run: |
@@ -330,7 +330,7 @@ jobs:
330330
- name: Install activerecord and testsuite from PyPI
331331
run: |
332332
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30"
333-
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
333+
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates"
334334
335335
- name: Create test database
336336
run: |
@@ -420,7 +420,7 @@ jobs:
420420
- name: Install activerecord and testsuite from PyPI
421421
run: |
422422
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30"
423-
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
423+
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates"
424424
425425
- name: Create test database
426426
run: |
@@ -510,7 +510,7 @@ jobs:
510510
- name: Install activerecord and testsuite from PyPI
511511
run: |
512512
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30"
513-
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
513+
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates"
514514
515515
- name: Create test database
516516
run: |
@@ -601,7 +601,7 @@ jobs:
601601
- name: Install activerecord and testsuite from PyPI
602602
run: |
603603
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30"
604-
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
604+
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates"
605605
606606
- name: Create test database
607607
run: |
@@ -691,7 +691,7 @@ jobs:
691691
- name: Install activerecord and testsuite from PyPI
692692
run: |
693693
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30"
694-
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
694+
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates"
695695
696696
- name: Create test database
697697
run: |
@@ -790,7 +790,7 @@ jobs:
790790
- name: Install activerecord and testsuite from PyPI
791791
run: |
792792
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30"
793-
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
793+
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates"
794794
795795
- name: Create test database
796796
run: |

‎.gitignore‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,8 @@ __marimo__/
212212
.claude/tmp/
213213
tests/config/
214214
test_connection.py
215+
# Worktrees for feature/* and fix/* branches. Never tracked: a `git add -A`
216+
# from the repo root would commit each branch's whole toolchain -- the venv, the
217+
# build directory and the test artifacts -- and the directory disappears when the
218+
# branch merges, so the history would keep a second copy of the source tree.
219+
.worktree/

‎README.md‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,11 @@ from typing import ClassVar, Optional
4646
from rhosocial.activerecord.base import FieldProxy
4747
from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend
4848
from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig
49-
from rhosocial.activerecord.backend.expression import CreateTableExpression
49+
from rhosocial.activerecord.backend.expression import CreateTableStatement
5050
from rhosocial.activerecord.backend.expression.statements import (
5151
ColumnConstraint,
52-
ColumnConstraintType,
5352
ColumnDefinition,
53+
ColumnPrimaryKeyConstraint,
5454
)
5555
from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType
5656
from rhosocial.activerecord.model import ActiveRecord
@@ -79,14 +79,16 @@ User.configure(
7979
)
8080

8181
dialect = User.__backend__.dialect
82-
User.__backend__.execute(*CreateTableExpression(
82+
User.__backend__.execute(*CreateTableStatement(
8383
dialect,
8484
"users",
8585
columns=[
8686
ColumnDefinition(
8787
dialect, "id", IntegerType(dialect),
88-
constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY,
89-
is_auto_increment=True)],
88+
constraints=[ColumnConstraint(
89+
dialect,
90+
branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True),
91+
)],
9092
),
9193
ColumnDefinition(dialect, "name", VarCharType(dialect, 100)),
9294
ColumnDefinition(dialect, "email", VarCharType(dialect, 255)),
@@ -145,7 +147,7 @@ from rhosocial.activerecord.backend.impl.sqlserver.expression import (
145147
SQLServerCreateTableOptions,
146148
)
147149

148-
CreateTableExpression(
150+
CreateTableStatement(
149151
dialect,
150152
"sessions",
151153
columns=[...],

‎changelog.d/9.added.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
The extended-property family, asked by table name, plus table and column comments read from `MS_Description`; and `views NAME` on the introspect CLI, whose twelve types derive from `_TYPE_CAPABILITIES`.
2+
3+
Add the SQL Server catalogue questions core has no shape for, on both introspector managers with identical signatures: ``list_schemas`` (``sys.schemas`` with the owning database principal resolved by ``principal_id``, not name), ``list_extended_properties`` (``sys.extended_properties`` -- ``MS_Description`` and friends -- for one catalogue object, optionally one column, with the ``sql_variant`` value cast off the type pyodbc cannot fetch), ``list_procedures`` and ``list_functions`` (``sys.objects`` types ``P``/``PC`` and ``FN``/``IF``/``TF``/``FS``/``FT``, one builder parameterized by the code set), and ``list_sequences`` (``sys.sequences`` with every ``sql_variant`` bound cast to text and parsed back into exact ``Decimal`` values rather than floats that would round a wide sequence's bounds). Each question is built from expression nodes and rendered by the dialect, carries its family name into its cache key so two families never answer each other, and reports a schema-free family as unknown rather than returning nothing.

‎changelog.d/9.changed.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
A system table is not a base table; the backend-only catalogue questions are statement classes; and a `NAME` a type cannot use is an error (exit 2) instead of an ignored argument.

‎changelog.d/9.fixed.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
A view's definition is read from `INFORMATION_SCHEMA.VIEWS` rather than left null, and a trigger reports the events that fire it and whether it is disabled. Two returns displaced by inserted switches were restored, and the expression matrix registers the new statement instead of skipping it.
2+
3+
Fix the ``introspect`` subcommand calling introspector methods that exist nowhere in the backend. It asked ``get_tables``/``get_views``/``get_columns``/``get_indexes``/``get_foreign_keys``/``get_triggers``/``get_constraints``/``get_procedures``/``get_functions``/``get_sequences`` -- core spells those questions ``list_tables``/``list_views``/``list_columns`` and friends -- so every type failed against a live server while the old offline fakes answered the misspelled names happily. The type-to-method mapping now lives in one place (``_steps``) and is exercised twice: the offline dispatch test checks every advertised type against the real introspector classes with no fake in the way, and a live test runs the command itself against a SQL Server. ``table`` and ``database <name>`` now render through ``get_table_info``, whose single query already fills columns, indexes and foreign keys, replacing the three separate calls plus the iteration over every table that triggers used to need.

‎changelog.d/9.internal.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The catalogue questions moved to statement classes, the CLI tests follow the removed aggregate, and CI installs core from `release/v1.0.0.dev30` now that core #127 has merged.

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ dev = [
6262
"ruff>=0.1.0",
6363
"pre-commit>=3.0.0",
6464
"towncrier>=23.0.0",
65+
"pytest-xdist>=3.0.0",
6566
"rhosocial-activerecord-testsuite",
6667
]
6768
docs = [

‎pytest.ini‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,18 @@ testpaths = tests
55
python_files = test_*.py
66
python_classes = Test*
77
python_functions = test_*
8-
addopts = -v --tb=short -p rhosocial.activerecord.testsuite.conftest
8+
; pythonpath is what makes ``-p plugins.expression_xdist`` loadable: pytest
9+
; applies this ini value *before* it imports the -p plugins, and a plugin
10+
; imported by name has to be findable by name. It also means a plain ``pytest``
11+
; invocation works without PYTHONPATH=src:tests, which CI sets anyway.
12+
pythonpath = tests
13+
; plugins.expression_xdist is this repository's own: it makes the expression
14+
; area (feature/backend/expression) parallel by default, which the org test
15+
; rules ask for and a conftest cannot do because xdist reads the worker count
16+
; before any conftest loads. See tests/plugins/expression_xdist.py.
17+
addopts = -v --tb=short -p rhosocial.activerecord.testsuite.conftest -p plugins.expression_xdist
918
filterwarnings =
1019
ignore::DeprecationWarning
1120
markers =
1221
requires_sqlserver: mark test as requiring SQL Server connection
13-
slow: mark test as slow running
22+
slow: mark test as slow running

‎src/rhosocial/activerecord/backend/impl/sqlserver/alter_table_modifier.py‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,33 @@ def supports_add_column_if_not_exists(self) -> bool:
3030
return False
3131

3232
def supports_drop_column_if_exists(self) -> bool:
33-
"""``DROP COLUMN IF EXISTS`` is supported since SQL Server 2016."""
33+
"""``DROP COLUMN IF EXISTS`` is supported since SQL Server 2016.
34+
35+
The "since 2016" floor comes from the official ``ALTER TABLE``
36+
grammar page; the instance measured here is 2019 only
37+
(``tests/config/sqlserver_scenarios.yaml``: ``sqlserver_2019``,
38+
RTM-CU32-GDR 15.0.4465.1, Developer Edition).
39+
"""
3440
return True
3541

3642
def supports_drop_constraint_if_exists(self) -> bool:
37-
"""``DROP CONSTRAINT IF EXISTS`` is supported since SQL Server 2016."""
43+
"""``DROP CONSTRAINT IF EXISTS`` is supported since SQL Server 2016.
44+
45+
Measured on 2019 (RTM-CU32-GDR, 15.0.4465.1)::
46+
47+
ALTER TABLE dbo.probe_t DROP CONSTRAINT IF EXISTS does_not_exist
48+
-> accepted, silent no-op (no result set, no error)
49+
50+
so ``IF EXISTS`` is what makes dropping a constraint idempotent on
51+
this backend — the plain form is *not* a no-op, it raises
52+
``'does_not_exist' is not a constraint. (3728)`` followed by
53+
``Could not drop constraint. See previous errors. (3727)``.
54+
Catching that error pair instead of spelling ``IF EXISTS`` would also
55+
break on MySQL, which has no ``DROP CONSTRAINT IF EXISTS`` at all.
56+
57+
The "since 2016" floor is from the official ``ALTER TABLE`` grammar
58+
page; no pre-2019 instance was measured here.
59+
"""
3860
return True
3961

4062
def format_add_column_action(self, action: Any) -> Tuple[str, tuple]:
@@ -112,10 +134,13 @@ def format_alter_column_type_action(self, action: Any) -> Tuple[str, tuple]:
112134
if data_type is None:
113135
raise ValueError("data type is required for ALTER COLUMN")
114136

137+
from rhosocial.activerecord.backend.expression.types import DataType
138+
139+
type_sql, _ = DataType.as_type(self, data_type).to_sql()
115140
parts = [
116141
"ALTER COLUMN",
117142
self.format_identifier(action.column_name),
118-
str(data_type),
143+
type_sql,
119144
]
120145

121146
collate = getattr(action, "collate", None)

0 commit comments

Comments
 (0)