From e6d44bd0416a09430dbbd90aefd4b4ba718eeefe Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 6 Oct 2026 17:30:12 +0800 Subject: [PATCH 01/10] refactor(sqlserver): carry catalogue objects and check the kind that names them Core's expression and dialect layers were reworked: a relation is split into the object it names and the source that reads it, the namespace shape became a protocol the dialect states once, and to_sql() now inspects what answers a format lookup. Schema objects are rendered as objects rather than as parsed names, and the object kind is checked where the name is spelled. Thirty-two dispatch-failure pins move from AttributeError to UnsupportedFeatureError. Their shared fragment moved with them, and the reason matters: a dialect that knows a statement and refuses it also raises UnsupportedFeatureError in the same message shape, and only the dispatch's suggestion offers a mixin. With the type no longer separating the two causes, the fragment does. Four shadowed formatters did not move. CreateFulltextIndexExpression, DropFunctionExpression, CreateFunctionExpression and DropTriggerExpression still raise exactly what they raised before, because the dispatch reaches them the same way: the cause is a signature colliding with core's, not a missing formatter, and the pins still record it. Of 303 distinct format_* names, none resolves to a Protocol and no non-protocol class aliases a protocol stub. assert_sql_roundtrip_classified consulted its pinned tables after catching UnsupportedFeatureError, so the retyped entries were unreachable on the classification path; the tables come first now. Four XML classes were pinned as unconstructible. Each wants a Sequence of its own item type, and testsuite's constructor read that alias' name as the Sequence catalogue object rather than as a list. They build now, and refuse at dispatch naming the formatter this dialect does not compose. --- .claude/backend_development.md | 63 +- .github/workflows/test.yml | 36 +- .../impl/sqlserver/backend/async_backend.py | 3 +- .../backend/impl/sqlserver/cli/info.py | 22 +- .../backend/impl/sqlserver/dialect.py | 325 +++-- .../examples/connection/quickstart.py | 22 +- .../sqlserver/examples/ddl/create_index.py | 9 +- .../sqlserver/examples/ddl/create_table.py | 5 +- .../examples/functions/json_example.py | 5 +- .../examples/functions/string_example.py | 7 +- .../impl/sqlserver/examples/insert/batch.py | 11 +- .../sqlserver/examples/insert/with_output.py | 8 +- .../named_expressions/order_expressions.py | 18 +- .../impl/sqlserver/examples/query/basic.py | 10 +- .../impl/sqlserver/examples/query/join.py | 21 +- .../examples/schema_diff/add_column.py | 9 +- .../sqlserver/examples/transaction/basic.py | 13 +- .../sqlserver/examples/types/json_basic.py | 11 +- .../impl/sqlserver/expression/__init__.py | 4 + .../impl/sqlserver/expression/ddl/trigger.py | 7 + .../impl/sqlserver/expression/ddl/type.py | 19 +- .../impl/sqlserver/expression/graph.py | 20 +- .../impl/sqlserver/expression/objects.py | 98 ++ .../impl/sqlserver/expression/temporal.py | 47 +- .../backend/impl/sqlserver/mixins/__init__.py | 2 + .../impl/sqlserver/mixins/backend_mixin.py | 3 +- .../impl/sqlserver/mixins/columnstore.py | 21 +- .../impl/sqlserver/mixins/ddl_database.py | 49 +- .../backend/impl/sqlserver/mixins/ddl_type.py | 135 +- .../backend/impl/sqlserver/mixins/ddl_view.py | 45 +- .../backend/impl/sqlserver/mixins/dml.py | 38 +- .../backend/impl/sqlserver/mixins/graph.py | 23 +- .../backend/impl/sqlserver/mixins/lateral.py | 44 +- .../impl/sqlserver/mixins/memory_optimized.py | 4 +- .../impl/sqlserver/mixins/namespace.py | 347 +++++ .../impl/sqlserver/mixins/partition.py | 76 +- .../impl/sqlserver/mixins/protocol_support.py | 41 +- .../backend/impl/sqlserver/mixins/routine.py | 46 +- .../backend/impl/sqlserver/mixins/schema.py | 28 +- .../backend/impl/sqlserver/mixins/sequence.py | 13 +- .../backend/impl/sqlserver/mixins/trigger.py | 22 +- .../backend/impl/sqlserver/protocols/table.py | 4 +- .../backend/impl/sqlserver/protocols/type.py | 51 +- tests/providers/basic.py | 6 +- tests/providers/events.py | 6 +- tests/providers/fixtures/_common.py | 8 +- tests/providers/fixtures/basic.py | 60 +- tests/providers/fixtures/events.py | 5 +- tests/providers/fixtures/mixins.py | 9 +- tests/providers/fixtures/query.py | 97 +- tests/providers/mixins.py | 6 +- tests/providers/query.py | 15 +- .../dialect/test_sqlserver_sql_graph.py | 9 +- .../backend/test_auto_increment_ddl.py | 7 +- .../feature/backend/test_backend_explain.py | 10 +- .../feature/backend/test_create_table_like.py | 25 +- .../feature/backend/test_dialect.py | 22 +- .../backend/test_dialect_comprehensive.py | 143 +- .../backend/test_dialect_version_branches.py | 84 +- .../backend/test_expression_roundtrip_all.py | 1238 ++++++++++++++++- .../feature/backend/test_expressions.py | 61 +- .../feature/backend/test_niladic_functions.py | 19 +- .../backend/test_p1_coverage_features.py | 8 +- .../backend/test_p2_coverage_features.py | 17 +- .../feature/backend/test_protocol_support.py | 19 +- .../feature/backend/test_schema_support.py | 125 +- .../backend/test_sqlserver_database_ddl.py | 7 +- .../test_sqlserver_ddl_improvements.py | 12 +- .../backend/test_sqlserver_partition.py | 7 +- .../test_sqlserver_protocol_conformance.py | 101 +- .../backend/test_sqlserver_type_ddl.py | 89 +- 71 files changed, 3303 insertions(+), 697 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/expression/objects.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins/namespace.py diff --git a/.claude/backend_development.md b/.claude/backend_development.md index 9eb5e35..1bd4963 100644 --- a/.claude/backend_development.md +++ b/.claude/backend_development.md @@ -269,35 +269,72 @@ Every dialect inherits from: ##### Current Protocols and Mixins (Main Package) +Names below are the ones the core package actually defines, checked against +`backend/dialect/protocols/` and `backend/dialect/mixins/`. A few of the +protocols have no mixin of their own — their formatter lives in a broader one, +and the column says which. `NamespaceSupport` is the naming protocol, not a DDL +one: it answers whether a name may be *qualified* with a catalog or a schema, +which is a different question from whether the engine has those objects at all +(`CreateSchemaSupport` / `CreateDatabaseSupport`). + | Protocol | Mixin | Description | |----------|-------|-------------| | `WindowFunctionSupport` | `WindowFunctionMixin` | Window functions (OVER, PARTITION BY) | | `CTESupport` | `CTEMixin` | Common Table Expressions (WITH clause) | -| `AdvancedGroupingSupport` | `AdvancedGroupingMixin` | ROLLUP, CUBE, GROUPING SETS | -| `ReturningSupport` | `ReturningMixin` | RETURNING clause | +| `AdvancedGroupingSupport` | `DQLMixin` | ROLLUP, CUBE, GROUPING SETS (`format_grouping_clause`) | +| `DqlOrderSupport` | `DQLMixin` | ORDER BY in a query part | +| `ReturningSupport` | `DMLMixin` | RETURNING clause (`format_returning_clause`) | | `UpsertSupport` | `UpsertMixin` | UPSERT operations (ON CONFLICT) | | `LateralJoinSupport` | `LateralJoinMixin` | LATERAL joins | | `ArraySupport` | `ArrayMixin` | Array types and operations | | `JSONSupport` | `JSONMixin` | JSON types and operations | | `ExplainSupport` | `ExplainMixin` | EXPLAIN statement | -| `FilterClauseSupport` | `FilterClauseMixin` | FILTER clause for aggregates | -| `OrderedSetAggregationSupport` | `OrderedSetAggregationMixin` | WITHIN GROUP (ORDER BY) | +| `FilterClauseSupport` | `ExpressionMixin` | FILTER clause for aggregates | +| `OrderedSetAggregationSupport` | `ExpressionMixin` | WITHIN GROUP (ORDER BY) | | `MergeSupport` | `MergeMixin` | MERGE statement | | `TemporalTableSupport` | `TemporalTableMixin` | FOR SYSTEM_TIME queries | -| `QualifyClauseSupport` | `QualifyClauseMixin` | QUALIFY clause | -| `LockingSupport` | `LockingMixin` | FOR UPDATE, SKIP LOCKED | +| `QualifyClauseSupport` | `DQLMixin` | QUALIFY clause | +| `LockingSupport` | `DQLMixin` | FOR UPDATE, SKIP LOCKED (`format_for_update_clause`) | | `GraphSupport` | `GraphMixin` | Graph queries (MATCH) | +| `GraphTableSupport` | `GraphTableMixin` | GRAPH_TABLE row source (SQL/PGQ) | | `JoinSupport` | `JoinMixin` | JOIN operations | +| `WildcardSupport` | `DQLMixin` | `*` and `table.*` expansion | | `SetOperationSupport` | `SetOperationMixin` | UNION, INTERSECT, EXCEPT | | `ILIKESupport` | `ILIKEMixin` | Case-insensitive LIKE | -| `TableSupport` | `TableMixin` | CREATE/DROP/ALTER TABLE | -| `ViewSupport` | `ViewMixin` | CREATE/DROP VIEW | +| `CollationSupport` | `CollationMixin` | COLLATE | +| `DateTimeSupport` | `DateTimeMixin` | Date and time parts | +| `DataTypeSupport` | `DataTypeMixin` | Type spelling (`format_data_type_*`) | +| `PivotSupport` | `PivotMixin` | PIVOT / UNPIVOT | +| `PartitionSupport` | `PartitionMixin` | Table partitioning | +| `ConstraintSupport` | `ConstraintMixin` / `DDLColumnMixin` | Column and table constraints | +| `IntrospectionSupport` | `IntrospectionMixin` | Catalog introspection queries | +| `TransactionControlSupport` | `TransactionControlMixin` | BEGIN / COMMIT / ROLLBACK | +| `CommentSupport` | `CommentOnMixin` | COMMENT ON | +| `GeneratedColumnSupport` | `GeneratedColumnMixin` | Generated (computed) columns | +| `AutoIncrementSupport` | `AutoIncrementMixin` | IDENTITY / AUTO_INCREMENT | +| `AlterTableModifierSupport` | `SQLServerAlterColumnModifierMixin` (backend) | Backend-only ALTER TABLE options | +| `CreateTableSupport` / `DropTableSupport` / `AlterTableSupport` | `TableMixin` | CREATE / DROP / ALTER TABLE | +| `CreateTableAsSupport` / `CreateTableLikeSupport` / `CreateTableCloneSupport` / `CreateTableUsingTemplateSupport` | `TableMixin` | The other CREATE TABLE forms | +| `CreateViewSupport` / `DropViewSupport` | `ViewMixin` | CREATE / DROP VIEW | +| `MaterializedViewSupport` | `ViewMixin` | CREATE / DROP / REFRESH MATERIALIZED VIEW | | `TruncateSupport` | `TruncateMixin` | TRUNCATE TABLE | -| `SchemaSupport` | `SchemaMixin` | CREATE/DROP SCHEMA | -| `IndexSupport` | `IndexMixin` | CREATE/DROP INDEX | -| `SequenceSupport` | `SequenceMixin` | CREATE/DROP/ALTER SEQUENCE | -| `TriggerSupport` | `TriggerMixin` | CREATE/DROP TRIGGER (SQL:1999) | -| `FunctionSupport` | `FunctionMixin` | CREATE/DROP FUNCTION (SQL/PSM) | +| `CreateSchemaSupport` / `DropSchemaSupport` | `SchemaMixin` | CREATE / DROP SCHEMA | +| `CreateIndexSupport` / `DropIndexSupport` / `FulltextIndexSupport` | `IndexMixin` | CREATE / DROP INDEX, FULLTEXT INDEX | +| `CreateSequenceSupport` / `DropSequenceSupport` / `AlterSequenceSupport` | `SequenceMixin` | CREATE / DROP / ALTER SEQUENCE | +| `CreateTriggerSupport` / `DropTriggerSupport` | `TriggerMixin` | CREATE / DROP TRIGGER (SQL:1999) | +| `CreateRoutineSupport` / `DropRoutineSupport` / `SQLFunctionSupport` | `FunctionMixin` | CREATE / DROP FUNCTION and PROCEDURE (SQL/PSM) | +| `CreateTypeSupport` / `AlterTypeSupport` / `DropTypeSupport` | `UserDefinedTypeMixin` | CREATE / ALTER / DROP TYPE | +| `CreateDomainSupport` / `AlterDomainSupport` / `DropDomainSupport` | `DomainMixin` | CREATE / ALTER / DROP DOMAIN | +| `CreateDatabaseSupport` / `DropDatabaseSupport` / `AlterDatabaseSupport` | `DatabaseMixin` | CREATE / DROP / ALTER DATABASE | +| `SQLXMLSupport` and the five `SQLXML*Support` protocols | `SQLXMLMixin` and its five siblings | SQL/XML and XQuery | +| `TableObjectSupport`, `ViewObjectSupport`, `IndexObjectSupport`, `SequenceObjectSupport`, `TypeObjectSupport`, `RoutineObjectSupport`, `TriggerObjectSupport`, `SynonymObjectSupport`, `MaterializedViewObjectSupport`, `ForeignTableObjectSupport`, `PropertyGraphObjectSupport` | `TableNameMixin`, `ViewNameMixin`, … (one per kind) | Naming a catalogue object | +| `NamespaceSupport` | `NamespaceMixin` | Which namespace levels a name may be qualified with | + +The naming protocols are per object kind rather than one: each kind's +`format__object` lives in its own `*NameMixin`, and all of them are +`NamespaceMixin` subclasses, which is where `validate_namespace` and +`format_qualified_name` live. A dialect overrides the one kind it spells +differently and inherits the rest. ##### Principles for Adding New Protocols/Mixins diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a850841..26dd3c6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -59,8 +59,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/expression-schema-object-refactor" - name: Create test database run: | @@ -149,8 +149,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/expression-schema-object-refactor" - name: Create test database run: | @@ -239,8 +239,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/expression-schema-object-refactor" - name: Create test database run: | @@ -329,8 +329,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/expression-schema-object-refactor" - name: Create test database run: | @@ -419,8 +419,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/expression-schema-object-refactor" - name: Create test database run: | @@ -509,8 +509,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/expression-schema-object-refactor" - name: Create test database run: | @@ -600,8 +600,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/expression-schema-object-refactor" - name: Create test database run: | @@ -690,8 +690,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/expression-schema-object-refactor" - name: Create test database run: | @@ -789,8 +789,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/expression-schema-object-refactor" - name: Create test database run: | diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py index ed3effa..6f30ee0 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py @@ -262,8 +262,7 @@ async def _get_scope_identity_async(self) -> Optional[int]: async def set_identity_insert(self, table: str, on: bool = True) -> None: """Enable or disable IDENTITY_INSERT for a table (async).""" - state = "ON" if on else "OFF" - sql = f"SET IDENTITY_INSERT {self.dialect.format_identifier(table)} {state}" + sql, _params = self.dialect.format_set_identity_insert(table, on) async with await self._get_cursor() as cursor: await cursor.execute(sql) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/info.py b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/info.py index 1fd3d4f..138dd66 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/info.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/info.py @@ -24,22 +24,22 @@ ExplainSupport, GraphSupport, SetOperationSupport, - ViewSupport, - TableSupport, + ViewObjectSupport, + TableObjectSupport, TruncateSupport, GeneratedColumnSupport, - TriggerSupport, - FunctionSupport, + TriggerObjectSupport, + RoutineObjectSupport, AdvancedGroupingSupport, ArraySupport, ILIKESupport, - IndexSupport, + IndexObjectSupport, LockingSupport, MergeSupport, OrderedSetAggregationSupport, QualifyClauseSupport, - SchemaSupport, - SequenceSupport, + NamespaceSupport, + SequenceObjectSupport, TemporalTableSupport, ) @@ -77,10 +77,10 @@ ], "Transaction & Locking": [LockingSupport, TemporalTableSupport], "Query Analysis": [ExplainSupport, GraphSupport, QualifyClauseSupport], - "DDL - Table": [TableSupport, TruncateSupport, GeneratedColumnSupport], - "DDL - View": [ViewSupport], - "DDL - Schema & Index": [SchemaSupport, IndexSupport], - "DDL - Sequence & Trigger": [SequenceSupport, TriggerSupport, FunctionSupport], + "DDL - Table": [TableObjectSupport, TruncateSupport, GeneratedColumnSupport], + "DDL - View": [ViewObjectSupport], + "DDL - Schema & Index": [NamespaceSupport, IndexObjectSupport], + "DDL - Sequence & Trigger": [SequenceObjectSupport, TriggerObjectSupport, RoutineObjectSupport], "String Matching": [ILIKESupport], "SQL Server Native": [ SQLServerIndexedViewSupport, diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index f995172..bd5c1f3 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -12,6 +12,7 @@ from rhosocial.activerecord.backend.dialect.base import SQLDialectBase from rhosocial.activerecord.backend.expression import bases from rhosocial.activerecord.backend.expression.bases import BaseExpression, ToSQLProtocol +from rhosocial.activerecord.backend.expression.objects import Index, Sequence, Table from rhosocial.activerecord.backend.dialect.protocols import ( CollationSupport, CTESupport, @@ -32,19 +33,26 @@ LateralJoinSupport, WildcardSupport, JoinSupport, - ViewSupport, SetOperationSupport, TruncateSupport, - SchemaSupport, - IndexSupport, - SequenceSupport, - TableSupport, + NamespaceSupport, + TableObjectSupport, + ViewObjectSupport, + MaterializedViewObjectSupport, + ForeignTableObjectSupport, + IndexObjectSupport, + SequenceObjectSupport, + TriggerObjectSupport, + RoutineObjectSupport, + TypeObjectSupport, + SynonymObjectSupport, ConstraintSupport, IntrospectionSupport, TransactionControlSupport, SQLFunctionSupport, DDLTypeSupport, - UserDefinedTypeSupport, + CreateTypeSupport, + DropTypeSupport, ) from .protocols import ( SQLServerTableSupport, @@ -69,6 +77,28 @@ from rhosocial.activerecord.backend.dialect.mixins import ( CollationMixin, CTEMixin, + # Named objects: each *NameMixin renders one object kind through + # NamespaceMixin.format_qualified_name, so they precede it. A statement that + # now holds an object -- CREATE INDEX's index and table, TRUNCATE's table -- + # has nowhere else to render it. + TableNameMixin, + ViewNameMixin, + MaterializedViewNameMixin, + ForeignTableNameMixin, + IndexNameMixin, + SequenceNameMixin, + TriggerNameMixin, + FunctionNameMixin, + ProcedureNameMixin, + TypeNameMixin, + DomainNameMixin, + SynonymNameMixin, + SchemaNameMixin, + DatabaseNameMixin, + PropertyGraphNameMixin, + # NamespaceMixin itself: what every *NameMixin above falls back on, and + # what SQLServerNamespaceMixin refines. + NamespaceMixin, WindowFunctionMixin, JSONMixin, @@ -107,6 +137,9 @@ PartitionMixin, ILIKEMixin, FunctionMixin, + # The FROM side of a named object: an alias and a temporal clause, which the + # relation itself has no room for. The name comes from the relation. + RelationSourceMixin, ) from rhosocial.activerecord.backend.dialect.protocols import PartitionSupport from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError @@ -144,7 +177,9 @@ from .mixins.generated_column import SQLServerGeneratedColumnMixin from .mixins.set_operation import SQLServerSetOperationMixin from .mixins.ddl_table import SQLServerTableMixin +from .mixins.namespace import SQLServerNamespaceMixin from .mixins.identifier import SQLServerIdentifierMixin +from .expression.objects import SQLServerTable from .mixins.transaction import SQLServerTransactionMixin from .mixins.function import SQLServerFunctionMixin from .mixins.version_constants import ( @@ -183,7 +218,6 @@ CreateTableExpression, CreateIndexExpression, DropTableExpression, - AlterTableExpression, CreateSchemaExpression, DropSchemaExpression, CreateSequenceExpression, @@ -212,6 +246,41 @@ class SQLServerDialect( + # ``SQLDialectBase`` quotes identifiers with double quotes. Python resolves + # base classes left to right, so the bracket-quoting mixin has to precede it + # to win. This is also why the dialect body declares no ``format_identifier`` + # of its own: there is exactly one, and it lives in the mixin. + SQLServerIdentifierMixin, + # Which namespace levels a name carries, and what SQL Server will accept as + # one. The per-kind rendering lives in the core *NameMixin classes listed + # below, which is where every object kind is spelled. + SQLServerNamespaceMixin, + # A JOIN side is a row source, not an object: it carries the alias the + # object has no room for, and renders the relation's name through that + # relation's own format__object. + RelationSourceMixin, + # One renderer per object kind. Each inherits NamespaceMixin, which is what + # SQLServerNamespaceMixin refines, so they follow it here and precede it in + # the MRO -- and a statement that holds a Table or an Index has somewhere to + # render it without a formatter of its own. + TableNameMixin, + ViewNameMixin, + MaterializedViewNameMixin, + ForeignTableNameMixin, + IndexNameMixin, + SequenceNameMixin, + TriggerNameMixin, + FunctionNameMixin, + ProcedureNameMixin, + TypeNameMixin, + DomainNameMixin, + SynonymNameMixin, + SchemaNameMixin, + DatabaseNameMixin, + PropertyGraphNameMixin, + # NamespaceMixin itself: the base every *NameMixin above falls back on, + # and the one SQLServerNamespaceMixin refines. + NamespaceMixin, SQLDialectBase, # Core infrastructure mixins (provide base implementations # that the dialect overrides as needed) @@ -232,7 +301,6 @@ class SQLServerDialect( DDLColumnMixin, DDLTypeMixin, UserDefinedTypeMixin, - SQLServerIdentifierMixin, SQLServerCollationMixin, CollationMixin, SQLServerCTEMixin, @@ -317,22 +385,35 @@ class SQLServerDialect( LateralJoinSupport, WildcardSupport, JoinSupport, - ViewSupport, SetOperationSupport, TruncateSupport, - SchemaSupport, - IndexSupport, - SequenceSupport, - SQLServerTableSupport, # Before TableSupport - TableSupport, + # The object protocols: one per kind, each naming its format__object + # method and inheriting NamespaceSupport, so a subclass precedes its base and + # NamespaceSupport follows every one of them. SQL Server has all three + # namespace levels and renders every one of them, which is what + # NamespaceSupport says. + SQLServerTableSupport, # Before TableObjectSupport (subclass precedes base) + TableObjectSupport, + ViewObjectSupport, + MaterializedViewObjectSupport, + ForeignTableObjectSupport, + IndexObjectSupport, + SequenceObjectSupport, + TriggerObjectSupport, + RoutineObjectSupport, + TypeObjectSupport, + SynonymObjectSupport, + NamespaceSupport, ConstraintSupport, IntrospectionSupport, TransactionControlSupport, SQLFunctionSupport, # DataType Support Protocol DDLTypeSupport, - SQLServerUserDefinedTypeSupport, - UserDefinedTypeSupport, + # TYPE DDL: SQL Server has CREATE TYPE and DROP TYPE but no ALTER TYPE. + SQLServerUserDefinedTypeSupport, # Before its core bases (subclass precedes base) + CreateTypeSupport, + DropTypeSupport, # SQL Server-specific protocols (marker classes for isinstance() checks; # implementations live in SQLServerProtocolSupportMixin / SQLServerSequenceMixin) SQLServerOutputSupport, @@ -431,14 +512,6 @@ class SQLServerDialect( "bit_shift_right": (SQL_SERVER_2022, None), } - def format_identifier(self, identifier: str, need_quote: bool = True) -> str: - """Format an identifier with SQL Server square brackets. - - Declared on the dialect so it is not shadowed by ``SQLDialectBase`` - (double quotes), which precedes the feature mixins in the MRO. - """ - return SQLServerIdentifierMixin.format_identifier(self, identifier, need_quote) - def __init__( self, version: Optional[Tuple[int, int, int]] = None, @@ -526,11 +599,29 @@ def format_create_table_statement( SQL Server has no CREATE TABLE ... LIKE (``supports_create_table_like`` stays ``False``), so the gated ``format_create_table_like_statement`` raises - ``UnsupportedFeatureError``. Temporary tables use a ``#``-prefixed - table name instead of the ``TEMPORARY`` keyword. + ``UnsupportedFeatureError``. + + ``expr.table`` is already the object being named, so the only decision + left here is whether it is the temporary form: SQL Server spells a + temporary table by prefixing its *name* with ``#``, which is a fact + about the table rather than a rendering choice. Both places the name + appears -- the ``OBJECT_ID`` guard and the ``CREATE TABLE`` header -- + then render that one object, so the temporary and ordinary paths cannot + disagree about what they named. + + Raises: + TypeError: ``expr.table`` is not a Table. A view or an index handed + here would render as a well-formed ``CREATE TABLE`` over that + object's name, because the object carries its own + ``format_method`` and the dialect has no other way to tell. """ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + if not isinstance(expr.table, Table): + raise TypeError( + f"CreateTableExpression.table must be a Table, " + f"got {type(expr.table).__name__}" + ) if getattr(expr, "tablespace", None): raise UnsupportedFeatureError( self.name, @@ -552,29 +643,37 @@ def format_create_table_statement( "SQL Server has no inline table comment; comments are " "annotated through sp_addextendedproperty (not implemented).", ) + + # The caller may hand over a plain ``Table`` even for a temporary, so + # the temporary form is settled here: one ``SQLServerTable`` whose name + # already carries the ``#``. Nothing below reads ``expr.temporary`` + # again. + table = expr.table + if expr.temporary and not isinstance(table, SQLServerTable): + table = SQLServerTable( + self, + table.name, + catalog_name=table.catalog_name, + schema_name=table.schema_name, + catalog_need_quote=table.catalog_need_quote, + schema_need_quote=table.schema_need_quote, + name_need_quote=table.name_need_quote, + temporary=True, + ) + if_not_exists_guard = "" if expr.if_not_exists: # SQL Server has no CREATE TABLE IF NOT EXISTS syntax; the # idempotent form is the IF OBJECT_ID(...) IS NULL batch guard. - target = expr.table.name - if expr.table.schema_name: - target = f"{expr.table.schema_name}.{target}" - escaped_target = target.replace("'", "''") - if_not_exists_guard = f"IF OBJECT_ID(N'{escaped_target}', N'U') IS NULL\n" + # OBJECT_ID names an object rather than quoting an identifier, so + # this is the one unbracketed shape -- see format_object_id_name. + target = self.format_object_id_name(table).replace("'", "''") + if_not_exists_guard = f"IF OBJECT_ID(N'{target}', N'U') IS NULL\n" all_params: List[Any] = [] parts = ["CREATE TABLE"] - if expr.temporary: - temp_name = expr.table.name - if not temp_name.startswith("#"): - temp_name = f"#{temp_name}" - table_sql = self.format_identifier(temp_name) - if expr.table.schema_name: - table_sql = f"{self.format_identifier(expr.table.schema_name)}.{table_sql}" - table_params = () - else: - table_sql, table_params = expr.table.to_sql() + table_sql, table_params = table.to_sql() all_params.extend(table_params) parts.append(table_sql) @@ -731,7 +830,25 @@ def format_table_constraint( *, memory_optimized: bool = False, ) -> Tuple[str, tuple]: - """Format a table constraint for SQL Server.""" + """Format a table constraint for SQL Server. + + The memory-optimized variants are why this overrides core's formatter at + all: ``PRIMARY KEY NONCLUSTERED`` and ``UNIQUE NONCLUSTERED`` are the + spellings a memory-optimized table needs. + + Raises: + TypeError: ``t_const.foreign_key_table`` is not a Table. Another + object kind would have had its own name rendered as the + referenced relation, because the constraint renders that slot + through ``to_sql()``. + """ + if t_const.foreign_key_table is not None and not isinstance( + t_const.foreign_key_table, Table + ): + raise TypeError( + f"TableConstraint.foreign_key_table must be a Table, " + f"got {type(t_const.foreign_key_table).__name__}" + ) from rhosocial.activerecord.backend.expression.statements import ( TableConstraintType, ForeignKeyConstraint, @@ -764,7 +881,7 @@ def format_table_constraint( ref_cols_str = ', '.join( self.format_identifier(c) for c in t_const.foreign_key_columns ) - ref_table = self.format_identifier(t_const.foreign_key_table) + ref_table = t_const.foreign_key_table.to_sql()[0] parts.append( f"FOREIGN KEY ({cols_str}) REFERENCES {ref_table} ({ref_cols_str})" ) @@ -802,7 +919,7 @@ def format_inline_index(self, idx_def: "IndexDefinition") -> Tuple[str, tuple]: parts.append("UNIQUE") parts.append("INDEX") - parts.append(self.format_identifier(idx_def.name)) + parts.append(self.qualified_object_name(Index, idx_def.name)) col_parts = [] for col in idx_def.columns: @@ -833,9 +950,25 @@ def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[ SQL Server doesn't support IF NOT EXISTS for CREATE INDEX, and has no index TABLESPACE concept; declared values are never silently dropped. + + Raises: + TypeError: ``expr.index`` is not an Index. A table handed here would + render as a well-formed ``CREATE INDEX`` over that table's name. + TypeError: ``expr.table`` is not a Table. An index handed here would + render as a well-formed ``CREATE INDEX ... ON ``. """ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + if not isinstance(expr.index, Index): + raise TypeError( + f"CreateIndexExpression.index must be an Index, " + f"got {type(expr.index).__name__}" + ) + if not isinstance(expr.table, Table): + raise TypeError( + f"CreateIndexExpression.table must be a Table, " + f"got {type(expr.table).__name__}" + ) if getattr(expr, "if_not_exists", False): raise UnsupportedFeatureError( self.name, "CREATE INDEX IF NOT EXISTS", @@ -858,9 +991,9 @@ def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[ parts.append(index_type) parts.append("INDEX") - parts.append(self.format_identifier(expr.index_name)) + parts.append(expr.index.to_sql()[0]) parts.append("ON") - parts.append(self.format_identifier(expr.table_name)) + parts.append(expr.table.to_sql()[0]) col_parts = [] for col in expr.columns: @@ -893,9 +1026,19 @@ def format_create_sequence_statement(self, expr: "CreateSequenceExpression") -> - START WITH, INCREMENT BY, MINVALUE, MAXVALUE - CYCLE | NO CYCLE, CACHE, NO ORDER (ORDER not supported) - No IF NOT EXISTS support, no OWNED BY + + Raises: + TypeError: ``expr.sequence`` is not a Sequence. A table handed here + would render as a well-formed ``CREATE SEQUENCE`` over that + table's name. """ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + if not isinstance(expr.sequence, Sequence): + raise TypeError( + f"CreateSequenceExpression.sequence must be a Sequence, " + f"got {type(expr.sequence).__name__}" + ) if getattr(expr, "if_not_exists", False): raise UnsupportedFeatureError( self.name, "CREATE SEQUENCE IF NOT EXISTS", @@ -907,7 +1050,7 @@ def format_create_sequence_statement(self, expr: "CreateSequenceExpression") -> "SQL Server sequences have no OWNED BY clause.", ) parts = ["CREATE SEQUENCE"] - parts.append(self.format_identifier(expr.sequence_name)) + parts.append(expr.sequence.to_sql()[0]) if expr.start is not None: parts.append(f"START WITH {expr.start}") if expr.increment is not None: @@ -925,16 +1068,38 @@ def format_create_sequence_statement(self, expr: "CreateSequenceExpression") -> return " ".join(parts), () def format_drop_sequence_statement(self, expr: "DropSequenceExpression") -> Tuple[str, tuple]: - """Format DROP SEQUENCE for SQL Server (2012+).""" + """Format DROP SEQUENCE for SQL Server (2012+). + + Raises: + TypeError: ``expr.sequence`` is not a Sequence. A table handed here + would render as a well-formed ``DROP SEQUENCE`` over that + table's name. + """ + if not isinstance(expr.sequence, Sequence): + raise TypeError( + f"DropSequenceExpression.sequence must be a Sequence, " + f"got {type(expr.sequence).__name__}" + ) parts = ["DROP SEQUENCE"] if expr.if_exists and self.version >= SQL_SERVER_2012: parts.append("IF EXISTS") - parts.append(self.format_identifier(expr.sequence_name)) + parts.append(expr.sequence.to_sql()[0]) return " ".join(parts), () def format_alter_sequence_statement(self, expr: "AlterSequenceExpression") -> Tuple[str, tuple]: - """Format ALTER SEQUENCE for SQL Server.""" - parts = [f"ALTER SEQUENCE {self.format_identifier(expr.sequence_name)}"] + """Format ALTER SEQUENCE for SQL Server. + + Raises: + TypeError: ``expr.sequence`` is not a Sequence. A table handed here + would render as a well-formed ``ALTER SEQUENCE`` over that + table's name. + """ + if not isinstance(expr.sequence, Sequence): + raise TypeError( + f"AlterSequenceExpression.sequence must be a Sequence, " + f"got {type(expr.sequence).__name__}" + ) + parts = [f"ALTER SEQUENCE {expr.sequence.to_sql()[0]}"] if expr.restart is not None: parts.append(f"RESTART WITH {expr.restart}") if expr.increment is not None: @@ -952,11 +1117,21 @@ def format_alter_sequence_statement(self, expr: "AlterSequenceExpression") -> Tu def format_truncate_statement(self, expr: "TruncateExpression") -> Tuple[str, tuple]: """Format TRUNCATE TABLE for SQL Server. - TRUNCATE TABLE is a DDL operation (minimal logging). - Does not support RESTART IDENTITY or CASCADE. + SQL Server's TRUNCATE TABLE is a DDL operation (minimal logging) with + no modifiers, so the statement renders the table object and nothing + else. + + Raises: + TypeError: ``expr.table`` is not a Table. A view or a sequence + handed here would render as a well-formed ``TRUNCATE TABLE`` + over that object's name. """ - sql = f"TRUNCATE TABLE {self.format_identifier(expr.table_name)}" - return sql, () + if not isinstance(expr.table, Table): + raise TypeError( + f"TruncateExpression.table must be a Table, " + f"got {type(expr.table).__name__}" + ) + return f"TRUNCATE TABLE {expr.table.to_sql()[0]}", () def format_merge_statement(self, expr: "MergeExpression") -> Tuple[str, tuple]: """Format MERGE statement for SQL Server (2008+). @@ -966,9 +1141,19 @@ def format_merge_statement(self, expr: "MergeExpression") -> Tuple[str, tuple]: WHEN MATCHED [AND condition] THEN UPDATE/DELETE WHEN NOT MATCHED [BY TARGET] [AND condition] THEN INSERT WHEN NOT MATCHED BY SOURCE [AND condition] THEN UPDATE/DELETE + + Raises: + TypeError: ``expr.target_table`` is not a Table. Any other object kind + would have its own name rendered as the MERGE target. """ from rhosocial.activerecord.backend.expression.statements import MergeActionType + if not isinstance(expr.target_table, Table): + raise TypeError( + f"MergeExpression.target_table must be a Table, " + f"got {type(expr.target_table).__name__}" + ) + all_params: list = [] target_sql, target_params = expr.target_table.to_sql() @@ -1048,36 +1233,6 @@ def format_merge_statement(self, expr: "MergeExpression") -> Tuple[str, tuple]: # T-SQL requires a MERGE statement to be terminated by a semi-colon. return " ".join(parts) + ";", tuple(all_params) - def format_alter_table_statement(self, expr: "AlterTableExpression") -> Tuple[str, tuple]: - """Format ALTER TABLE for SQL Server. - - SQL Server supports: - - ADD column - - DROP COLUMN - - ALTER COLUMN (modify data type) - - ADD CONSTRAINT (PRIMARY KEY, UNIQUE, CHECK, FOREIGN KEY) - - DROP CONSTRAINT - - Single action per ALTER TABLE statement - """ - all_params: list = [] - table_sql = self.format_identifier(expr.table_name) - - action_parts = [] - for action in expr.actions: - action_part, action_params = action.to_sql() - action_parts.append(action_part) - all_params.extend(action_params) - - if not action_parts: - return f"ALTER TABLE {table_sql}", () - - if self.supports_multi_action_alter_table(): - return f"ALTER TABLE {table_sql} {', '.join(action_parts)}", tuple(all_params) - - # SQL Server requires one action per statement: emit a statement each. - stmts = [f"ALTER TABLE {table_sql} {part}" for part in action_parts] - return "; ".join(stmts), tuple(all_params) - def format_function_call(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: """Format a function call, mapping MySQL/generic function names to their SQL Server equivalents. diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/connection/quickstart.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/connection/quickstart.py index e74833f..8b3790d 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/connection/quickstart.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/connection/quickstart.py @@ -18,10 +18,10 @@ DropTableExpression, InsertExpression, QueryExpression, - TableExpression, ValuesSource, WhereClause, ) +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.core import Column, Literal from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.expression.statements import ( @@ -65,7 +65,7 @@ def execute_expression(expression, options=None): # Create demo tables users_table = CreateTableExpression( dialect=dialect, - table_name='quickstart_users', + table=Table(dialect, 'quickstart_users'), columns=[ ColumnDefinition( 'id', @@ -88,7 +88,7 @@ def execute_expression(expression, options=None): logs_table = CreateTableExpression( dialect=dialect, - table_name='quickstart_logs', + table=Table(dialect, 'quickstart_logs'), columns=[ ColumnDefinition( 'id', @@ -111,7 +111,7 @@ def execute_expression(expression, options=None): # Seed demo data insert_users = InsertExpression( dialect=dialect, - into='quickstart_users', + into=Table(dialect, 'quickstart_users'), columns=['name', 'status'], source=ValuesSource( dialect, @@ -134,7 +134,7 @@ def execute_expression(expression, options=None): Column(dialect, 'name'), Column(dialect, 'status'), ], - from_=TableExpression(dialect, 'quickstart_users'), + from_=Table(dialect, 'quickstart_users'), where=WhereClause( dialect, condition=ComparisonPredicate( @@ -172,7 +172,7 @@ def execute_expression(expression, options=None): Column(dialect, 'name'), Column(dialect, 'status'), ], - from_=TableExpression(dialect, 'quickstart_users'), + from_=Table(dialect, 'quickstart_users'), where=WhereClause( dialect, condition=LogicalPredicate( @@ -197,7 +197,7 @@ def execute_expression(expression, options=None): with backend.transaction(): insert_log = InsertExpression( dialect=dialect, - into='quickstart_logs', + into=Table(dialect, 'quickstart_logs'), columns=['message'], source=ValuesSource(dialect, [[Literal(dialect, 'quickstart transaction')]]), ) @@ -206,7 +206,7 @@ def execute_expression(expression, options=None): logs_query = QueryExpression( dialect=dialect, select=[Column(dialect, 'id'), Column(dialect, 'message')], - from_=TableExpression(dialect, 'quickstart_logs'), + from_=Table(dialect, 'quickstart_logs'), ) result = execute_expression(logs_query, dql_options) print(f"Transaction result: {result.data}") @@ -218,7 +218,7 @@ def execute_expression(expression, options=None): invalid_query = QueryExpression( dialect=dialect, select=[Column(dialect, 'id')], - from_=TableExpression(dialect, 'nonexistent_table'), + from_=Table(dialect, 'nonexistent_table'), ) execute_expression(invalid_query, dql_options) except Exception as error: @@ -228,12 +228,12 @@ def execute_expression(expression, options=None): # SECTION: Teardown (necessary for execution, reference only) # ============================================================ drop_logs = DropTableExpression( - dialect=dialect, table_name='quickstart_logs', if_exists=True + dialect=dialect, table=Table(dialect, 'quickstart_logs'), if_exists=True ) execute_expression(drop_logs, ddl_options) drop_users = DropTableExpression( - dialect=dialect, table_name='quickstart_users', if_exists=True + dialect=dialect, table=Table(dialect, 'quickstart_users'), if_exists=True ) execute_expression(drop_users, ddl_options) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/ddl/create_index.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/ddl/create_index.py index 748a439..6e1834f 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/ddl/create_index.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/ddl/create_index.py @@ -27,6 +27,7 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression.objects import Index, Table from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, @@ -39,7 +40,7 @@ create_table = CreateTableExpression( dialect=dialect, - table_name='users', + table=Table(dialect, 'users'), columns=[ ColumnDefinition('id', 'INT', constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), @@ -62,8 +63,8 @@ create_idx = CreateIndexExpression( dialect=dialect, - index_name='idx_users_email', - table_name='users', + index=Index(dialect, 'idx_users_email'), + table=Table(dialect, 'users'), columns=['email'], unique=True, if_not_exists=True, @@ -84,7 +85,7 @@ # ============================================================ from rhosocial.activerecord.backend.expression import DropTableExpression -drop_expr = DropTableExpression(dialect=dialect, table_name='users', if_exists=True) +drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, 'users'), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params, options=ddl_options) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/ddl/create_table.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/ddl/create_table.py index 344ca75..dcbc466 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/ddl/create_table.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/ddl/create_table.py @@ -35,6 +35,7 @@ ColumnConstraint, ColumnConstraintType, ) +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements.ddl_table import IndexDefinition columns = [ @@ -77,7 +78,7 @@ create_expr = CreateTableExpression( dialect=dialect, - table_name='users', + table=Table(dialect, 'users'), columns=columns, indexes=indexes, if_not_exists=True, @@ -102,7 +103,7 @@ # ============================================================ from rhosocial.activerecord.backend.expression import DropTableExpression -drop_expr = DropTableExpression(dialect=dialect, table_name='users', if_exists=True) +drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, 'users'), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params, options=ddl_options) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/functions/json_example.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/functions/json_example.py index ff6894f..9432866 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/functions/json_example.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/functions/json_example.py @@ -12,7 +12,8 @@ json_object, json_array, ) -from rhosocial.activerecord.backend.expression import QueryExpression, Column, TableExpression +from rhosocial.activerecord.backend.expression import QueryExpression, Column +from rhosocial.activerecord.backend.expression.objects import Table dialect = SQLServerDialect(version=(16, 0, 0)) @@ -54,7 +55,7 @@ json_value(dialect, "data", "$.name").as_("name"), json_value(dialect, "data", "$.age").as_("age"), ], - from_=TableExpression(dialect, "users"), + from_=Table(dialect, "users"), ) sql, params = query.to_sql() print(f" SQL: {sql}") \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/functions/string_example.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/functions/string_example.py index 1c66d74..cee09e0 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/functions/string_example.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/functions/string_example.py @@ -12,7 +12,8 @@ string_split, format, ) -from rhosocial.activerecord.backend.expression import QueryExpression, Column, TableExpression +from rhosocial.activerecord.backend.expression import QueryExpression, Column +from rhosocial.activerecord.backend.expression.objects import Table dialect = SQLServerDialect(version=(16, 0, 0)) @@ -52,7 +53,7 @@ Column(dialect, "id"), concat_ws(dialect, "-", "first_name", "last_name").as_("full_name"), ], - from_=TableExpression(dialect, "users"), + from_=Table(dialect, "users"), ) sql, params = query.to_sql() print(f" SQL: {sql}") @@ -61,7 +62,7 @@ query = QueryExpression( dialect=dialect, select=[Column(dialect, "id")], - from_=TableExpression(dialect, "users"), + from_=Table(dialect, "users"), where=string_split(dialect, Column(dialect, "tags"), ","), ) sql, params = query.to_sql() diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/insert/batch.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/insert/batch.py index 3b70e33..8051a27 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/insert/batch.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/insert/batch.py @@ -27,6 +27,7 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, @@ -39,7 +40,7 @@ create_table = CreateTableExpression( dialect=dialect, - table_name='batch_users', + table=Table(dialect, 'batch_users'), columns=[ ColumnDefinition('id', 'INT', constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), @@ -71,7 +72,7 @@ # Create batch insert with multiple value rows insert_expr = InsertExpression( dialect=dialect, - into='batch_users', + into=Table(dialect, 'batch_users'), columns=['name', 'email'], source=ValuesSource( dialect, @@ -90,12 +91,12 @@ print(f"Affected rows: {result.affected_rows}") # Verify insertion -from rhosocial.activerecord.backend.expression import QueryExpression, TableExpression, Column +from rhosocial.activerecord.backend.expression import QueryExpression, Column query = QueryExpression( dialect=dialect, select=[Column(dialect, 'id'), Column(dialect, 'name'), Column(dialect, 'email')], - from_=TableExpression(dialect, 'batch_users'), + from_=Table(dialect, 'batch_users'), ) sql, params = query.to_sql() dql_options = ExecutionOptions(stmt_type=StatementType.DQL) @@ -109,7 +110,7 @@ # ============================================================ from rhosocial.activerecord.backend.expression import DropTableExpression -drop_expr = DropTableExpression(dialect=dialect, table_name='batch_users', if_exists=True) +drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, 'batch_users'), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params, options=ddl_options) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/insert/with_output.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/insert/with_output.py index 0f19b44..a4723e7 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/insert/with_output.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/insert/with_output.py @@ -29,6 +29,7 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, @@ -41,7 +42,7 @@ create_table = CreateTableExpression( dialect=dialect, - table_name='output_users', + table=Table(dialect, 'output_users'), columns=[ ColumnDefinition('id', 'INT', constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), @@ -63,7 +64,6 @@ InsertExpression, ValuesSource, ReturningClause, - TableExpression, Column, ) from rhosocial.activerecord.backend.expression.core import Literal @@ -71,7 +71,7 @@ # SQL Server uses 'returning' attribute which dialect converts to OUTPUT clause insert_expr = InsertExpression( dialect=dialect, - into=TableExpression(dialect, 'output_users'), + into=Table(dialect, 'output_users'), source=ValuesSource(dialect, [[Literal(dialect, 'Alice')]]), columns=['name'], returning=ReturningClause(dialect, [Column(dialect, 'id')]), @@ -94,7 +94,7 @@ # ============================================================ from rhosocial.activerecord.backend.expression import DropTableExpression -drop_expr = DropTableExpression(dialect=dialect, table_name='output_users', if_exists=True) +drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, 'output_users'), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params, options=ddl_options) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_expressions/order_expressions.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_expressions/order_expressions.py index 4392c71..12e3d32 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_expressions/order_expressions.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_expressions/order_expressions.py @@ -22,8 +22,8 @@ Column, Literal, QueryExpression, - TableExpression, ) +from rhosocial.activerecord.backend.expression.objects import Table # noqa: E402 def get_order(dialect, order_id: int): @@ -31,7 +31,7 @@ def get_order(dialect, order_id: int): return QueryExpression( dialect, select=[Column(dialect, "id"), Column(dialect, "status"), Column(dialect, "user_id")], - from_=TableExpression(dialect, "orders"), + from_=Table(dialect, "orders"), where=Column(dialect, "id") == Literal(dialect, order_id), ) @@ -41,7 +41,7 @@ def check_inventory(dialect, order_id: int): return QueryExpression( dialect, select=[Column(dialect, "available")], - from_=TableExpression(dialect, "inventory"), + from_=Table(dialect, "inventory"), where=Column(dialect, "order_id") == Literal(dialect, order_id), ) @@ -51,7 +51,7 @@ def reserve_inventory(dialect, order_id: int): return QueryExpression( dialect, select=[Column(dialect, "id"), Column(dialect, "available")], - from_=TableExpression(dialect, "inventory"), + from_=Table(dialect, "inventory"), where=Column(dialect, "order_id") == Literal(dialect, order_id), ) @@ -61,7 +61,7 @@ def send_notification(dialect, user_id: int, type: str): return QueryExpression( dialect, select=[Column(dialect, "id")], - from_=TableExpression(dialect, "notifications"), + from_=Table(dialect, "notifications"), where=Column(dialect, "user_id") == Literal(dialect, user_id), ) @@ -71,7 +71,7 @@ def process_payment(dialect, order_id: int, amount: float): return QueryExpression( dialect, select=[Column(dialect, "status"), Column(dialect, "transaction_id")], - from_=TableExpression(dialect, "payments"), + from_=Table(dialect, "payments"), where=Column(dialect, "order_id") == Literal(dialect, order_id), ) @@ -81,7 +81,7 @@ def release_inventory(dialect, order_id: int): return QueryExpression( dialect, select=[Column(dialect, "id")], - from_=TableExpression(dialect, "inventory"), + from_=Table(dialect, "inventory"), where=Column(dialect, "order_id") == Literal(dialect, order_id), ) @@ -91,7 +91,7 @@ def create_order_record(dialect, order_id: int, user_id: int, amount: float): return QueryExpression( dialect, select=[Column(dialect, "id"), Column(dialect, "created_at")], - from_=TableExpression(dialect, "order_records"), + from_=Table(dialect, "order_records"), where=Column(dialect, "order_id") == Literal(dialect, order_id), ) @@ -101,7 +101,7 @@ def confirm_inventory(dialect, order_id: int): return QueryExpression( dialect, select=[Column(dialect, "id")], - from_=TableExpression(dialect, "inventory"), + from_=Table(dialect, "inventory"), where=Column(dialect, "order_id") == Literal(dialect, order_id), ) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/query/basic.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/query/basic.py index 861d2b9..abe1c88 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/query/basic.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/query/basic.py @@ -34,6 +34,7 @@ ValuesSource, ) from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, @@ -45,7 +46,7 @@ create_table = CreateTableExpression( dialect=dialect, - table_name='query_users', + table=Table(dialect, 'query_users'), columns=[ ColumnDefinition('id', 'INT', constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), @@ -70,7 +71,7 @@ for row in users: insert_expr = InsertExpression( dialect=dialect, - into='query_users', + into=Table(dialect, 'query_users'), columns=['name', 'age', 'status'], source=ValuesSource(dialect, [[Literal(dialect, v) for v in row]]), ) @@ -82,7 +83,6 @@ # ============================================================ from rhosocial.activerecord.backend.expression import ( QueryExpression, - TableExpression, Column, WhereClause, OrderByClause, @@ -97,7 +97,7 @@ Column(dialect, 'name'), Column(dialect, 'age'), ], - from_=TableExpression(dialect, 'query_users'), + from_=Table(dialect, 'query_users'), where=WhereClause( dialect, condition=ComparisonPredicate( @@ -131,7 +131,7 @@ # ============================================================ from rhosocial.activerecord.backend.expression import DropTableExpression -drop_expr = DropTableExpression(dialect=dialect, table_name='query_users', if_exists=True) +drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, 'query_users'), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params, options=ddl_options) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/query/join.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/query/join.py index d963d87..77e0a61 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/query/join.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/query/join.py @@ -34,6 +34,7 @@ ValuesSource, ) from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, @@ -46,7 +47,7 @@ users_table = CreateTableExpression( dialect=dialect, - table_name='join_users', + table=Table(dialect, 'join_users'), columns=[ ColumnDefinition('id', 'INT', constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), @@ -63,7 +64,7 @@ orders_table = CreateTableExpression( dialect=dialect, - table_name='join_orders', + table=Table(dialect, 'join_orders'), columns=[ ColumnDefinition('id', 'INT', constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), @@ -76,7 +77,7 @@ TableConstraint( constraint_type=TableConstraintType.FOREIGN_KEY, columns=['user_id'], - foreign_key_table='join_users', + foreign_key_table=Table(dialect, 'join_users'), foreign_key_columns=['id'], ), ], @@ -89,7 +90,7 @@ for user in users: insert_expr = InsertExpression( dialect=dialect, - into='join_users', + into=Table(dialect, 'join_users'), columns=['name'], source=ValuesSource(dialect, [[Literal(dialect, v) for v in user]]), ) @@ -104,7 +105,7 @@ for row in orders: insert_expr = InsertExpression( dialect=dialect, - into='join_orders', + into=Table(dialect, 'join_orders'), columns=['user_id', 'amount'], source=ValuesSource(dialect, [[Literal(dialect, v) for v in row]]), ) @@ -116,16 +117,16 @@ # ============================================================ from rhosocial.activerecord.backend.expression import ( QueryExpression, - TableExpression, Column, JoinClause, ) from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef join_expr = JoinClause( dialect=dialect, - left_table=TableExpression(dialect, 'join_users', alias='u'), - right_table=TableExpression(dialect, 'join_orders', alias='o'), + left_table=NamedRelationRef(dialect, Table(dialect, 'join_users'), alias='u'), + right_table=NamedRelationRef(dialect, Table(dialect, 'join_orders'), alias='o'), join_type='LEFT JOIN', condition=ComparisonPredicate( dialect, @@ -162,11 +163,11 @@ # ============================================================ from rhosocial.activerecord.backend.expression import DropTableExpression -drop_orders = DropTableExpression(dialect=dialect, table_name='join_orders', if_exists=True) +drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, 'join_orders'), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params, options=ddl_options) -drop_users = DropTableExpression(dialect=dialect, table_name='join_users', if_exists=True) +drop_users = DropTableExpression(dialect=dialect, table=Table(dialect, 'join_users'), if_exists=True) sql, params = drop_users.to_sql() backend.execute(sql, params, options=ddl_options) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/schema_diff/add_column.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/schema_diff/add_column.py index a396d05..0b3b284 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/schema_diff/add_column.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/schema_diff/add_column.py @@ -28,17 +28,20 @@ ColumnConstraint, ColumnConstraintType, AlterTableExpression, AddColumn, ) +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.types import ( IntegerType, VarCharType, ) -expr = DropTableExpression(dialect, "users", if_exists=True) +users = Table(dialect, "users") + +expr = DropTableExpression(dialect, users, if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) # Baseline table: ID, NAME expr = CreateTableExpression( - dialect=dialect, table="users", columns=[ + dialect=dialect, table=users, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True), @@ -61,7 +64,7 @@ # Add a column alter = AlterTableExpression( - dialect=dialect, table_name="users", + dialect=dialect, table=users, actions=[ AddColumn(dialect, column=ColumnDefinition(dialect, "email", VarCharType(dialect, length=255))), ], diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/transaction/basic.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/transaction/basic.py index a9276b5..52257fb 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/transaction/basic.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/transaction/basic.py @@ -28,6 +28,7 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, @@ -40,7 +41,7 @@ create_table = CreateTableExpression( dialect=dialect, - table_name='txn_users', + table=Table(dialect, 'txn_users'), columns=[ ColumnDefinition('id', 'INT', constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), @@ -66,7 +67,7 @@ with backend.transaction(): insert_expr = InsertExpression( dialect=dialect, - into='txn_users', + into=Table(dialect, 'txn_users'), columns=['name'], source=ValuesSource(dialect, [[Literal(dialect, 'Alice')]]), ) @@ -75,7 +76,7 @@ insert_expr2 = InsertExpression( dialect=dialect, - into='txn_users', + into=Table(dialect, 'txn_users'), columns=['name'], source=ValuesSource(dialect, [[Literal(dialect, 'Bob')]]), ) @@ -90,12 +91,12 @@ # ============================================================ # SECTION: Execution (verify results) # ============================================================ -from rhosocial.activerecord.backend.expression import QueryExpression, TableExpression, Column +from rhosocial.activerecord.backend.expression import QueryExpression, Column query = QueryExpression( dialect=dialect, select=[Column(dialect, 'id'), Column(dialect, 'name')], - from_=TableExpression(dialect, 'txn_users'), + from_=Table(dialect, 'txn_users'), ) sql, params = query.to_sql() dql_options = ExecutionOptions(stmt_type=StatementType.DQL) @@ -109,7 +110,7 @@ # ============================================================ from rhosocial.activerecord.backend.expression import DropTableExpression -drop_expr = DropTableExpression(dialect=dialect, table_name='txn_users', if_exists=True) +drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, 'txn_users'), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params, options=ddl_options) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/types/json_basic.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/types/json_basic.py index 0700146..929ee75 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/types/json_basic.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/types/json_basic.py @@ -30,6 +30,7 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, @@ -43,7 +44,7 @@ create_table = CreateTableExpression( dialect=dialect, - table_name='json_data', + table=Table(dialect, 'json_data'), columns=[ ColumnDefinition('id', 'INT', constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), @@ -68,7 +69,7 @@ insert_expr = InsertExpression( dialect=dialect, - into='json_data', + into=Table(dialect, 'json_data'), columns=['data'], source=ValuesSource(dialect, [[Literal(dialect, json_literal)]]), ) @@ -81,13 +82,13 @@ # ============================================================ # For JSON queries, we use raw SQL since JSON_VALUE/JSON_QUERY # are SQL Server specific functions not yet available as expressions -from rhosocial.activerecord.backend.expression import QueryExpression, TableExpression, Column +from rhosocial.activerecord.backend.expression import QueryExpression, Column # Basic query to get the JSON data query = QueryExpression( dialect=dialect, select=[Column(dialect, 'id'), Column(dialect, 'data')], - from_=TableExpression(dialect, 'json_data'), + from_=Table(dialect, 'json_data'), ) sql, params = query.to_sql() result = backend.execute(sql, params, options=dql_options) @@ -100,7 +101,7 @@ # ============================================================ from rhosocial.activerecord.backend.expression import DropTableExpression -drop_expr = DropTableExpression(dialect=dialect, table_name='json_data', if_exists=True) +drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, 'json_data'), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params, options=ddl_options) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/__init__.py index d2a3448..b16d7aa 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/__init__.py @@ -1,6 +1,7 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/expression/__init__.py """SQL Server-specific expression classes.""" +from .objects import SQLServerTable, PartitionFunction, PartitionScheme from .output import SQLServerOutputInsertedExpression, SQLServerOutputDeletedExpression from .locking import SQLServerTableHintClause, SQLServerTableHint, SQLServerReadPastHint from .temporal import SQLServerTemporalPeriodDefinition, SQLServerSystemVersioningClause @@ -61,6 +62,9 @@ ) __all__ = [ + "SQLServerTable", + "PartitionFunction", + "PartitionScheme", "SQLServerOutputInsertedExpression", "SQLServerOutputDeletedExpression", "SQLServerTableHintClause", diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/trigger.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/trigger.py index 2fd26ed..bdc2b51 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/trigger.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/trigger.py @@ -25,6 +25,8 @@ class SQLServerCreateTriggerExpression(BaseExpression): Attributes: name: Trigger name. table: Table the trigger is attached to. + schema: Optional schema holding the trigger. A SQL Server trigger + lives in the same schema as its table, so this qualifies both names. timing: Either ``AFTER`` or ``INSTEAD OF``. events: List of ``INSERT`` / ``UPDATE`` / ``DELETE``. body: Raw T-SQL body passed through verbatim. @@ -48,11 +50,13 @@ def __init__( events: Optional[List[str]] = None, body: str = "", *, + schema: Optional[str] = None, or_alter: bool = False, ): super().__init__(dialect) self.name = name self.table = table + self.schema = schema self.timing = timing self.events = list(events or []) self.body = body @@ -81,6 +85,7 @@ class SQLServerDropTriggerExpression(BaseExpression): Attributes: name: Trigger name. + schema: Optional schema holding the trigger. if_exists: Render ``IF EXISTS`` (SQL Server 2016+ only). Example: @@ -94,10 +99,12 @@ def __init__( dialect: "SQLServerDialect", name: str, *, + schema: Optional[str] = None, if_exists: bool = False, ): super().__init__(dialect) self.name = name + self.schema = schema self.if_exists = if_exists def validate(self, strict: bool = True) -> None: diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/type.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/type.py index 1b2e446..f3b5903 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/type.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/type.py @@ -7,6 +7,7 @@ from typing import Any, Dict, Optional, Sequence, Union, cast from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.objects import Type from rhosocial.activerecord.backend.expression.serialization import ExpressionRegistry from rhosocial.activerecord.backend.expression.statements.ddl_table import ( ColumnDefinition, @@ -149,14 +150,19 @@ def __init__( class SQLServerDropTypeExpression(DropTypeExpression): - """SQL Server DROP TYPE expression with explicit unsupported behavior flags.""" + """SQL Server DROP TYPE expression with explicit unsupported behavior flags. + + ``type`` is the :class:`~rhosocial.activerecord.backend.expression.objects.Type` + being dropped, namespace and all; SQL Server has neither ``CASCADE`` nor + ``RESTRICT`` for ``DROP TYPE``, and asking for one is an error here rather + than a silently dropped token. + """ def __init__( self, dialect: Any, - type_name: str, + type: Type, *, - schema_name: Optional[str] = None, if_exists: bool = False, cascade: bool = False, restrict: bool = False, @@ -165,12 +171,7 @@ def __init__( raise TypeError("cascade and restrict must be bools") if cascade and restrict: raise ValueError("CASCADE and RESTRICT are mutually exclusive") - super().__init__( - dialect, - type_name, - schema_name=schema_name, - if_exists=if_exists, - ) + super().__init__(dialect, type, if_exists=if_exists) self.cascade = cascade self.restrict = restrict diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/graph.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/graph.py index 2093d7e..de60a05 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/graph.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/graph.py @@ -198,12 +198,30 @@ class SQLServerForPathTable(BaseExpression): Used with ``SHORTEST_PATH`` so intermediate nodes/edges can be projected by path aggregates. + + Graph tables **do** support a namespace here. A SQL Graph node or edge + table is an ordinary table as far as SQL Server is concerned -- it lives in + a schema and is qualified like any other -- so the schema and catalog slots + are accepted and rendered through the shared qualified-name entry point. + The ``AS`` alias stays a plain identifier: it renames the row for this one + statement and renames nothing in the catalogue, so it is not a schema + object. """ - def __init__(self, dialect: "SQLServerDialect", table: str, alias: Optional[str] = None): + def __init__( + self, + dialect: "SQLServerDialect", + table: str, + alias: Optional[str] = None, + *, + catalog_name: Optional[str] = None, + schema_name: Optional[str] = None, + ): super().__init__(dialect) self.table = table self.alias = alias + self.catalog_name = catalog_name + self.schema_name = schema_name @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/objects.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/objects.py new file mode 100644 index 0000000..9850a8a --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/objects.py @@ -0,0 +1,98 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/expression/objects.py +"""The schema-object kinds SQL Server adds to the shared tree. + +Every named object the backend touches is a +:class:`~rhosocial.activerecord.backend.expression.objects.SchemaObject`: three +fixed slots (catalog, schema, name), no rendering, no dialect. The shared tree +in ``backend.expression.objects`` already names the kinds SQL Server uses for +tables, views, indexes, sequences, types and routines. Two things are missing, +and they are defined here rather than smuggled into name strings: + +:class:`SQLServerTable` + SQL Server has no ``TEMPORARY`` keyword. A temporary table is spelled by + prefixing the *name* with ``#``, so the prefix is part of what the table is + called rather than a rendering decision. ``temporary`` is therefore a field + on the object, settled once at construction, and the qualified-name renderer + never has to ask whether it is looking at a temporary table. +:class:`PartitionFunction` / :class:`PartitionScheme` + SQL Server's partitioning model has two named objects of its own. The + shared tree does not name them yet; deriving them straight from + :class:`SchemaObject` keeps them in the same tree with the same three slots + and leaves promotion to a shared subclass for when another engine grows the + same pair. + +Because these three kinds are SQL Server's own, they declare the formatter that +renders them and the dialect implements it: ``format_table_object`` is already +core's for every table, so :class:`SQLServerTable` inherits it, while the two +partition kinds each get a small renderer on +:class:`~...mixins.partition.SQLServerPartitionMixin`. +""" + +from rhosocial.activerecord.backend.expression.objects import SchemaObject, Table + +__all__ = ["SQLServerTable", "PartitionFunction", "PartitionScheme"] + + +class SQLServerTable(Table): + """A SQL Server table, whose ``#``-prefixed temporary form is a field. + + ``temporary`` records what the table *is*, and the ``#`` prefix is folded + into :attr:`name` at construction. That is what keeps the qualified-name + renderer free of a ``if temporary:`` branch: ``CREATE TABLE [#stage]`` and + ``CREATE TABLE [stage]`` differ only in the object, not in how the object + is rendered. + """ + + __slots__ = ("temporary",) + + def __init__(self, dialect, name: str, *, temporary: bool = False, **slots) -> None: + """Record the table's identity. + + Args: + dialect: The dialect that will render this table. + name: The table's own name, without the temporary marker. + temporary: Whether this is a ``#``-prefixed temporary table. + **slots: ``catalog_name`` / ``schema_name`` and the three + ``*_need_quote`` flags, passed straight through. + """ + super().__init__(dialect, name, **slots) + self.temporary = bool(temporary) + if self.temporary and not self.name.startswith("#"): + self.name = f"#{self.name}" + + def identity(self) -> tuple: + """The comparable payload, widened with :attr:`temporary`. + + ``SQLServerTable("t", temporary=True)`` and ``SQLServerTable("#t")`` + name the same table in SQL Server but disagree about what the object + is, so both the flag and the resulting name take part. + """ + return super().identity() + (self.temporary,) + + +class PartitionFunction(SchemaObject): + """A SQL Server ``PARTITION FUNCTION``. + + Named by ``CREATE PARTITION FUNCTION`` and referenced by name from a + partition scheme, so it renders through the dialect's + ``format_partition_function_object``. + """ + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this partition function.""" + return "format_partition_function_object" + + +class PartitionScheme(SchemaObject): + """A SQL Server ``PARTITION SCHEME``. + + Named by ``CREATE PARTITION SCHEME`` and referenced from ``CREATE TABLE ... + ON ``, so it renders through the dialect's + ``format_partition_scheme_object``. + """ + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this partition scheme.""" + return "format_partition_scheme_object" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/temporal.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/temporal.py index ee1339d..4d36abd 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/temporal.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/temporal.py @@ -1,9 +1,10 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/expression/temporal.py """SQL Server temporal table expressions (2016+).""" -from typing import Any, Optional, Tuple, TYPE_CHECKING +from typing import Optional, Union, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLQueryAndParams +from rhosocial.activerecord.backend.expression.objects import Table if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase @@ -39,27 +40,49 @@ class SQLServerSystemVersioningClause(BaseExpression): Enables system-versioning on a temporal table. + The history table is one schema object, not a name plus a parallel schema + string: ``WITH (SYSTEM_VERSIONING = ON, HISTORY_TABLE = dbo.TestTableHistory)`` + names a single object living in a namespace, and splitting that into two + constructor parameters was the same namespace-smuggling the schema-object + layer exists to remove. + Example: - >>> expr = SQLServerSystemVersioningClause(dialect, "dbo.TestTableHistory") + >>> from rhosocial.activerecord.backend.expression.objects import Table + >>> clause = SQLServerSystemVersioningClause( + ... dialect, Table(dialect, "TestTableHistory", schema_name="dbo") + ... ) """ def __init__( self, dialect: "SQLDialectBase", - history_table: Optional[str] = None, - history_schema: Optional[str] = None, + history_table: Optional[Union[Table, str]] = None, ): + """Record the history table this clause points at. + + Args: + dialect: The SQL Server dialect. + history_table: The history table, as a + :class:`~rhosocial.activerecord.backend.expression.objects.Table`. + A bare string is accepted as shorthand for an unqualified name; + pass the object to place the table in a schema or catalog. + """ super().__init__(dialect) - self.history_table = history_table - self.history_schema = history_schema + if history_table is None: + self.history_table: Optional[Table] = None + elif isinstance(history_table, str): + self.history_table = Table(dialect, history_table) + elif isinstance(history_table, Table): + self.history_table = history_table + else: + raise TypeError( + f"history_table must be a Table or a string, " + f"got {type(history_table).__name__}" + ) def to_sql(self) -> SQLQueryAndParams: sql = "WITH (SYSTEM_VERSIONING = ON" - if self.history_table: - tbl = self.dialect.format_identifier(self.history_table) - if self.history_schema: - schema = self.dialect.format_identifier(self.history_schema) - tbl = f"{schema}.{tbl}" - sql += f", HISTORY_TABLE = {tbl}" + if self.history_table is not None: + sql += f", HISTORY_TABLE = {self.history_table.to_sql()[0]}" sql += ")" return sql, () \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py index 55bfbef..6d3e248 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py @@ -35,6 +35,7 @@ from .set_operation import SQLServerSetOperationMixin from .ddl_table import SQLServerTableMixin from .identifier import SQLServerIdentifierMixin +from .namespace import SQLServerNamespaceMixin from .transaction import SQLServerTransactionMixin from .function import SQLServerFunctionMixin from .ddl_type import SQLServerTypeDDLMixin @@ -76,6 +77,7 @@ "SQLServerSetOperationMixin", "SQLServerTableMixin", "SQLServerIdentifierMixin", + "SQLServerNamespaceMixin", "SQLServerTransactionMixin", "SQLServerFunctionMixin", "SQLServerTypeDDLMixin", diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/backend_mixin.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/backend_mixin.py index 3fa5c9b..9f11827 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/backend_mixin.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/backend_mixin.py @@ -169,8 +169,7 @@ def set_nocount(self, on: bool = True) -> None: cursor.close() def set_identity_insert(self, table: str, on: bool = True) -> None: - state = "ON" if on else "OFF" - sql = f"SET IDENTITY_INSERT {self.dialect.format_identifier(table)} {state}" + sql, _params = self.dialect.format_set_identity_insert(table, on) cursor = self._get_cursor() cursor.execute(sql) cursor.close() diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/columnstore.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/columnstore.py index 660d4d9..6ba5590 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/columnstore.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/columnstore.py @@ -11,6 +11,8 @@ from typing import Tuple, TYPE_CHECKING +from rhosocial.activerecord.backend.expression.objects import Index, Table + if TYPE_CHECKING: from ..expression.columnstore import SQLServerColumnstoreIndexExpression @@ -43,6 +45,19 @@ def format_create_columnstore_index_statement( SQL Syntax: CREATE [CLUSTERED | NONCLUSTERED] COLUMNSTORE INDEX name ON table [ (columns) ] [ ORDER (order_columns) ] + + ``expr.index_name`` and ``expr.table_name`` are *not* stale attributes. + They were listed as such because a reader compared them with the + :class:`Index` and :class:`Table` objects ``CREATE INDEX`` takes, and + found no field of that name. Both belong to + :class:`SQLServerColumnstoreIndexExpression`, which names its target as + text; each is turned into the matching object and rendered through the + one qualified-name entry point, so the bracket-free shape below is the + only shape the statement can produce. + + Raises: + UnsupportedFeatureError: The requested columnstore form needs a + newer SQL Server than the dialect is configured for. """ parts = ["CREATE"] @@ -65,9 +80,11 @@ def format_create_columnstore_index_statement( parts.append("COLUMNSTORE") parts.append("INDEX") - parts.append(self.format_identifier(expr.index_name)) # type: ignore[attr-defined] + # A columnstore index is an index living in the same schema as its + # table, and both names go through the one qualified-name entry point. + parts.append(self.qualified_object_name(Index, expr.index_name)) parts.append("ON") - parts.append(self.format_identifier(expr.table_name)) # type: ignore[attr-defined] + parts.append(self.qualified_object_name(Table, expr.table_name)) if expr.columns: columns_str = ", ".join( diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_database.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_database.py index a833819..bc14af1 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_database.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_database.py @@ -4,6 +4,8 @@ from typing import Tuple, TYPE_CHECKING +from rhosocial.activerecord.backend.expression.objects import Database + if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements.ddl_database import ( AlterDatabaseExpression, @@ -46,8 +48,20 @@ def supports_database_tablespace(self) -> bool: def format_create_database_statement( self, expr: CreateDatabaseExpression ) -> Tuple[str, tuple]: + """Format CREATE DATABASE for SQL Server. + + Raises: + TypeError: ``expr.database`` is not a Database. A schema or a table + handed here would render as a well-formed ``CREATE DATABASE`` + over that object's name. + """ + if not isinstance(expr.database, Database): + raise TypeError( + f"CreateDatabaseExpression.database must be a Database, " + f"got {type(expr.database).__name__}" + ) parts = ["CREATE DATABASE"] - parts.append(self.format_identifier(expr.database_name)) + parts.append(expr.database.to_sql()[0]) if expr.collation: parts.append(f"COLLATE {expr.collation}") if expr.owner: @@ -57,20 +71,43 @@ def format_create_database_statement( def format_drop_database_statement( self, expr: DropDatabaseExpression ) -> Tuple[str, tuple]: - parts = ["DROP DATABASE"] - parts.append(self.format_identifier(expr.database_name)) - return " ".join(parts), () + """Format DROP DATABASE for SQL Server. + + Raises: + TypeError: ``expr.database`` is not a Database. A schema handed here + would render as a well-formed ``DROP DATABASE`` over that + schema's name. + """ + if not isinstance(expr.database, Database): + raise TypeError( + f"DropDatabaseExpression.database must be a Database, " + f"got {type(expr.database).__name__}" + ) + return f"DROP DATABASE {expr.database.to_sql()[0]}", () def format_alter_database_statement( self, expr: AlterDatabaseExpression ) -> Tuple[str, tuple]: + """Format ALTER DATABASE for SQL Server. + + Raises: + TypeError: ``expr.database`` is not a Database. A schema handed here + would render as a well-formed ``ALTER DATABASE`` over that + schema's name. + """ from rhosocial.activerecord.backend.expression.statements.ddl_database import AlterDatabaseAction + + if not isinstance(expr.database, Database): + raise TypeError( + f"AlterDatabaseExpression.database must be a Database, " + f"got {type(expr.database).__name__}" + ) + database = expr.database.to_sql()[0] if expr.action == AlterDatabaseAction.OWNER_TO: - database = self.format_identifier(expr.database_name) owner = self.format_identifier(expr.target) return f"ALTER AUTHORIZATION ON DATABASE::{database} TO {owner}", () parts = ["ALTER DATABASE"] - parts.append(self.format_identifier(expr.database_name)) + parts.append(database) if expr.action == AlterDatabaseAction.RENAME_TO: parts.append(f"MODIFY NAME = {self.format_identifier(expr.target)}") elif expr.action == AlterDatabaseAction.COLLATION: diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_type.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_type.py index 7ffe62e..e5dfcaf 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_type.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_type.py @@ -9,6 +9,7 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.dialect.mixins.user_defined_type import UserDefinedTypeMixin from rhosocial.activerecord.backend.expression.bases import ToSQLProtocol +from rhosocial.activerecord.backend.expression.objects import Index, Type from rhosocial.activerecord.backend.expression.statements.ddl_table import ( ColumnConstraintType, ColumnDefinition, @@ -47,9 +48,6 @@ class SQLServerTypeDDLMixin(UserDefinedTypeMixin): _version: Optional[Tuple[int, int, int]] deployment_target: str - def format_identifier(self, identifier: str, need_quote: bool = True) -> str: - ... - def format_column_definition( self, column: ColumnDefinition, @@ -183,35 +181,47 @@ def supports_memory_optimized_table_types(self) -> bool: def supports_rename_type(self) -> bool: return self._version_at_least(SQL_SERVER_2005) - def _format_type_name( + def _type_object_name( self, type_name: str, schema_name: Optional[str] = None, ) -> str: + """The qualified SQL name of a user-defined TYPE. + + ``sp_rename`` names the type as text -- its first argument is a string + literal -- so this is the one place a TYPE name still arrives as a name + plus a separate schema. The name is built as a :class:`Type` object and + rendered through the shared qualified-name entry point rather than + joined here. + + A ``.`` inside ``type_name`` is part of the identifier, not a + namespace separator: SQL Server allows dots in an object name as long + as the name is delimited, which is exactly what bracketing does. + + Raises: + TypeError: ``schema_name`` is present and is not a string. + ValueError: Either part is present and empty. + """ if not isinstance(type_name, str) or not type_name.strip(): raise ValueError("type_name must be a non-empty string") - parts: List[str] = [] if schema_name is not None: if not isinstance(schema_name, str): raise TypeError("schema_name must be a string or None") if not schema_name.strip(): raise ValueError("schema_name must contain a non-empty identifier") - parts.append(self.format_identifier(schema_name)) - if not type_name.strip(): - raise ValueError("type_name must contain a non-empty identifier") - parts.append(self.format_identifier(type_name)) - return ".".join(parts) - - def _raw_type_name( - self, - type_name: str, - schema_name: Optional[str] = None, - ) -> str: - formatted = self._format_type_name(type_name, schema_name) - return formatted + return self.qualified_object_name(Type, type_name, schema_name=schema_name) @staticmethod def _format_nstring(value: str) -> str: + """*value* as a national-character SQL string literal. + + ``sp_rename`` takes its arguments as literals, so its arguments are text + by definition rather than by caller intent. + + Raises: + TypeError: ``value`` is not a string. Anything else would reach + ``str.replace`` or render as a literal of the wrong sort. + """ if not isinstance(value, str): raise TypeError("sp_rename arguments must be strings") return f"N'{value.replace(chr(39), chr(39) * 2)}'" @@ -221,6 +231,13 @@ def _format_table_type_column( column: ColumnDefinition, memory_optimized: bool, ) -> Tuple[str, tuple]: + """Render one column of a table TYPE. + + Raises: + TypeError: ``column`` is not a ColumnDefinition. The definition is + built from a declared list rather than named one at a time, so a + wrong kind here would render as a well-formed column. + """ if not isinstance(column, ColumnDefinition): raise TypeError("table TYPE columns must be ColumnDefinition instances") if getattr(column, "comment", None) is not None: @@ -289,6 +306,11 @@ def _format_table_type_constraint( constraint: TableConstraint, memory_optimized: bool, ) -> Tuple[str, tuple]: + """Render one constraint of a table TYPE. + + Raises: + TypeError: ``constraint`` is not a TableConstraint. + """ if not isinstance(constraint, TableConstraint): raise TypeError("table TYPE constraints must be TableConstraint instances") if memory_optimized: @@ -362,6 +384,11 @@ def _format_table_type_index( index: IndexDefinition, memory_optimized: bool, ) -> Tuple[str, tuple]: + """Render one index of a table TYPE. + + Raises: + TypeError: ``index`` is not an IndexDefinition. + """ if not isinstance(index, IndexDefinition): raise TypeError("table TYPE indexes must be IndexDefinition instances") if not isinstance(index.name, str) or not index.name.strip(): @@ -439,7 +466,7 @@ def _format_table_type_index( parts: List[str] = [] if index.unique: parts.append("UNIQUE") - parts.extend(("INDEX", self.format_identifier(index.name))) + parts.extend(("INDEX", self.qualified_object_name(Index, index.name))) if hash_index: parts.append("NONCLUSTERED HASH") elif index_type: @@ -558,6 +585,14 @@ def format_create_type_statement( self, expr: CreateTypeExpression, ) -> Tuple[str, tuple]: + """Format CREATE TYPE for SQL Server. + + Raises: + TypeError: ``expr`` is not a CreateTypeExpression. + TypeError: ``expr.type`` is not a Type. A table handed here would + render as a well-formed ``CREATE TYPE`` over that table's name. + TypeError: ``expr.definition`` is not a TypeDefinition. + """ if not isinstance(expr, CreateTypeExpression): raise TypeError("expr must be a CreateTypeExpression") if expr.if_not_exists and expr.or_replace: @@ -568,6 +603,11 @@ def format_create_type_statement( raise UnsupportedFeatureError(self.name, "CREATE TYPE IF NOT EXISTS") if expr.or_replace and not self.supports_create_type_or_replace(): raise UnsupportedFeatureError(self.name, "CREATE OR REPLACE TYPE") + if not isinstance(expr.type, Type): + raise TypeError( + f"CreateTypeExpression.type must be a Type, " + f"got {type(expr.type).__name__}" + ) if not isinstance(expr.definition, TypeDefinition): raise TypeError("definition must be a TypeDefinition instance") if not self.supports_type_definition(type(expr.definition)): @@ -578,13 +618,23 @@ def format_create_type_statement( definition_sql, definition_params = expr.definition.to_sql() if definition_params: raise ValueError("CREATE TYPE must render without bind parameters") - name_sql = self._format_type_name(expr.type_name, expr.schema_name) - return f"CREATE TYPE {name_sql} {definition_sql}", () + return f"CREATE TYPE {expr.type.to_sql()[0]} {definition_sql}", () def format_alter_type_statement( self, expr: AlterTypeExpression, ) -> Tuple[str, tuple]: + """Report that SQL Server has no ALTER TYPE. + + SQL Server has no ALTER TYPE statement: a TYPE's definition is fixed at + creation, so the only route is to drop and recreate it. The kind check + runs first because the refusal is about the statement, and a statement of + the wrong kind should not be reported as a missing feature. + + Raises: + TypeError: ``expr`` is not an AlterTypeExpression. + UnsupportedFeatureError: Always, once *expr* is the right kind. + """ if not isinstance(expr, AlterTypeExpression): raise TypeError("expr must be an AlterTypeExpression") raise UnsupportedFeatureError( @@ -597,8 +647,20 @@ def format_drop_type_statement( self, expr: Union[DropTypeExpression, SQLServerDropTypeExpression], ) -> Tuple[str, tuple]: + """Format DROP TYPE for SQL Server. + + Raises: + TypeError: ``expr`` is not a DropTypeExpression. + TypeError: ``expr.type`` is not a Type. A table handed here would + render as a well-formed ``DROP TYPE`` over that table's name. + """ if not isinstance(expr, DropTypeExpression): raise TypeError("expr must be a DropTypeExpression") + if not isinstance(expr.type, Type): + raise TypeError( + f"DropTypeExpression.type must be a Type, " + f"got {type(expr.type).__name__}" + ) if not self.supports_type_objects() or not self.supports_drop_type(): raise UnsupportedFeatureError(self.name, "DROP TYPE") if expr.if_exists and not self.supports_drop_type_if_exists(): @@ -618,13 +680,20 @@ def format_drop_type_statement( parts = ["DROP TYPE"] if expr.if_exists: parts.append("IF EXISTS") - parts.append(self._format_type_name(expr.type_name, expr.schema_name)) + parts.append(expr.type.to_sql()[0]) return " ".join(parts), () def format_type_definition( self, expr: TypeDefinition, ) -> Tuple[str, tuple]: + """Render the body of a CREATE TYPE, whatever kind of definition it is. + + Raises: + TypeError: ``expr`` is not a TypeDefinition. CREATE TYPE holds the + definition in its own slot rather than naming one, so a wrong kind + here would render as a well-formed type body. + """ if not isinstance(expr, TypeDefinition): raise TypeError("expr must be a TypeDefinition instance") if not self.supports_type_definition(type(expr)): @@ -661,6 +730,12 @@ def format_type_alter_action( self, expr: TypeAlterAction, ) -> Tuple[str, tuple]: + """Report that SQL Server has no ALTER TYPE, for an action asked directly. + + Raises: + TypeError: ``expr`` is not a TypeAlterAction. + UnsupportedFeatureError: Always, once *expr* is the right kind. + """ if not isinstance(expr, TypeAlterAction): raise TypeError("expr must be a TypeAlterAction instance") raise UnsupportedFeatureError( @@ -672,6 +747,20 @@ def format_rename_type_statement( self, expr: SQLServerRenameTypeExpression, ) -> Tuple[str, tuple]: + """Format a TYPE rename as ``sp_rename``. + + ``expr.type_name`` and ``expr.schema_name`` are *not* stale attributes. + They were listed as such because a reader compared this formatter with + the ``Type`` object ``CREATE TYPE`` takes, and found no field of that + name. Both belong to :class:`SQLServerRenameTypeExpression`: ``sp_rename`` + names its target as a string literal, so this statement never took the + object. The name is turned into a :class:`Type` and rendered through the + shared entry point by :meth:`_type_object_name` precisely so that the + literal is spelled the way every other name is. + + Raises: + TypeError: ``expr`` is not a SQLServerRenameTypeExpression. + """ if not isinstance(expr, SQLServerRenameTypeExpression): raise TypeError("expr must be a SQLServerRenameTypeExpression") if not self.supports_rename_type(): @@ -680,7 +769,7 @@ def format_rename_type_statement( "RENAME TYPE", "requires SQL Server 2005+.", ) - old_name = self._raw_type_name(expr.type_name, expr.schema_name) + old_name = self._type_object_name(expr.type_name, expr.schema_name) return ( f"EXECUTE sp_rename {self._format_nstring(old_name)}, " f"{self._format_nstring(expr.new_name)}, N'USERDATATYPE'", diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_view.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_view.py index c8a9d41..7c9dc1f 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_view.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_view.py @@ -2,6 +2,7 @@ from typing import Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.objects import View from .version_constants import SQL_SERVER_2005, SQL_SERVER_2016 _SUGGESTION_MATERIALIZED_VIEW = "SQL Server does not support materialized views. Consider using indexed views." @@ -40,13 +41,25 @@ def supports_view_check_option(self) -> bool: def format_create_view_statement( self, expr: "CreateViewExpression" ) -> Tuple[str, tuple]: - """Format CREATE VIEW statement for SQL Server.""" - parts = ["CREATE VIEW"] - - if expr.replace: - parts = ["CREATE OR ALTER VIEW"] - - parts.append(self.format_identifier(expr.view_name)) + """Format CREATE VIEW statement for SQL Server. + + SQL Server spells the replacement form ``CREATE OR ALTER VIEW``, and + ``WITH SCHEMABINDING`` is an indexed view's requirement rather than an + option -- see ``format_create_indexed_view_statement``. + + Raises: + TypeError: ``expr.view`` is not a View. A table or a materialized + view handed here would render as a well-formed ``CREATE VIEW`` + over that object's name. + """ + if not isinstance(expr.view, View): + raise TypeError( + f"CreateViewExpression.view must be a View, " + f"got {type(expr.view).__name__}" + ) + parts = ["CREATE OR ALTER VIEW" if expr.replace else "CREATE VIEW"] + + parts.append(expr.view.to_sql()[0]) if expr.column_aliases: cols = ", ".join(self.format_identifier(c) for c in expr.column_aliases) @@ -72,13 +85,27 @@ def format_create_view_statement( def format_drop_view_statement( self, expr: "DropViewExpression" ) -> Tuple[str, tuple]: - """Format DROP VIEW statement for SQL Server.""" + """Format DROP VIEW statement for SQL Server. + + ``IF EXISTS`` is rendered only on SQL Server 2016+; a request for it on + an older version is a no-op rather than a syntax error, which is the + spelling this dialect has always produced. + + Raises: + TypeError: ``expr.view`` is not a View. A table handed here would + render as a well-formed ``DROP VIEW`` over that table's name. + """ + if not isinstance(expr.view, View): + raise TypeError( + f"DropViewExpression.view must be a View, " + f"got {type(expr.view).__name__}" + ) parts = ["DROP VIEW"] if expr.if_exists and self.supports_if_exists_view(): parts.append("IF EXISTS") - parts.append(self.format_identifier(expr.view_name)) + parts.append(expr.view.to_sql()[0]) return " ".join(parts), () diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dml.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dml.py index 4490783..f1dff3b 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dml.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dml.py @@ -1,6 +1,8 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dml.py from typing import Any, List, Tuple, TYPE_CHECKING +from rhosocial.activerecord.backend.expression.objects import Table + if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements import ( InsertExpression, @@ -20,7 +22,17 @@ def format_insert_statement(self, expr: "InsertExpression") -> Tuple[str, tuple] - OUTPUT clause for returning inserted rows - INSERT TOP (n) for limiting rows - DEFAULT VALUES + + Raises: + TypeError: ``expr.into`` is not a Table. A view or a sequence handed + here would render as a well-formed ``INSERT INTO`` over that + object's name. """ + if not isinstance(expr.into, Table): + raise TypeError( + f"InsertExpression.into must be a Table, " + f"got {type(expr.into).__name__}" + ) if self.strict_validation: expr.validate(strict=True) @@ -71,7 +83,17 @@ def format_insert_statement(self, expr: "InsertExpression") -> Tuple[str, tuple] return sql, tuple(all_params) def format_update_statement(self, expr: "UpdateExpression") -> Tuple[str, tuple]: - """Format UPDATE statement with OUTPUT clause support.""" + """Format UPDATE statement with OUTPUT clause support. + + Raises: + TypeError: ``expr.table`` is not a Table. A view handed here would + render as a well-formed ``UPDATE`` over that view's name. + """ + if not isinstance(expr.table, Table): + raise TypeError( + f"UpdateExpression.table must be a Table, " + f"got {type(expr.table).__name__}" + ) if self.strict_validation: expr.validate(strict=True) @@ -103,7 +125,19 @@ def format_update_statement(self, expr: "UpdateExpression") -> Tuple[str, tuple] return " ".join(parts), tuple(all_params) def format_delete_statement(self, expr: "DeleteExpression") -> Tuple[str, tuple]: - """Format DELETE statement with OUTPUT clause support.""" + """Format DELETE statement with OUTPUT clause support. + + Raises: + TypeError: An entry of ``expr.tables`` is not a Table. Any other + object kind would have its own name rendered as a delete + target. + """ + for _table in expr.tables or (): + if not isinstance(_table, Table): + raise TypeError( + f"DeleteExpression.tables must hold Table instances, " + f"got {type(_table).__name__}" + ) if self.strict_validation: expr.validate(strict=True) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/graph.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/graph.py index c80d0b3..e6ef4e0 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/graph.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/graph.py @@ -12,6 +12,7 @@ from typing import Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.objects import NodeTable, Table from .version_constants import SQL_SERVER_2017, SQL_SERVER_2019 @@ -89,8 +90,11 @@ def format_sqlserver_edge_constraint( parts = [] if constraint.name: parts.append(f"CONSTRAINT {self.format_identifier(constraint.name)}") - from_table = self.format_identifier(constraint.from_table) - to_table = self.format_identifier(constraint.to_table) + # The two endpoints are graph *node* tables, so they are rendered as + # NodeTable objects: same three slots, same one rendering path, and the + # kind now says what they are. + from_table = self.qualified_object_name(NodeTable, constraint.from_table) + to_table = self.qualified_object_name(NodeTable, constraint.to_table) parts.append(f"CONNECTION ({from_table} TO {to_table})") return " ".join(parts), () @@ -220,13 +224,24 @@ def format_sqlserver_graph_path_aggregate( def format_sqlserver_for_path_table( self, table: "SQLServerForPathTable" ) -> Tuple[str, tuple]: - """Format a `` FOR PATH [AS ]`` FROM item.""" + """Format a ``
FOR PATH [AS ]`` FROM item. + + A graph node or edge table is an ordinary schema-scoped table, so the + name is rendered as a :class:`Table` object -- catalog, schema and name + -- rather than assembled from the raw ``table`` string. + """ if not self.supports_shortest_path(): raise UnsupportedFeatureError( self.name, "FOR PATH", "FOR PATH requires SQL Server 2019 or later.", ) - sql = f"{self.format_identifier(table.table)} FOR PATH" + table_object = self.schema_object( + Table, + table.table, + catalog_name=table.catalog_name, + schema_name=table.schema_name, + ) + sql = f"{table_object.to_sql()[0]} FOR PATH" if table.alias: sql += f" AS {self.format_identifier(table.alias)}" return sql, () diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/lateral.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/lateral.py index 395518e..c1aef1b 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/lateral.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/lateral.py @@ -1,5 +1,8 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/mixins/lateral.py -from typing import Tuple +from typing import Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.expression.query_sources import LateralExpression class SQLServerLateralMixin: @@ -11,22 +14,47 @@ def supports_lateral_join(self) -> bool: def format_lateral_expression( self, - expr_sql: str, - expr_params: tuple, - alias: str, - join_type: str = "CROSS APPLY", + expr: "LateralExpression", ) -> Tuple[str, tuple]: """Format LATERAL as CROSS APPLY / OUTER APPLY for SQL Server. SQL Server uses CROSS APPLY (equivalent to INNER LATERAL) and OUTER APPLY (equivalent to LEFT LATERAL) instead of LATERAL. + + The parameter is the ``LateralExpression`` node, which is what core's + ``LateralExpression.to_sql()`` passes and what every other dialect's + override of this method takes. It used to take the four pieces the node + is made of -- ``expr_sql, expr_params, alias, join_type`` -- which no + caller has ever done: the node calls the formatter with itself, so every + ``LateralExpression`` on SQL Server raised ``TypeError`` for a missing + argument, and nothing reported it because the round-trip matrix + swallowed every render failure. The pieces are read off the node here. + + Args: + expr: The LateralExpression node, carrying the joined expression, + the join type, and an optional alias. + + Returns: + Tuple of (SQL string, parameters tuple). """ - if join_type.upper() in ("LEFT", "LEFT JOIN", "LEFT OUTER JOIN"): + if not self.supports_lateral_join(): + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + + raise UnsupportedFeatureError( + self.name, + "LATERAL join", + "Restructure the query with a plain subquery or a CTE instead.", + ) + expr_sql, expr_params = expr.expression.to_sql() + join_type = (expr.join_type or "INNER").upper() + if join_type in ("LEFT", "LEFT JOIN", "LEFT OUTER JOIN"): apply_type = "OUTER APPLY" else: apply_type = "CROSS APPLY" sql = f"{apply_type} ({expr_sql})" - if alias: - sql += f" AS {self.format_identifier(alias)}" + if expr.alias is not None: + sql += f" AS {self.format_identifier(expr.alias)}" return sql, expr_params diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/memory_optimized.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/memory_optimized.py index 4aaf0cd..91f7470 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/memory_optimized.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/memory_optimized.py @@ -15,6 +15,8 @@ from typing import Optional, Sequence +from rhosocial.activerecord.backend.expression.objects import Index + _SQL_SERVER_MEMORY_OPTIMIZED_VERSION = (12, 0, 0) _DURABILITY_OPTIONS = ("SCHEMA_ONLY", "SCHEMA_AND_DATA") @@ -95,7 +97,7 @@ def format_hash_index_definition( if unique: parts.append("UNIQUE") if name: - parts.append(f"INDEX {self.format_identifier(name)}") # type: ignore[attr-defined] + parts.append(f"INDEX {self.qualified_object_name(Index, name)}") columns_str = ", ".join( self.format_identifier(col) # type: ignore[attr-defined] for col in columns diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/namespace.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/namespace.py new file mode 100644 index 0000000..eaa3649 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/namespace.py @@ -0,0 +1,347 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/namespace.py +"""SQL Server namespaces: which levels a name carries, and how it is validated. + +SQL Server is the only engine in this family that renders all three namespace +slots at once -- ``[catalog].[schema].[name]`` -- so this backend is where the +schema-object layer gets exercised end to end. The rendering itself is core's: +each object kind declares a ``format__object`` and the matching +``*NameMixin`` implements it on top of +:meth:`NamespaceMixin.format_qualified_name`. +What this mixin supplies is everything core leaves to the engine -- that a +catalog exists above the schema, that both are rendered, and that an identifier +is capped at 128 characters. + +Two names exist for one object, and each is a named method rather than a string +built inside a formatter: + +``Object.to_sql()`` + Bracketed: ``[catalog].[schema].[name]``. The ordinary identifier form, + reached through the per-kind formatter. +``format_object_id_name`` + Bare: ``catalog.schema.name``. ``OBJECT_ID()`` takes a string literal + naming an object, not a T-SQL identifier, and SQL Server does not strip + brackets from it -- ``OBJECT_ID(N'[dbo].[t]', N'U')`` is NULL. This is the + single deliberate exception, and it is documented at the method that + produces it. + +Core's ``NamespaceMixin`` answers two questions and keeps them apart. +``validate_namespace`` accepts or refuses the namespace levels an object +carries; ``format_qualified_name`` spells the name, joining the levels with +``separator``. SQL Server wants the default ``.`` -- the brackets come from +``format_identifier``, not from the join -- so this mixin inherits the +separator rather than restating it, and overrides only what SQL Server knows +that core does not: that both levels exist, that both are rendered, and that an +identifier is capped at 128 characters whatever level it sits at. + +Core offers no third operation, and the ``OBJECT_ID`` call site needs one: +join the name *without* quoting each level. That gap is why +:attr:`object_id_separator` and :meth:`format_unquoted_qualified_name` exist +here rather than in core. The reasoning is recorded at both. + +The helpers that build objects from a flat name live here too, because several +SQL Server-specific expressions (partition functions and schemes, routines, +triggers, ``NEXT VALUE FOR``) are handed their target as text rather than as an +object, and the namespace has to be read out of that text exactly once. +""" + +from typing import Optional, Type + +from rhosocial.activerecord.backend.dialect.mixins.schema_namespace import NamespaceMixin +from rhosocial.activerecord.backend.expression.objects import SchemaObject + +from ..expression.objects import SQLServerTable + +#: A qualified name has at most three parts: catalog, schema, name. +_MAX_QUALIFIED_PARTS = 3 + +#: SQL Server caps every identifier -- catalog, schema and name alike -- at +#: 128 characters. +_SQL_SERVER_MAX_IDENTIFIER_LENGTH = 128 + + +class SQLServerNamespaceMixin(NamespaceMixin): + """SQL Server's answer to which namespace levels a name may carry.""" + + #: What goes between the levels of the bare name ``OBJECT_ID`` reads. + #: + #: ``NamespaceMixin.separator`` governs *T-SQL identifiers*, where the + #: character between ``[catalog]`` and ``[schema]`` is a period. It is not + #: the right source for this, because ``OBJECT_ID`` is handed a string + #: literal rather than an identifier and SQL Server does not treat the + #: period as T-SQL syntax there -- it parses the literal's text into + #: namespace parts. The character happens to be a period in both places, + #: and it is kept separate on purpose so that a future dialect which spells + #: identifiers with a different join cannot silently change the shape of a + #: string literal, and so that no one reads ``separator`` as the reason + #: ``OBJECT_ID`` cannot have brackets. + object_id_separator: str = "." + + # ------------------------------------------------------------------ + # NamespaceSupport + # ------------------------------------------------------------------ + + def supports_catalog(self) -> bool: + """SQL Server models a catalog above the schema (the database).""" + return True + + def supports_catalog_qualification(self) -> bool: + """SQL Server renders the catalog when a name carries one.""" + return True + + def supports_schema_qualification(self) -> bool: + """SQL Server renders the schema when a name carries one. + + Distinct from :meth:`supports_schema`, which asks whether the engine has + schemas at all -- ``dbo`` does, and ``CREATE SCHEMA`` is spelled. This + one asks the naming question, and SQL Server qualifies with the schema + whether or not the caller supplies one: ``[catalog].[schema].[name]`` is + the shape every object name takes. + """ + return True + + def validate_catalog_name(self, obj: object) -> None: + """Accept or reject the catalog ``obj`` carries. + + SQL Server caps every identifier at 128 characters, and a catalog is an + ordinary identifier as far as that limit is concerned. ``tempdb``-style + names are fine; nothing about the outer namespace is special, so no + other rule is applied here. + """ + self._validate_identifier_length(getattr(obj, "catalog_name", None), "catalog_name") + + def validate_schema_name(self, obj: object) -> None: + """Accept or reject the schema ``obj`` carries. + + The same 128-character cap applies; the inner namespace is no more + exempt than the outer one. + """ + self._validate_identifier_length(getattr(obj, "schema_name", None), "schema_name") + + @staticmethod + def _validate_identifier_length(value: Optional[str], field_name: str) -> None: + """Reject a namespace part SQL Server could not store.""" + if value is not None and len(value) > _SQL_SERVER_MAX_IDENTIFIER_LENGTH: + raise ValueError( + f"SQL Server identifiers are limited to " + f"{_SQL_SERVER_MAX_IDENTIFIER_LENGTH} characters; " + f"{field_name} is {len(value)} characters" + ) + + # ------------------------------------------------------------------ + # Building objects out of a flat name + # ------------------------------------------------------------------ + + def schema_object( + self, + kind: Type[SchemaObject], + name: str, + *, + catalog_name: Optional[str] = None, + schema_name: Optional[str] = None, + catalog_need_quote: bool = True, + schema_need_quote: bool = True, + name_need_quote: bool = True, + temporary: bool = False, + ) -> SchemaObject: + """The ``kind`` object called ``name`` in the given namespaces. + + The dialect is the object's first constructor argument, as on every + other expression, so this helper supplies it. + + Args: + kind: The object class naming *what* this is: ``Table``, + ``View``, ``Index``, ``Sequence``, ``Type``, ``Procedure``, + ``Function``, ``SQLServerTable``, ... + name: The object's own name, unqualified. + catalog_name: Outer namespace, or ``None`` for the current + database. + schema_name: Inner namespace, or ``None`` for the default schema. + catalog_need_quote: Whether the catalog is quoted when rendered. + schema_need_quote: Whether the schema is quoted when rendered. + name_need_quote: Whether the name is quoted when rendered. + temporary: Whether the table is a ``#``-prefixed temporary. Only + :class:`SQLServerTable` can be temporary, and asking any other + kind is an error rather than a silently ignored flag. + + Returns: + The schema object; it carries no SQL. + + Raises: + TypeError: ``temporary`` was requested for a kind that has no such + notion. + ValueError: A slot is present but not a usable identifier. + """ + if temporary and kind is not SQLServerTable: + raise TypeError( + f"temporary=True is only meaningful for SQLServerTable, " + f"not for {kind.__name__}" + ) + slots = { + "catalog_name": catalog_name, + "schema_name": schema_name, + "catalog_need_quote": catalog_need_quote, + "schema_need_quote": schema_need_quote, + "name_need_quote": name_need_quote, + } + if temporary: + return kind(self, name, temporary=True, **slots) + return kind(self, name, **slots) + + def qualified_object_name( + self, + kind: Type[SchemaObject], + name: str, + **slots, + ) -> str: + """The bracketed SQL name of the ``kind`` object called ``name``. + + This is the bridge for the SQL Server-specific expressions that are + handed their target as text: the object is built, then rendered through + its own ``format__object``, which is the same path a statement + takes for an object it was given directly. ``slots`` are the keyword + arguments of :meth:`schema_object`. + """ + return self.schema_object(kind, name, **slots).to_sql()[0] + + def qualified_object_name_from_dotted( + self, + kind: Type[SchemaObject], + name: str, + *, + catalog_name: Optional[str] = None, + schema_name: Optional[str] = None, + **slots, + ) -> str: + """The bracketed SQL name of a flat name that may carry its namespace. + + Several SQL Server-specific expressions accept the namespace written + inline -- ``"dbo.pf_sales"`` is a documented way to spell a + schema-qualified partition function. This is the one place a namespace + is read back out of text: the parts fill the object's fixed slots from + the right, and the object is then rendered like every other name. + + Args: + kind: The object class naming what the name refers to. + name: One, two or three dot-separated parts. + catalog_name: Outer namespace, when the caller supplies it + separately. + schema_name: Inner namespace, when the caller supplies it + separately. + **slots: Remaining :meth:`schema_object` keyword arguments. + + Raises: + ValueError: The name has more than three parts, has an empty part, + or supplies a namespace slot the caller also passed + differently. A name that resolves two ways is reported rather + than resolved one way or the other. + """ + if not isinstance(name, str) or not name.strip(): + raise ValueError("name must be a non-empty string") + parts = name.split(".") + if len(parts) > _MAX_QUALIFIED_PARTS: + raise ValueError( + f"{name!r} has {len(parts)} qualified parts; SQL Server has at " + f"most {_MAX_QUALIFIED_PARTS} (catalog, schema, name)" + ) + if any(not part.strip() for part in parts): + raise ValueError(f"{name!r} must contain non-empty qualified parts") + dotted_catalog = dotted_schema = None + if len(parts) == _MAX_QUALIFIED_PARTS: + dotted_catalog, dotted_schema, name = parts + elif len(parts) == 2: + dotted_schema, name = parts + if dotted_catalog is not None: + if catalog_name is not None and catalog_name != dotted_catalog: + raise ValueError( + f"catalog_name={catalog_name!r} disagrees with the " + f"catalog {dotted_catalog!r} in the name" + ) + catalog_name = dotted_catalog + if dotted_schema is not None: + if schema_name is not None and schema_name != dotted_schema: + raise ValueError( + f"schema_name={schema_name!r} disagrees with the schema " + f"{dotted_schema!r} in the name" + ) + schema_name = dotted_schema + return self.qualified_object_name( + kind, name, catalog_name=catalog_name, schema_name=schema_name, **slots + ) + + # ------------------------------------------------------------------ + # The one unbracketed shape + # ------------------------------------------------------------------ + # + # The decision this section records, and why the obvious answers are wrong. + # + # Core, before ``separator`` existed, spelled a qualified name with a period + # hard-coded in the middle of one method that also did all the checking. + # ``format_object_id_name`` could not use that method, because it needs a + # name with no brackets, and it worked around the exclusion by joining the + # slots itself: ``".".join(...)``, bypassing the rendering *and* the + # checks. That is a policy leak with a live failure mode -- if the join were + # ever lost, or if a slot were added, ``OBJECT_ID`` would return NULL for a + # table that plainly exists, no test would notice, and no statement would + # run. + # + # With L3 that is no longer the only two-way door, but the obvious repair is + # still wrong. Deleting the method and calling + # ``format_qualified_name(expr)`` looks right and is not: that method quotes + # every level, so ``OBJECT_ID(N'[dbo].[t]')`` comes straight back -- the + # exact NULL the method exists to prevent. So the requirement is a third + # operation, not one of the existing two. + # + # It belongs to the dialect rather than to core, for two reasons. Core's two + # operations are about SQL text, and neither has a use for a bare name; this + # is the only call site in the tree that wants one, and it is SQL Server's + # ``OBJECT_ID`` that imposes it. Putting it in core would give nine dialects + # a method none of them calls -- the same "declared but never dispatched" + # shape core already retired twice. Declaring it here keeps the knowledge + # with the engine that has it, and gives the conformance tests somewhere to + # point. + # + # What the method deliberately does *not* do is skip the check. + # ``validate_namespace`` runs first, so an object carrying a level SQL + # Server cannot express is refused rather than quietly spelled without it. + + def format_unquoted_qualified_name(self, obj: SchemaObject) -> str: + """*obj*'s qualified name as bare text, with no level quoted. + + The third operation, and the one ``OBJECT_ID`` needs: the same levels + and the same order as :meth:`~.NamespaceMixin.format_qualified_name`, + joined by :attr:`object_id_separator` and with each part emitted + verbatim. + + Args: + obj: The object being named. + + Returns: + The bare name, e.g. ``"dbo.t"`` or ``"sales.dbo.t"``. ``params`` is + absent because this is text, not SQL. + """ + parts = [part for part in (obj.catalog_name, obj.schema_name, obj.name) if part] + return self.object_id_separator.join(parts) + + def format_object_id_name(self, obj: SchemaObject) -> str: + """The ``OBJECT_ID()`` argument naming ``obj``: ``catalog.schema.name``. + + ``OBJECT_ID`` does not take an identifier. It takes a string literal + naming an object, and SQL Server compares it literally, so brackets + inside the literal stop it resolving -- ``IF OBJECT_ID(N'[dbo].[t]', + N'U') IS NULL`` is true for a table that plainly exists. This method + therefore renders the same object's slots as bare text, and it is the + only place in the backend that does so. + + It does not build the text itself: the spelling is + :meth:`format_unquoted_qualified_name`, and the checks are core's + :meth:`~.NamespaceMixin.validate_namespace`. Both used to be bypassed + here, which is what made a regression in either invisible. + + The ``OBJECT_ID`` caller is responsible for escaping the result as an + SQL string literal; this method produces the name, not the literal. + """ + self.validate_namespace(obj) + return self.format_unquoted_qualified_name(obj) + + +__all__ = ["SQLServerNamespaceMixin"] \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/partition.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/partition.py index 9051b15..0333259 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/partition.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/partition.py @@ -16,6 +16,8 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from ..expression.objects import PartitionFunction, PartitionScheme + if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements import PartitionClause from ..expression.partition import ( @@ -125,11 +127,55 @@ def format_partition_clause(self, expr: "PartitionClause") -> Tuple[str, tuple]: scheme = getattr(expr, "partition_scheme", None) if scheme: - return f" ON {self.format_identifier(scheme)} ({', '.join(parts)})", tuple(params) # type: ignore[attr-defined] + # The scheme is a named object of its own, not part of the table's + # name: ``ON ()`` names the scheme, and the table + # this clause hangs off is named elsewhere in CREATE TABLE. + return ( + f" ON {self.qualified_object_name(PartitionScheme, scheme)}" + f" ({', '.join(parts)})", + tuple(params), + ) keys_sql = ", ".join(parts) return f" ON partition_scheme_name ({keys_sql})", tuple(params) + # --- Rendering SQL Server's own named objects ----------------------- + # + # Partition functions and schemes are named catalogue entries with no + # counterpart in the shared object tree, so the dialect supplies their two + # formatters. Each is the same two calls every core ``*NameMixin`` makes: + # refuse a namespace level this engine cannot express, then ask for the + # spelling. They used to open with ``self.render_namespace(...)`` and + # re-append the name themselves; core split that method into exactly these + # two halves when the join character became the dialect's ``separator``, so + # repeating the spelling here would have re-pinned the policy this backend + # exists to express. + # + # The parameter types name the two kinds rather than ``Any``, for the same + # reason the core mixins do: a partition scheme handed where a function + # belongs would otherwise render as a well-formed statement naming the + # wrong object. + + def format_partition_function_object(self, expr: PartitionFunction) -> Tuple[str, tuple]: + """Render *expr* as a partition function name. + + Raises: + UnsupportedFeatureError: *expr* carries a namespace level SQL + Server cannot express. + """ + self.validate_namespace(expr) + return self.format_qualified_name(expr) + + def format_partition_scheme_object(self, expr: PartitionScheme) -> Tuple[str, tuple]: + """Render *expr* as a partition scheme name. + + Raises: + UnsupportedFeatureError: *expr* carries a namespace level SQL + Server cannot express. + """ + self.validate_namespace(expr) + return self.format_qualified_name(expr) + def format_sqlserver_partition_function( self, expr: "SQLServerPartitionFunctionExpression" ) -> Tuple[str, tuple]: @@ -138,18 +184,22 @@ def format_sqlserver_partition_function( SQL Syntax: CREATE PARTITION FUNCTION function_name (data_type) AS RANGE [LEFT | RIGHT] FOR VALUES (v1, v2, ...) + + ``expr.function_name`` is *not* a stale attribute. It was listed as one + because a reader compared this formatter with the ``Table``-shaped + members core objectified, and found no field of that name. It belongs to + :class:`SQLServerPartitionFunctionExpression`: a partition function is + handed its name as text, optionally dotted, and + ``qualified_object_name_from_dotted`` is what turns that into a + :class:`PartitionFunction` rendered the way every other kind is. """ boundary_parts = [] for value in expr.boundary_values: boundary_parts.append(self.inline_sql_literal(value)) - func_name = expr.function_name - # Support schema-qualified names - if "." in func_name: - func_parts = func_name.split(".") - quoted = ".".join(self.format_identifier(p) for p in func_parts) # type: ignore[attr-defined] - else: - quoted = self.format_identifier(func_name) # type: ignore[attr-defined] + quoted = self.qualified_object_name_from_dotted( + PartitionFunction, expr.function_name + ) data_type = expr.data_type direction = expr.range_direction.value @@ -169,9 +219,15 @@ def format_sqlserver_partition_scheme( CREATE PARTITION SCHEME scheme_name AS PARTITION function_name [ALL] TO (filegroup1, filegroup2, ...) + + ``expr.function_name`` is the same deliberate text field as the one in + :meth:`format_sqlserver_partition_function`: the partition function is + named by text and objectified at the rendering boundary. """ - scheme_name = self.format_identifier(expr.scheme_name) # type: ignore[attr-defined] - func_name = self.format_identifier(expr.function_name) # type: ignore[attr-defined] + scheme_name = self.qualified_object_name(PartitionScheme, expr.scheme_name) + func_name = self.qualified_object_name_from_dotted( + PartitionFunction, expr.function_name + ) if expr.all_filegroup: fg_quoted = self.format_identifier(expr.all_filegroup) # type: ignore[attr-defined] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/protocol_support.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/protocol_support.py index 302c0ea..9b5b19f 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/protocol_support.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/protocol_support.py @@ -15,6 +15,7 @@ from typing import Any, Dict, List, Optional, Tuple from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.objects import Index, Table, View class SQLServerProtocolSupportMixin: @@ -107,6 +108,11 @@ def format_create_fulltext_index_statement( SQL Syntax: CREATE FULLTEXT INDEX ON table (column, ...) KEY INDEX unique_index ON catalog_name + + The table and the key index are schema-scoped objects and go through the + shared qualified-name entry point. The full-text catalog is **not** a + schema object: SQL Server scopes it to the database, not to a schema, + so it stays a plain identifier. """ self.check_feature_support( "supports_fulltext_catalog", @@ -115,8 +121,10 @@ def format_create_fulltext_index_statement( ) columns_str = ", ".join(self.format_identifier(col) for col in columns) sql = ( - f"CREATE FULLTEXT INDEX ON {self.format_identifier(table)} " - f"({columns_str}) KEY INDEX {self.format_identifier(key_index)} " + f"CREATE FULLTEXT INDEX ON " + f"{self.qualified_object_name(Table, table)} " + f"({columns_str}) KEY INDEX " + f"{self.qualified_object_name(Index, key_index)} " f"ON {self.format_identifier(catalog_name)}" ) return sql, () @@ -132,7 +140,10 @@ def format_drop_fulltext_index_statement(self, table: str) -> Tuple[str, tuple]: "FULLTEXT indexes", "requires SQL Server 2005+.", ) - return f"DROP FULLTEXT INDEX ON {self.format_identifier(table)}", () + return ( + f"DROP FULLTEXT INDEX ON {self.qualified_object_name(Table, table)}", + (), + ) def format_contains_predicate(self, column: str, search_string: str) -> Tuple[str, tuple]: """Format a CONTAINS predicate delegating to format_fulltext_match.""" @@ -152,7 +163,7 @@ def format_containstable_function( """Format a CONTAINSTABLE full-text table function.""" escaped = search_string.replace("'", "''") sql = ( - f"CONTAINSTABLE({self.format_identifier(table)}, " + f"CONTAINSTABLE({self.qualified_object_name(Table, table)}, " f"{self.format_identifier(column)}, '{escaped}'" ) if top_n is not None: @@ -275,7 +286,7 @@ def format_select_into_statement(self, expr: Any) -> Tuple[str, tuple]: select_head = full_sql[:from_index] tail = full_sql[from_index:] - into_sql = f"INTO {self.format_identifier(into_table)}" + into_sql = f"INTO {self.qualified_object_name(Table, into_table)}" return f"{select_head} {into_sql}{tail}", tuple(params) def _format_select_head(self, expr: Any) -> str: @@ -360,7 +371,8 @@ def supports_identity_insert(self) -> bool: def format_set_identity_insert(self, table: str, on: bool) -> Tuple[str, tuple]: """Format SET IDENTITY_INSERT table {ON|OFF}.""" state = "ON" if on else "OFF" - return f"SET IDENTITY_INSERT {self.format_identifier(table)} {state}", () + name_sql = self.qualified_object_name(Table, table) + return f"SET IDENTITY_INSERT {name_sql} {state}", () def format_create_indexed_view_statement(self, expr: Any) -> Tuple[str, tuple]: """Format CREATE VIEW WITH SCHEMABINDING (indexed view) (2005+). @@ -379,14 +391,23 @@ def format_create_indexed_view_statement(self, expr: Any) -> Tuple[str, tuple]: Returns: Tuple of (SQL string, params tuple). + + Raises: + TypeError: ``expr.view`` is not a View. A table handed here would + render as a well-formed ``CREATE VIEW`` over that table's name. """ self.check_feature_support( "supports_indexed_view", "indexed views", "requires SQL Server 2005+.", ) + if not isinstance(expr.view, View): + raise TypeError( + f"CreateViewExpression.view must be a View, " + f"got {type(expr.view).__name__}" + ) parts = ["CREATE VIEW"] - parts.append(self.format_identifier(expr.view_name)) + parts.append(expr.view.to_sql()[0]) if expr.column_aliases: cols = ", ".join(self.format_identifier(c) for c in expr.column_aliases) @@ -704,8 +725,10 @@ def format_create_spatial_index(self, expr: Any) -> Tuple[str, tuple]: "requires SQL Server 2008+.", ) sql = ( - f"CREATE SPATIAL INDEX {self.format_identifier(expr.index_name)} " - f"ON {self.format_identifier(expr.table_name)} ({self.format_identifier(expr.column)}) " + f"CREATE SPATIAL INDEX " + f"{self.qualified_object_name(Index, expr.index_name)} " + f"ON {self.qualified_object_name(Table, expr.table_name)} " + f"({self.format_identifier(expr.column)}) " f"WITH (BOUNDING_BOX = (0, 0, 100, 100))" ) return sql, () diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/routine.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/routine.py index f30669b..b7f4069 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/routine.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/routine.py @@ -11,6 +11,8 @@ from typing import Any, List, Optional, Tuple, TYPE_CHECKING +from rhosocial.activerecord.backend.expression.objects import Function, Procedure + if TYPE_CHECKING: from ..expression.ddl.routine import ( SQLServerCreateFunctionExpression, @@ -45,19 +47,26 @@ def supports_drop_function(self) -> bool: """DROP FUNCTION is supported since SQL Server 2005.""" return self.version >= _SQL_SERVER_ROUTINE_VERSION # type: ignore[attr-defined] - def _format_object_name(self, name: str, schema: Optional[str] = None) -> str: - """Quote a (possibly schema-qualified) object name with brackets.""" - parts = [] - if schema: - parts.append(self.format_identifier(schema)) # type: ignore[attr-defined] - if "." in name: - parts.extend( - self.format_identifier(part) # type: ignore[attr-defined] - for part in name.split(".") - ) - else: - parts.append(self.format_identifier(name)) # type: ignore[attr-defined] - return ".".join(parts) + def _routine_object_name( + self, + kind: str, + name: str, + schema: Optional[str] = None, + ) -> str: + """The qualified SQL name of a routine. + + T-SQL spells a routine ``[schema.]name`` -- and ``schema.name`` with the + dot left in is accepted too, which is why the name may arrive either as + one dotted string or as a name plus a separate schema. Both are read + into the same :class:`Procedure` / :class:`Function` object and + rendered through the shared qualified-name entry point; a name that + supplies both ways at once is rejected there rather than resolved one + way or the other. + """ + routine_class = Function if kind.upper() == "FUNCTION" else Procedure + return self.qualified_object_name_from_dotted( + routine_class, name, schema_name=schema + ) @staticmethod def _format_params(parameters: List[Any]) -> str: @@ -66,6 +75,11 @@ def _format_params(parameters: List[Any]) -> str: Accepts either raw parameter strings (``"@x INT"``) or dictionaries with ``name`` / ``type`` keys (matching the core ``CreateFunctionExpression`` convention). + + Raises: + TypeError: A parameter is neither a string nor a dict. A parameter is + part of the statement's signature rather than a bind value, so a + wrong kind cannot render. """ parts = [] for param in parameters: @@ -116,7 +130,7 @@ def format_create_procedure_statement( if getattr(expr, "or_alter", False): parts.append("OR ALTER") parts.append("PROCEDURE") - parts.append(self._format_object_name(name, getattr(expr, "schema", None))) + parts.append(self._routine_object_name("PROCEDURE", name, getattr(expr, "schema", None))) parameters = getattr(expr, "parameters", None) or [] if parameters: @@ -155,7 +169,7 @@ def format_create_function_statement( if getattr(expr, "or_alter", False): parts.append("OR ALTER") parts.append("FUNCTION") - parts.append(self._format_object_name(name, getattr(expr, "schema", None))) + parts.append(self._routine_object_name("FUNCTION", name, getattr(expr, "schema", None))) parameters = getattr(expr, "parameters", None) or [] parts.append(f"({self._format_params(list(parameters))})") @@ -194,5 +208,5 @@ def format_drop_routine_statement( parts = ["DROP", kind] if getattr(expr, "if_exists", False) and self.version >= _SQL_SERVER_DROP_IF_EXISTS_VERSION: # type: ignore[attr-defined] parts.append("IF EXISTS") - parts.append(self._format_object_name(name, getattr(expr, "schema", None))) + parts.append(self._routine_object_name(kind, name, getattr(expr, "schema", None))) return " ".join(parts), () diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/schema.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/schema.py index ffb446a..3271988 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/schema.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/schema.py @@ -1,6 +1,8 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/mixins/schema.py from typing import Tuple, TYPE_CHECKING +from rhosocial.activerecord.backend.expression.objects import Schema + if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements import ( CreateSchemaExpression, @@ -32,9 +34,19 @@ def format_create_schema_statement(self, expr: "CreateSchemaExpression") -> Tupl SQL Server uses CREATE SCHEMA schema_name [AUTHORIZATION owner_name]. Does not support IF NOT EXISTS. + + Raises: + TypeError: ``expr.schema`` is not a Schema. A table handed here + would render as a well-formed ``CREATE SCHEMA`` over that + table's name. """ + if not isinstance(expr.schema, Schema): + raise TypeError( + f"CreateSchemaExpression.schema must be a Schema, " + f"got {type(expr.schema).__name__}" + ) parts = ["CREATE SCHEMA"] - parts.append(self.format_identifier(expr.schema_name)) + parts.append(expr.schema.to_sql()[0]) if expr.authorization: parts.append(f"AUTHORIZATION {self.format_identifier(expr.authorization)}") return " ".join(parts), () @@ -44,7 +56,15 @@ def format_drop_schema_statement(self, expr: "DropSchemaExpression") -> Tuple[st SQL Server requires schema to be empty before dropping. Does not support IF EXISTS or CASCADE. + + Raises: + TypeError: ``expr.schema`` is not a Schema. A table handed here + would render as a well-formed ``DROP SCHEMA`` over that table's + name. """ - parts = ["DROP SCHEMA"] - parts.append(self.format_identifier(expr.schema_name)) - return " ".join(parts), () + if not isinstance(expr.schema, Schema): + raise TypeError( + f"DropSchemaExpression.schema must be a Schema, " + f"got {type(expr.schema).__name__}" + ) + return f"DROP SCHEMA {expr.schema.to_sql()[0]}", () diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/sequence.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/sequence.py index 9a49a5e..199e800 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/sequence.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/sequence.py @@ -9,6 +9,7 @@ from typing import Any, Tuple from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.objects import Sequence _SQL_SERVER_NEXT_VALUE_FOR_VERSION = (11, 0, 0) @@ -43,12 +44,12 @@ def format_next_value_for(self, sequence: Any) -> Tuple[str, tuple]: if sequence_name is None: sequence_name = str(sequence) - if "." in sequence_name: - quoted = ".".join( - self.format_identifier(part) for part in sequence_name.split(".") # type: ignore[attr-defined] - ) - else: - quoted = self.format_identifier(sequence_name) # type: ignore[attr-defined] + # The sequence expressions hold one flat name string, and + # ``NEXT VALUE FOR dbo.my_seq`` is the documented spelling for a + # schema-qualified sequence. The namespace is therefore read out of the + # text once, into a Sequence object, and rendered from there -- the + # formatter itself never re-derives which part is the schema. + quoted = self.qualified_object_name_from_dotted(Sequence, sequence_name) return f"NEXT VALUE FOR {quoted}", () diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/trigger.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/trigger.py index c1d9cf4..fd11d42 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/trigger.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/trigger.py @@ -11,6 +11,7 @@ from typing import Any, Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.objects import Table, Trigger if TYPE_CHECKING: from ..expression.ddl.trigger import ( @@ -96,9 +97,20 @@ def format_create_trigger_statement( if getattr(expr, "or_alter", False): parts.append("OR ALTER") parts.append("TRIGGER") - parts.append(self.format_identifier(name)) # type: ignore[attr-defined] + # A trigger and the table it hangs off are both schema objects; a + # trigger lives in the same schema as its table, so both names go + # through the one qualified-name entry point. + parts.append( + self.qualified_object_name_from_dotted( + Trigger, name, schema_name=getattr(expr, "schema", None) + ) + ) parts.append("ON") - parts.append(self.format_identifier(table)) # type: ignore[attr-defined] + parts.append( + self.qualified_object_name( + Table, table, schema_name=getattr(expr, "schema", None) + ) + ) parts.append(timing) parts.append(", ".join(events)) parts.append(f"AS BEGIN {expr.body} END;") @@ -127,7 +139,11 @@ def format_drop_trigger_statement( parts = ["DROP", "TRIGGER"] if getattr(expr, "if_exists", False) and self.version >= _SQL_SERVER_TRIGGER_IF_EXISTS_VERSION: # type: ignore[attr-defined] parts.append("IF EXISTS") - parts.append(self.format_identifier(name)) # type: ignore[attr-defined] + parts.append( + self.qualified_object_name_from_dotted( + Trigger, name, schema_name=getattr(expr, "schema", None) + ) + ) return " ".join(parts), () # --- Additional trigger capability declarations (moved from dialect.py) --- diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/table.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/table.py index 75351c3..8d617ba 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/table.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/table.py @@ -1,11 +1,11 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/protocols/table.py from typing import Protocol, Tuple, runtime_checkable -from rhosocial.activerecord.backend.dialect.protocols import TableSupport +from rhosocial.activerecord.backend.dialect.protocols import TableObjectSupport @runtime_checkable -class SQLServerTableSupport(TableSupport, Protocol): +class SQLServerTableSupport(TableObjectSupport, Protocol): def supports_select_into(self) -> bool: ... def supports_tablesample(self) -> bool: ... def format_select_into_statement(self, expr) -> Tuple[str, tuple]: ... \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/type.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/type.py index 335e6a5..dd4a764 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/type.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/type.py @@ -3,33 +3,30 @@ from typing import Any, Protocol, Tuple, runtime_checkable -from rhosocial.activerecord.backend.dialect.protocols import UserDefinedTypeSupport +from rhosocial.activerecord.backend.dialect.protocols import ( + CreateTypeSupport, + DropTypeSupport, +) @runtime_checkable -class SQLServerUserDefinedTypeSupport(UserDefinedTypeSupport, Protocol): - """SQL Server-specific contract layered on core TYPE support.""" - - def supports_clr_type_definition(self) -> bool: - ... - - def supports_clr_user_defined_type(self) -> bool: - ... - - def supports_drop_type_cascade(self) -> bool: - ... - - def supports_drop_type_restrict(self) -> bool: - ... - - def supports_rename_type(self) -> bool: - ... - - def supports_memory_optimized_table_types(self) -> bool: - ... - - def format_rename_type_statement(self, expr: Any) -> Tuple[str, tuple]: - ... - - -__all__ = ["SQLServerUserDefinedTypeSupport"] +class SQLServerUserDefinedTypeSupport(CreateTypeSupport, DropTypeSupport, Protocol): + """SQL Server-specific contract layered on core TYPE support. + + Core splits TYPE DDL into one protocol per statement, and SQL Server + implements two of them: it can ``CREATE TYPE`` and ``DROP TYPE``. It has no + ``ALTER TYPE`` at all, so ``AlterTypeSupport`` is deliberately absent -- + ``sp_rename`` is the SQL Server way to change a type's name, which is this + backend's own ``format_rename_type_statement``. + """ + + def supports_clr_type_definition(self) -> bool: ... + def supports_clr_user_defined_type(self) -> bool: ... + def supports_drop_type_cascade(self) -> bool: ... + def supports_drop_type_restrict(self) -> bool: ... + def supports_rename_type(self) -> bool: ... + def supports_memory_optimized_table_types(self) -> bool: ... + def format_rename_type_statement(self, expr: Any) -> Tuple[str, tuple]: ... + + +__all__ = ["SQLServerUserDefinedTypeSupport"] \ No newline at end of file diff --git a/tests/providers/basic.py b/tests/providers/basic.py index aac1bfc..f2040fd 100644 --- a/tests/providers/basic.py +++ b/tests/providers/basic.py @@ -384,7 +384,8 @@ def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, tabl def _reset_table_sync(self, model_class: Type[ActiveRecord], table_name: str) -> None: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression, TableExpression + from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression.objects import Table from providers.fixtures.basic import TABLE_EXPRESSIONS from providers.fixtures._common import to_sqlserver_ddl_sql @@ -578,7 +579,8 @@ async def _setup_async_model( async def _reset_table_async(self, model_class: Type[ActiveRecord], table_name: str) -> None: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression, TableExpression + from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression.objects import Table from providers.fixtures.basic import TABLE_EXPRESSIONS from providers.fixtures._common import to_sqlserver_ddl_sql diff --git a/tests/providers/events.py b/tests/providers/events.py index efba4ca..4a349a8 100644 --- a/tests/providers/events.py +++ b/tests/providers/events.py @@ -110,7 +110,8 @@ def __init__(self): def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str) -> Type[ActiveRecord]: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression, TableExpression + from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression.objects import Table from providers.fixtures.events import TABLE_EXPRESSIONS from providers.fixtures._common import to_sqlserver_ddl_sql @@ -161,7 +162,8 @@ async def _setup_async_model( from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression, TableExpression + from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression.objects import Table from providers.fixtures.events import TABLE_EXPRESSIONS from providers.fixtures._common import to_sqlserver_ddl_sql diff --git a/tests/providers/fixtures/_common.py b/tests/providers/fixtures/_common.py index 06a67d7..a814fe0 100644 --- a/tests/providers/fixtures/_common.py +++ b/tests/providers/fixtures/_common.py @@ -8,8 +8,8 @@ from rhosocial.activerecord.backend.expression import ( CreateTableExpression, DropTableExpression, - TableExpression, ) +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType @@ -33,7 +33,7 @@ def drop_table(dialect, table_name: str) -> DropTableExpression: """Build a ``DROP TABLE IF EXISTS table_name`` expression.""" return DropTableExpression( dialect=dialect, - table=TableExpression(dialect, table_name), + table=Table(dialect, table_name), if_exists=True, ) @@ -107,7 +107,7 @@ def safe_drop_table(backend, dialect, table_name: str) -> None: try: drop_expr = DropTableExpression( dialect=dialect, - table=TableExpression(dialect, table_name), + table=Table(dialect, table_name), if_exists=True, ) backend.execute(*drop_expr.to_sql(), options=options) @@ -122,7 +122,7 @@ async def safe_drop_table_async(backend, dialect, table_name: str) -> None: try: drop_expr = DropTableExpression( dialect=dialect, - table=TableExpression(dialect, table_name), + table=Table(dialect, table_name), if_exists=True, ) await backend.execute(*drop_expr.to_sql(), options=options) diff --git a/tests/providers/fixtures/basic.py b/tests/providers/fixtures/basic.py index 716a13a..9ebde4c 100644 --- a/tests/providers/fixtures/basic.py +++ b/tests/providers/fixtures/basic.py @@ -11,6 +11,7 @@ from rhosocial.activerecord.backend.expression import ( CreateTableExpression, ) +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, @@ -52,7 +53,7 @@ def to_sql(expr: CreateTableExpression): def create_users_table(dialect, table_name: str = "users") -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), @@ -79,7 +80,7 @@ def create_users_table(dialect, table_name: str = "users") -> CreateTableExpress def create_type_cases_table(dialect, table_name: str = "type_cases") -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", CharType(dialect, length=36), @@ -112,7 +113,7 @@ def create_type_cases_table(dialect, table_name: str = "type_cases") -> CreateTa def create_type_tests_table(dialect, table_name: str = "type_tests") -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", CharType(dialect, length=36), @@ -144,7 +145,7 @@ def create_type_tests_table(dialect, table_name: str = "type_tests") -> CreateTa def create_validated_field_users_table(dialect, table_name: str = "validated_field_users") -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), @@ -170,7 +171,7 @@ def create_validated_field_users_table(dialect, table_name: str = "validated_fie def create_validated_users_table(dialect, table_name: str = "validated_users") -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), @@ -189,7 +190,7 @@ def create_pydantic_validated_models_table( ) -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), @@ -210,7 +211,7 @@ def create_pydantic_validated_models_table( def create_bulk_users_table(dialect, table_name: str = "bulk_users") -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), @@ -228,7 +229,7 @@ def create_bulk_users_table(dialect, table_name: str = "bulk_users") -> CreateTa def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), @@ -246,8 +247,13 @@ def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpress ], indexes=[IndexDefinition(dialect, name="idx_author", columns=["author"])], table_constraints=[ - ForeignKeyConstraint(dialect, columns=["author"], foreign_key_table="users", foreign_key_columns=["id"], - on_delete=_CASCADE), + ForeignKeyConstraint( + dialect, + columns=["author"], + foreign_key_table=Table(dialect, "users"), + foreign_key_columns=["id"], + on_delete=_CASCADE, + ), ], ) @@ -255,7 +261,7 @@ def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpress def create_comments_table(dialect, table_name: str = "comments") -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), @@ -277,10 +283,20 @@ def create_comments_table(dialect, table_name: str = "comments") -> CreateTableE IndexDefinition(dialect, name="idx_author", columns=["author"]), ], table_constraints=[ - ForeignKeyConstraint(dialect, columns=["post_ref"], foreign_key_table="posts", foreign_key_columns=["id"], - on_delete=_CASCADE), - ForeignKeyConstraint(dialect, columns=["author"], foreign_key_table="users", foreign_key_columns=["id"], - on_delete=ReferentialAction.NO_ACTION), + ForeignKeyConstraint( + dialect, + columns=["post_ref"], + foreign_key_table=Table(dialect, "posts"), + foreign_key_columns=["id"], + on_delete=_CASCADE, + ), + ForeignKeyConstraint( + dialect, + columns=["author"], + foreign_key_table=Table(dialect, "users"), + foreign_key_columns=["id"], + on_delete=ReferentialAction.NO_ACTION, + ), ], ) @@ -290,7 +306,7 @@ def create_column_mapping_items_table( ) -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), @@ -309,7 +325,7 @@ def create_mixed_annotation_items_table( ) -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), @@ -329,7 +345,7 @@ def create_type_adapter_tests_table( ) -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=True, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), @@ -352,7 +368,7 @@ def create_composite_pk_order_items_table( ) -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "order_id", IntegerType(dialect), @@ -375,7 +391,7 @@ def create_composite_pk_order_items_table( def create_store_inventory_table(dialect, table_name: str = "store_inventory") -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "store_id", IntegerType(dialect), @@ -398,7 +414,7 @@ def create_store_inventory_table(dialect, table_name: str = "store_inventory") - def create_orders_table(dialect, table_name: str = "orders") -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), @@ -414,7 +430,7 @@ def create_orders_table(dialect, table_name: str = "orders") -> CreateTableExpre def create_product_table(dialect, table_name: str = "product") -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=True, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), diff --git a/tests/providers/fixtures/events.py b/tests/providers/fixtures/events.py index c2ed28e..e6da57f 100644 --- a/tests/providers/fixtures/events.py +++ b/tests/providers/fixtures/events.py @@ -9,6 +9,7 @@ from rhosocial.activerecord.backend.expression import ( CreateTableExpression, ) +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, @@ -31,7 +32,7 @@ def to_sql(expr: CreateTableExpression): def create_event_tests_table(dialect, table_name: str = "event_tests") -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), @@ -56,7 +57,7 @@ def create_event_tracking_models_table( ) -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), diff --git a/tests/providers/fixtures/mixins.py b/tests/providers/fixtures/mixins.py index ef1b65a..2778621 100644 --- a/tests/providers/fixtures/mixins.py +++ b/tests/providers/fixtures/mixins.py @@ -9,6 +9,7 @@ from rhosocial.activerecord.backend.expression import ( CreateTableExpression, ) +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, @@ -33,7 +34,7 @@ def to_sql(expr: CreateTableExpression): def create_combined_articles_table(dialect, table_name: str = "combined_articles") -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), @@ -59,7 +60,7 @@ def create_combined_articles_table(dialect, table_name: str = "combined_articles def create_tasks_table(dialect, table_name: str = "tasks") -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), @@ -78,7 +79,7 @@ def create_tasks_table(dialect, table_name: str = "tasks") -> CreateTableExpress def create_timestamped_posts_table(dialect, table_name: str = "timestamped_posts") -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), @@ -96,7 +97,7 @@ def create_timestamped_posts_table(dialect, table_name: str = "timestamped_posts def create_versioned_products_table(dialect, table_name: str = "versioned_products") -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), diff --git a/tests/providers/fixtures/query.py b/tests/providers/fixtures/query.py index 758b991..e2b26da 100644 --- a/tests/providers/fixtures/query.py +++ b/tests/providers/fixtures/query.py @@ -11,6 +11,7 @@ from rhosocial.activerecord.backend.expression import ( CreateTableExpression, ) +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, @@ -42,7 +43,7 @@ def to_sql(expr: CreateTableExpression): def create_users_table(dialect, table_name: str = "users") -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), @@ -69,7 +70,7 @@ def create_users_table(dialect, table_name: str = "users") -> CreateTableExpress def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), @@ -90,8 +91,13 @@ def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpress IndexDefinition(dialect, name="idx_status", columns=["status"]), ], table_constraints=[ - ForeignKeyConstraint(dialect, columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"], - on_delete=_CASCADE), + ForeignKeyConstraint( + dialect, + columns=["user_id"], + foreign_key_table=Table(dialect, "users"), + foreign_key_columns=["id"], + on_delete=_CASCADE, + ), ], ) @@ -99,7 +105,7 @@ def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpress def create_comments_table(dialect, table_name: str = "comments") -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), @@ -119,10 +125,20 @@ def create_comments_table(dialect, table_name: str = "comments") -> CreateTableE IndexDefinition(dialect, name="idx_post_id", columns=["post_id"]), ], table_constraints=[ - ForeignKeyConstraint(dialect, columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"], - on_delete=_CASCADE), - ForeignKeyConstraint(dialect, columns=["post_id"], foreign_key_table="posts", foreign_key_columns=["id"], - on_delete=ReferentialAction.NO_ACTION), + ForeignKeyConstraint( + dialect, + columns=["user_id"], + foreign_key_table=Table(dialect, "users"), + foreign_key_columns=["id"], + on_delete=_CASCADE, + ), + ForeignKeyConstraint( + dialect, + columns=["post_id"], + foreign_key_table=Table(dialect, "posts"), + foreign_key_columns=["id"], + on_delete=ReferentialAction.NO_ACTION, + ), ], ) @@ -130,7 +146,7 @@ def create_comments_table(dialect, table_name: str = "comments") -> CreateTableE def create_orders_table(dialect, table_name: str = "orders") -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), @@ -150,7 +166,12 @@ def create_orders_table(dialect, table_name: str = "orders") -> CreateTableExpre ], indexes=[IndexDefinition(dialect, name="idx_user_id", columns=["user_id"])], table_constraints=[ - ForeignKeyConstraint(dialect, columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"]), + ForeignKeyConstraint( + dialect, + columns=["user_id"], + foreign_key_table=Table(dialect, "users"), + foreign_key_columns=["id"], + ), ], ) @@ -158,7 +179,7 @@ def create_orders_table(dialect, table_name: str = "orders") -> CreateTableExpre def create_order_items_table(dialect, table_name: str = "order_items") -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), @@ -180,8 +201,13 @@ def create_order_items_table(dialect, table_name: str = "order_items") -> Create ], indexes=[IndexDefinition(dialect, name="idx_order_id", columns=["order_id"])], table_constraints=[ - ForeignKeyConstraint(dialect, columns=["order_id"], foreign_key_table="orders", foreign_key_columns=["id"], - on_delete=_CASCADE), + ForeignKeyConstraint( + dialect, + columns=["order_id"], + foreign_key_table=Table(dialect, "orders"), + foreign_key_columns=["id"], + on_delete=_CASCADE, + ), ], ) @@ -189,7 +215,7 @@ def create_order_items_table(dialect, table_name: str = "order_items") -> Create def create_profiles_table(dialect, table_name: str = "profiles") -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), @@ -202,7 +228,12 @@ def create_profiles_table(dialect, table_name: str = "profiles") -> CreateTableE ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], table_constraints=[ - ForeignKeyConstraint(dialect, columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"]), + ForeignKeyConstraint( + dialect, + columns=["user_id"], + foreign_key_table=Table(dialect, "users"), + foreign_key_columns=["id"], + ), ], ) @@ -210,7 +241,7 @@ def create_profiles_table(dialect, table_name: str = "profiles") -> CreateTableE def create_json_users_table(dialect, table_name: str = "json_users") -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), @@ -236,7 +267,7 @@ def create_json_users_table(dialect, table_name: str = "json_users") -> CreateTa def create_nodes_table(dialect, table_name: str = "nodes") -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), @@ -253,8 +284,13 @@ def create_nodes_table(dialect, table_name: str = "nodes") -> CreateTableExpress ], indexes=[IndexDefinition(dialect, name="idx_parent_id", columns=["parent_id"])], table_constraints=[ - ForeignKeyConstraint(dialect, columns=["parent_id"], foreign_key_table="nodes", foreign_key_columns=["id"], - on_delete=ReferentialAction.NO_ACTION), + ForeignKeyConstraint( + dialect, + columns=["parent_id"], + foreign_key_table=Table(dialect, "nodes"), + foreign_key_columns=["id"], + on_delete=ReferentialAction.NO_ACTION, + ), ], ) @@ -262,7 +298,7 @@ def create_nodes_table(dialect, table_name: str = "nodes") -> CreateTableExpress def create_searchable_items_table(dialect, table_name: str = "searchable_items") -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), @@ -278,7 +314,7 @@ def create_searchable_items_table(dialect, table_name: str = "searchable_items") def create_extended_orders_table(dialect, table_name: str = "extended_orders") -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), @@ -314,8 +350,13 @@ def create_extended_orders_table(dialect, table_name: str = "extended_orders") - IndexDefinition(dialect, name="idx_region", columns=["region"]), ], table_constraints=[ - ForeignKeyConstraint(dialect, columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"], - on_delete=_CASCADE), + ForeignKeyConstraint( + dialect, + columns=["user_id"], + foreign_key_table=Table(dialect, "users"), + foreign_key_columns=["id"], + on_delete=_CASCADE, + ), ], ) @@ -325,7 +366,7 @@ def create_extended_order_items_table( ) -> CreateTableExpression: return CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), @@ -346,7 +387,11 @@ def create_extended_order_items_table( ], indexes=[IndexDefinition(dialect, name="idx_order_id", columns=["order_id"])], table_constraints=[ - ForeignKeyConstraint(dialect, columns=["order_id"], foreign_key_table="extended_orders", + ForeignKeyConstraint( + dialect, + columns=["order_id"], + foreign_key_table=Table(dialect, "extended_orders", + ), foreign_key_columns=["id"], on_delete=_CASCADE), ], ) diff --git a/tests/providers/mixins.py b/tests/providers/mixins.py index a943e57..b744f85 100644 --- a/tests/providers/mixins.py +++ b/tests/providers/mixins.py @@ -129,7 +129,8 @@ def __init__(self): def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str) -> Type[ActiveRecord]: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression, TableExpression + from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression.objects import Table from providers.fixtures.mixins import TABLE_EXPRESSIONS from providers.fixtures._common import to_sqlserver_ddl_sql @@ -186,7 +187,8 @@ async def _setup_async_model( from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression, TableExpression + from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression.objects import Table from providers.fixtures.mixins import TABLE_EXPRESSIONS from providers.fixtures._common import to_sqlserver_ddl_sql diff --git a/tests/providers/query.py b/tests/providers/query.py index aa3ada1..21f0063 100644 --- a/tests/providers/query.py +++ b/tests/providers/query.py @@ -243,7 +243,8 @@ def __init__(self): def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str) -> Type[ActiveRecord]: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression, TableExpression + from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression.objects import Table from providers.fixtures.query import TABLE_EXPRESSIONS from providers.fixtures._common import to_sqlserver_ddl_sql @@ -285,7 +286,8 @@ def _setup_multiple_models( def _reset_schema(self, backend_instance, table_name: str) -> None: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression, TableExpression + from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression.objects import Table from providers.fixtures.query import TABLE_EXPRESSIONS from providers.fixtures._common import to_sqlserver_ddl_sql @@ -368,7 +370,8 @@ def setup_profile_fixtures( def setup_order_item_model(self, scenario_name: str) -> Type[ActiveRecord]: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression, TableExpression + from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression.objects import Table from providers.fixtures.basic import TABLE_EXPRESSIONS as BASIC_EXPRS from providers.fixtures._common import to_sqlserver_ddl_sql @@ -530,7 +533,8 @@ async def _setup_model_async( from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression, TableExpression + from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression.objects import Table from providers.fixtures.query import TABLE_EXPRESSIONS from providers.fixtures._common import to_sqlserver_ddl_sql @@ -684,7 +688,8 @@ async def setup_profile_fixtures( async def setup_order_item_model(self, scenario_name: str) -> Type[AsyncActiveRecord]: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression, TableExpression + from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend from providers.fixtures.basic import TABLE_EXPRESSIONS as BASIC_EXPRS from providers.fixtures._common import to_sqlserver_ddl_sql diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_sqlserver_sql_graph.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_sqlserver_sql_graph.py index 27d16f7..fc738b3 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_sqlserver_sql_graph.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_sqlserver_sql_graph.py @@ -12,6 +12,7 @@ import pytest from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.objects import NodeTable, Table from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, CreateTableExpression, @@ -292,7 +293,7 @@ def test_graph_table_rejected_before_2017(self, kind, create_table): expr = SQLServerAsGraphTableExpression(dialect, kind) if create_table: expr = SQLServerCreateTableExpression( - dialect, table="graph_table", + dialect, Table(dialect, "graph_table"), columns=[ColumnDefinition(dialect, "ID", IntegerType(dialect))], graph_table_kind=kind, ) @@ -329,7 +330,7 @@ def test_edge_constraint(self, major, name, expected): def test_create_graph_table(self, major, kind, expected): dialect = SQLServerDialect(version=(major, 0, 0)) expr = SQLServerCreateTableExpression( - dialect, table="graph_table", + dialect, Table(dialect, "graph_table"), columns=[ColumnDefinition(dialect, "ID", IntegerType(dialect))], graph_table_kind=kind, ) @@ -339,7 +340,7 @@ def test_create_graph_table(self, major, kind, expected): def test_create_table_edge_with_constraint(self, major): dialect = SQLServerDialect(version=(major, 0, 0)) expr = SQLServerCreateTableExpression( - dialect, table="friend", + dialect, Table(dialect, "friend"), columns=[ColumnDefinition(dialect, "ID", IntegerType(dialect))], graph_table_kind=SQLServerGraphTableKind.EDGE, edge_constraints=[SQLServerEdgeConstraint(dialect, "Person", "Person", name="ec_friend")], @@ -438,7 +439,7 @@ class TestCorePgqStillRejected: def test_core_match_clause_rejected(self, dialect): from rhosocial.activerecord.backend.expression.graph import GraphVertex, MatchClause - clause = MatchClause(dialect, GraphVertex(dialect, "p", "Person")) + clause = MatchClause(dialect, GraphVertex(dialect, "p", NodeTable(dialect, "Person"))) with pytest.raises(UnsupportedFeatureError, match="graph MATCH clause"): dialect.format_match_clause(clause) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_auto_increment_ddl.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_auto_increment_ddl.py index 70acf6a..9bce4b8 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_auto_increment_ddl.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_auto_increment_ddl.py @@ -18,6 +18,7 @@ BooleanType, IntegerType, TextType, TimestampType, VarCharType, ) from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect +from rhosocial.activerecord.backend.expression.objects import Table @pytest.fixture @@ -31,7 +32,7 @@ def _pk(dialect): def _build_table(dialect): return CreateTableExpression( - dialect=dialect, table="test_tbl", + dialect=dialect, table=Table(dialect, "test_tbl"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=_pk(dialect)), ColumnDefinition(dialect, "name", TextType(dialect), @@ -58,7 +59,7 @@ def test_all_standard_types_compile(self, dialect): def test_boolean_default_true(self, dialect): expr = CreateTableExpression( - dialect=dialect, table="bool_test", + dialect=dialect, table=Table(dialect, "bool_test"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=_pk(dialect)), ColumnDefinition(dialect, "flag", BooleanType(dialect), @@ -71,7 +72,7 @@ def test_boolean_default_true(self, dialect): def test_timestamp_type(self, dialect): expr = CreateTableExpression( - dialect=dialect, table="ts_test", + dialect=dialect, table=Table(dialect, "ts_test"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=_pk(dialect)), ColumnDefinition(dialect, "created_at", TimestampType(dialect)), diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_backend_explain.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_backend_explain.py index 90da1ee..e672457 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_backend_explain.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_backend_explain.py @@ -3,9 +3,13 @@ from rhosocial.activerecord.backend.explain import SyncExplainBackendProtocol from rhosocial.activerecord.backend.expression import ( - QueryExpression, TableExpression, Column, WildcardExpression, Literal, WhereClause, + QueryExpression, + Column, + WildcardExpression, + Literal, + WhereClause, ) - +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.impl.sqlserver.explain import SQLServerExplainResult, SQLServerExplainRow @@ -91,7 +95,7 @@ def test_build_explain_sql_structure(self, sqlserver_backend_single): expr = QueryExpression( dialect, select=[WildcardExpression(dialect)], - from_=TableExpression(dialect, "t") + from_=Table(dialect, "t") ) sql, params = backend._build_explain_sql(expr) assert isinstance(sql, str) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_create_table_like.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_create_table_like.py index 385f60b..58f04d5 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_create_table_like.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_create_table_like.py @@ -18,6 +18,7 @@ ) from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect +from rhosocial.activerecord.backend.expression.objects import Table class TestSQLServerCreateTableLike: @@ -32,8 +33,8 @@ def test_basic_like_raises(self): dialect = SQLServerDialect() create_expr = CreateTableLikeExpression( dialect=dialect, - table="users_copy", - like_table="users", + table=Table(dialect, "users_copy"), + like_table=Table(dialect, "users"), ) with pytest.raises(UnsupportedFeatureError): create_expr.to_sql() @@ -43,8 +44,8 @@ def test_like_with_if_not_exists_raises(self): dialect = SQLServerDialect() create_expr = CreateTableLikeExpression( dialect=dialect, - table="users_copy", - like_table="users", + table=Table(dialect, "users_copy"), + like_table=Table(dialect, "users"), if_not_exists=True, ) with pytest.raises(UnsupportedFeatureError): @@ -55,8 +56,8 @@ def test_like_with_temporary_raises(self): dialect = SQLServerDialect() create_expr = CreateTableLikeExpression( dialect=dialect, - table="temp_users", - like_table="users", + table=Table(dialect, "temp_users"), + like_table=Table(dialect, "users"), temporary=True, ) with pytest.raises(UnsupportedFeatureError): @@ -67,8 +68,8 @@ def test_like_with_schema_qualified_table_raises(self): dialect = SQLServerDialect() create_expr = CreateTableLikeExpression( dialect=dialect, - table="users_copy", - like_table=("production", "users"), + table=Table(dialect, "users_copy"), + like_table=Table(dialect, "users", schema_name="production"), ) with pytest.raises(UnsupportedFeatureError): create_expr.to_sql() @@ -78,8 +79,8 @@ def test_like_with_temporary_and_if_not_exists_raises(self): dialect = SQLServerDialect() create_expr = CreateTableLikeExpression( dialect=dialect, - table="temp_users_copy", - like_table=("test_db", "users"), + table=Table(dialect, "temp_users_copy"), + like_table=Table(dialect, "users", catalog_name="test_db"), temporary=True, if_not_exists=True, ) @@ -96,7 +97,7 @@ def test_temporary_uses_hash_prefix(self): ] create_expr = CreateTableExpression( dialect=dialect, - table="temp_users", + table=Table(dialect, "temp_users"), columns=columns, temporary=True, ) @@ -120,7 +121,7 @@ def test_fallback_to_base_when_no_like(self): ] create_expr = CreateTableExpression( dialect=dialect, - table="users", + table=Table(dialect, "users"), columns=columns ) sql, params = create_expr.to_sql() diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect.py index 87de38f..0a7c712 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect.py @@ -98,13 +98,17 @@ class DatabaseDialect(SQLServerDialect): ], ) def test_format_alter_database_owner(self, database_dialect, database, owner, expected): + from rhosocial.activerecord.backend.expression.objects import Database from rhosocial.activerecord.backend.expression.statements.ddl_database import ( AlterDatabaseAction, AlterDatabaseExpression, ) expr = AlterDatabaseExpression( - database_dialect, database, AlterDatabaseAction.OWNER_TO, target=owner + database_dialect, + Database(database_dialect, database), + AlterDatabaseAction.OWNER_TO, + target=owner, ) assert database_dialect.format_alter_database_statement(expr) == (expected, ()) assert expr.to_sql() == (expected, ()) @@ -121,24 +125,29 @@ def test_format_alter_database_owner(self, database_dialect, database, owner, ex ], ) def test_format_alter_database_other_actions(self, database_dialect, action, target, expected): + from rhosocial.activerecord.backend.expression.objects import Database from rhosocial.activerecord.backend.expression.statements.ddl_database import ( AlterDatabaseAction, AlterDatabaseExpression, ) expr = AlterDatabaseExpression( - database_dialect, "app_db", AlterDatabaseAction[action], target=target + database_dialect, + Database(database_dialect, "app_db"), + AlterDatabaseAction[action], + target=target, ) assert expr.to_sql() == (expected, ()) def test_format_create_index_basic(self, dialect: SQLServerDialect): """Test basic CREATE INDEX formatting.""" + from rhosocial.activerecord.backend.expression.objects import Index, Table from rhosocial.activerecord.backend.expression.statements import CreateIndexExpression expr = CreateIndexExpression( dialect=dialect, - index_name="idx_test", - table_name="test_table", + index=Index(dialect, "idx_test"), + table=Table(dialect, "test_table"), columns=["id", "name"], ) sql, params = dialect.format_create_index_statement(expr) @@ -149,12 +158,13 @@ def test_format_create_index_basic(self, dialect: SQLServerDialect): def test_format_create_unique_index(self, dialect: SQLServerDialect): """Test CREATE UNIQUE INDEX formatting.""" + from rhosocial.activerecord.backend.expression.objects import Index, Table from rhosocial.activerecord.backend.expression.statements import CreateIndexExpression expr = CreateIndexExpression( dialect=dialect, - index_name="idx_unique", - table_name="users", + index=Index(dialect, "idx_unique"), + table=Table(dialect, "users"), columns=["email"], unique=True, ) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect_comprehensive.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect_comprehensive.py index 61c4dff..e73e82a 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect_comprehensive.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect_comprehensive.py @@ -86,11 +86,11 @@ def test_insert_with_output(self, dialect): from rhosocial.activerecord.backend.expression import ( InsertExpression, ValuesSource, ReturningClause, Column, ) - from rhosocial.activerecord.backend.expression.core import Literal, TableExpression - + from rhosocial.activerecord.backend.expression.core import Literal + from rhosocial.activerecord.backend.expression.objects import Table insert = InsertExpression( dialect=dialect, - into=TableExpression(dialect, "users"), + into=Table(dialect, "users"), columns=["name"], source=ValuesSource(dialect, [[Literal(dialect, "Alice")]]), returning=ReturningClause(dialect, [Column(dialect, "id")]), @@ -105,11 +105,10 @@ def test_insert_default_values(self, dialect): from rhosocial.activerecord.backend.expression import ( InsertExpression, DefaultValuesSource, ) - from rhosocial.activerecord.backend.expression.core import TableExpression - + from rhosocial.activerecord.backend.expression.objects import Table insert = InsertExpression( dialect=dialect, - into=TableExpression(dialect, "log"), + into=Table(dialect, "log"), source=DefaultValuesSource(dialect), ) @@ -121,11 +120,11 @@ def test_update_with_output(self, dialect): from rhosocial.activerecord.backend.expression import ( UpdateExpression, ReturningClause, Column, ) - from rhosocial.activerecord.backend.expression.core import Literal, TableExpression - + from rhosocial.activerecord.backend.expression.core import Literal + from rhosocial.activerecord.backend.expression.objects import Table update = UpdateExpression( dialect=dialect, - table=TableExpression(dialect, "users"), + table=Table(dialect, "users"), assignments={"name": Literal(dialect, "Bob")}, returning=ReturningClause(dialect, [Column(dialect, "id")]), ) @@ -137,12 +136,14 @@ def test_update_with_output(self, dialect): def test_delete_with_output(self, dialect): from rhosocial.activerecord.backend.expression import ( - DeleteExpression, ReturningClause, Column, TableExpression, + DeleteExpression, + ReturningClause, + Column, ) - + from rhosocial.activerecord.backend.expression.objects import Table delete = DeleteExpression( dialect=dialect, - tables=[TableExpression(dialect, "users")], + tables=[Table(dialect, "users")], returning=ReturningClause(dialect, [Column(dialect, "id")]), ) @@ -152,13 +153,16 @@ def test_delete_with_output(self, dialect): def test_insert_top(self, dialect): from rhosocial.activerecord.backend.expression import ( - InsertExpression, ValuesSource, Column, TableExpression, + InsertExpression, + ValuesSource, + Column, ) + from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.core import Literal insert = InsertExpression( dialect=dialect, - into=TableExpression(dialect, "users"), + into=Table(dialect, "users"), columns=["name"], source=ValuesSource(dialect, [ [Literal(dialect, "A")], @@ -179,11 +183,12 @@ def dialect(self): return SQLServerDialect(SQL_SERVER_2022) def test_create_sequence(self, dialect): + from rhosocial.activerecord.backend.expression.objects import Sequence from rhosocial.activerecord.backend.expression.statements import CreateSequenceExpression expr = CreateSequenceExpression( dialect=dialect, - sequence_name="test_seq", + sequence=Sequence(dialect, "test_seq"), start=1, increment=1, ) @@ -193,11 +198,12 @@ def test_create_sequence(self, dialect): assert "INCREMENT BY 1" in sql def test_create_sequence_full(self, dialect): + from rhosocial.activerecord.backend.expression.objects import Sequence from rhosocial.activerecord.backend.expression.statements import CreateSequenceExpression expr = CreateSequenceExpression( dialect=dialect, - sequence_name="order_seq", + sequence=Sequence(dialect, "order_seq"), start=1000, increment=10, minvalue=1, @@ -214,11 +220,12 @@ def test_create_sequence_full(self, dialect): assert "CACHE 20" in sql def test_drop_sequence_if_exists(self, dialect): + from rhosocial.activerecord.backend.expression.objects import Sequence from rhosocial.activerecord.backend.expression.statements import DropSequenceExpression expr = DropSequenceExpression( dialect=dialect, - sequence_name="test_seq", + sequence=Sequence(dialect, "test_seq"), if_exists=True, ) sql, params = dialect.format_drop_sequence_statement(expr) @@ -233,21 +240,23 @@ def dialect(self): return SQLServerDialect(SQL_SERVER_2022) def test_create_schema(self, dialect): + from rhosocial.activerecord.backend.expression.objects import Schema from rhosocial.activerecord.backend.expression.statements import CreateSchemaExpression expr = CreateSchemaExpression( dialect=dialect, - schema_name="sales", + schema=Schema(dialect, "sales"), ) sql, params = dialect.format_create_schema_statement(expr) assert "CREATE SCHEMA [sales]" in sql def test_create_schema_with_authorization(self, dialect): + from rhosocial.activerecord.backend.expression.objects import Schema from rhosocial.activerecord.backend.expression.statements import CreateSchemaExpression expr = CreateSchemaExpression( dialect=dialect, - schema_name="sales", + schema=Schema(dialect, "sales"), authorization="dbo", ) sql, params = dialect.format_create_schema_statement(expr) @@ -255,11 +264,12 @@ def test_create_schema_with_authorization(self, dialect): assert "AUTHORIZATION [dbo]" in sql def test_drop_schema(self, dialect): + from rhosocial.activerecord.backend.expression.objects import Schema from rhosocial.activerecord.backend.expression.statements import DropSchemaExpression expr = DropSchemaExpression( dialect=dialect, - schema_name="sales", + schema=Schema(dialect, "sales"), ) sql, params = dialect.format_drop_schema_statement(expr) assert "DROP SCHEMA [sales]" in sql @@ -273,11 +283,12 @@ def dialect(self): return SQLServerDialect(SQL_SERVER_2022) def test_truncate_table(self, dialect): + from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import TruncateExpression expr = TruncateExpression( dialect=dialect, - table_name="users", + table=Table(dialect, "users"), ) sql, params = dialect.format_truncate_statement(expr) assert sql == "TRUNCATE TABLE [users]" @@ -290,20 +301,49 @@ class TestSQLServerDialectLateral: def dialect(self): return SQLServerDialect(SQL_SERVER_2022) + @staticmethod + def _lateral(dialect, join_type): + """A LateralExpression over a one-row query, as core builds it. + + The formatter takes the node, not the pieces it is made of: that is + what ``LateralExpression.to_sql()`` passes, and the previous signature + made every LateralExpression on SQL Server raise TypeError. + """ + from rhosocial.activerecord.backend.expression.core import Literal, Subquery + from rhosocial.activerecord.backend.expression.objects import Table + from rhosocial.activerecord.backend.expression.query_sources import ( + LateralExpression, + ) + from rhosocial.activerecord.backend.expression.statements.dql import ( + QueryExpression, + ) + + query = QueryExpression( + dialect, select=[Literal(dialect, 1)], from_=Table(dialect, "items") + ) + return LateralExpression( + dialect, Subquery(dialect, query), join_type=join_type, alias="i" + ) + def test_cross_apply(self, dialect): sql, params = dialect.format_lateral_expression( - "SELECT * FROM [items]", (), "i", "CROSS APPLY" + self._lateral(dialect, "INNER") ) assert "CROSS APPLY" in sql assert "AS [i]" in sql def test_outer_apply(self, dialect): sql, params = dialect.format_lateral_expression( - "SELECT * FROM [items]", (), "i", "LEFT JOIN" + self._lateral(dialect, "LEFT JOIN") ) assert "OUTER APPLY" in sql assert "AS [i]" in sql + def test_renders_through_to_sql(self, dialect): + """The node renders, which is the assertion the old signature broke.""" + sql, _ = self._lateral(dialect, "INNER").to_sql() + assert "CROSS APPLY" in sql + class TestSQLServerDialectSetOperation: """Set operation (UNION/INTERSECT/EXCEPT) formatting tests.""" @@ -313,18 +353,22 @@ def dialect(self): return SQLServerDialect(SQL_SERVER_2022) def test_union_all(self, dialect): - from rhosocial.activerecord.backend.expression import QueryExpression, Column, TableExpression + from rhosocial.activerecord.backend.expression import ( + QueryExpression, + Column, + ) + from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.query_sources import SetOperationExpression left = QueryExpression( dialect=dialect, select=[Column(dialect, "id")], - from_=TableExpression(dialect, "users"), + from_=Table(dialect, "users"), ) right = QueryExpression( dialect=dialect, select=[Column(dialect, "id")], - from_=TableExpression(dialect, "admins"), + from_=Table(dialect, "admins"), ) expr = SetOperationExpression( @@ -336,18 +380,22 @@ def test_union_all(self, dialect): assert "[admins]" in sql def test_intersect(self, dialect): - from rhosocial.activerecord.backend.expression import QueryExpression, Column, TableExpression + from rhosocial.activerecord.backend.expression import ( + QueryExpression, + Column, + ) + from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.query_sources import SetOperationExpression left = QueryExpression( dialect=dialect, select=[Column(dialect, "id")], - from_=TableExpression(dialect, "orders"), + from_=Table(dialect, "orders"), ) right = QueryExpression( dialect=dialect, select=[Column(dialect, "id")], - from_=TableExpression(dialect, "shipments"), + from_=Table(dialect, "shipments"), ) expr = SetOperationExpression( @@ -357,18 +405,22 @@ def test_intersect(self, dialect): assert "INTERSECT" in sql def test_except(self, dialect): - from rhosocial.activerecord.backend.expression import QueryExpression, Column, TableExpression + from rhosocial.activerecord.backend.expression import ( + QueryExpression, + Column, + ) + from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.query_sources import SetOperationExpression left = QueryExpression( dialect=dialect, select=[Column(dialect, "id")], - from_=TableExpression(dialect, "employees"), + from_=Table(dialect, "employees"), ) right = QueryExpression( dialect=dialect, select=[Column(dialect, "id")], - from_=TableExpression(dialect, "managers"), + from_=Table(dialect, "managers"), ) expr = SetOperationExpression( @@ -412,18 +464,20 @@ def test_merge_basic(self, dialect): MergeExpression, MergeActionType, MergeAction, ) from rhosocial.activerecord.backend.expression import ( - QueryExpression, Column, TableExpression, + QueryExpression, + Column, ) + from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.expression.core import Literal merge = MergeExpression( dialect=dialect, - target_table=TableExpression(dialect, "target"), + target_table=Table(dialect, "target"), source=QueryExpression( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name")], - from_=TableExpression(dialect, "source"), + from_=Table(dialect, "source"), ), on_condition=ComparisonPredicate( dialect, "=", @@ -663,10 +717,13 @@ def test_temporal_period(self, dialect): assert "PERIOD FOR SYSTEM_TIME ([StartTime], [EndTime])" in sql def test_system_versioning(self, dialect): + from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.impl.sqlserver.expression import ( SQLServerSystemVersioningClause, ) - expr = SQLServerSystemVersioningClause(dialect, "TestTableHistory", "dbo") + expr = SQLServerSystemVersioningClause( + dialect, Table(dialect, "TestTableHistory", schema_name="dbo") + ) sql, params = expr.to_sql() assert "SYSTEM_VERSIONING = ON" in sql assert "[dbo].[TestTableHistory]" in sql @@ -775,14 +832,14 @@ def test_create_table_basic(self, dialect): CreateTableExpression, ColumnDefinition, ColumnConstraint, ColumnConstraintType, ) - from rhosocial.activerecord.backend.expression.core import TableExpression + from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.types import ( IntegerType, VarCharType, ) expr = CreateTableExpression( dialect=dialect, - table=TableExpression(dialect, "users"), + table=Table(dialect, "users"), columns=[ ColumnDefinition(dialect, name="id", @@ -808,23 +865,23 @@ def test_drop_table_if_exists(self, dialect): from rhosocial.activerecord.backend.expression.statements import ( DropTableExpression, ) - from rhosocial.activerecord.backend.expression.core import TableExpression - + from rhosocial.activerecord.backend.expression.objects import Table expr = DropTableExpression( dialect=dialect, - table=TableExpression(dialect, "users"), + table=Table(dialect, "users"), if_exists=True, ) sql, params = dialect.format_drop_table_statement(expr) assert "DROP TABLE IF EXISTS [users]" in sql def test_create_index_with_includes(self, dialect): + from rhosocial.activerecord.backend.expression.objects import Index, Table from rhosocial.activerecord.backend.expression.statements import CreateIndexExpression expr = CreateIndexExpression( dialect=dialect, - index_name="idx_users_email_include", - table_name="users", + index=Index(dialect, "idx_users_email_include"), + table=Table(dialect, "users"), columns=["email"], include=["id", "name"], ) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect_version_branches.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect_version_branches.py index f455ee8..3aeb750 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect_version_branches.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect_version_branches.py @@ -180,10 +180,13 @@ def test_skip_locked_readpast_2019_gate(self, version, readpast): assert d.supports_readpast() is readpast def test_if_exists_rendering_turns_on_in_2016(self): + from rhosocial.activerecord.backend.expression.objects import View from rhosocial.activerecord.backend.expression.statements.ddl_view import DropViewExpression - pre = DropViewExpression(SQLServerDialect((11, 0, 0)), "v", if_exists=True).to_sql() - post = DropViewExpression(SQLServerDialect((13, 0, 0)), "v", if_exists=True).to_sql() + pre_dialect = SQLServerDialect((11, 0, 0)) + post_dialect = SQLServerDialect((13, 0, 0)) + pre = DropViewExpression(pre_dialect, View(pre_dialect, "v"), if_exists=True).to_sql() + post = DropViewExpression(post_dialect, View(post_dialect, "v"), if_exists=True).to_sql() assert pre[0] == "DROP VIEW [v]" assert post[0] == "DROP VIEW IF EXISTS [v]" @@ -207,7 +210,7 @@ def test_sequence_objects_2012_gate(self, version, sequence): class TestRenderingSnapshots: def test_temporary_table_gets_hash_prefix(self): - from rhosocial.activerecord.backend.expression.core import TableExpression + from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, CreateTableExpression, @@ -217,7 +220,7 @@ def test_temporary_table_gets_hash_prefix(self): d = SQLServerDialect((16, 0, 0)) expr = CreateTableExpression( dialect=d, - table=TableExpression(d, "stage_users"), + table=Table(d, "stage_users"), columns=[ColumnDefinition(d, name="id", data_type=IntegerType(d))], temporary=True, ) @@ -226,7 +229,7 @@ def test_temporary_table_gets_hash_prefix(self): assert params == () def test_temporary_table_keeps_schema_prefix_order(self): - from rhosocial.activerecord.backend.expression.core import TableExpression + from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, CreateTableExpression, @@ -236,13 +239,14 @@ def test_temporary_table_keeps_schema_prefix_order(self): d = SQLServerDialect((16, 0, 0)) expr = CreateTableExpression( dialect=d, - table=TableExpression(d, "t", "dbo"), + table=Table(d, "t", schema_name="dbo"), columns=[ColumnDefinition(d, name="id", data_type=IntegerType(d))], temporary=True, ) assert expr.to_sql()[0] == "CREATE TABLE [dbo].[#t] ([id] INT)" def test_create_table_like_is_unsupported(self): + from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( CreateTableLikeExpression, ) @@ -251,8 +255,8 @@ def test_create_table_like_is_unsupported(self): assert d.supports_create_table_like() is False expr = CreateTableLikeExpression( dialect=d, - table="copy", - like_table="original", + table=Table(d, "copy"), + like_table=Table(d, "original"), ) with pytest.raises(UnsupportedFeatureError): expr.to_sql() @@ -282,7 +286,11 @@ def test_for_update_clause_hint_rendering(self): assert sql == "WITH (UPDLOCK, ROWLOCK, READPAST)" def test_merge_output_action_snapshot(self): - from rhosocial.activerecord.backend.expression import Column, QueryExpression, TableExpression + from rhosocial.activerecord.backend.expression import ( + Column, + QueryExpression, + ) + from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.expression.statements import ( MergeAction, @@ -295,11 +303,11 @@ def test_merge_output_action_snapshot(self): d = SQLServerDialect((16, 0, 0)) merge = SQLServerMergeExpression( dialect=d, - target_table=TableExpression(d, "target"), + target_table=Table(d, "target"), source=QueryExpression( dialect=d, select=[Column(d, "id"), Column(d, "name")], - from_=TableExpression(d, "source"), + from_=Table(d, "source"), ), on_condition=ComparisonPredicate( d, "=", Column(d, "id", "target"), Column(d, "id", "source") @@ -324,7 +332,11 @@ def test_merge_output_action_snapshot(self): assert params == () def test_merge_without_options_has_no_output(self): - from rhosocial.activerecord.backend.expression import Column, QueryExpression, TableExpression + from rhosocial.activerecord.backend.expression import ( + Column, + QueryExpression, + ) + from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.expression.statements import ( MergeAction, @@ -335,8 +347,8 @@ def test_merge_without_options_has_no_output(self): d = SQLServerDialect((16, 0, 0)) merge = MergeExpression( dialect=d, - target_table=TableExpression(d, "t"), - source=QueryExpression(dialect=d, select=[Column(d, "id")], from_=TableExpression(d, "s")), + target_table=Table(d, "t"), + source=QueryExpression(dialect=d, select=[Column(d, "id")], from_=Table(d, "s")), on_condition=ComparisonPredicate(d, "=", Column(d, "id", "t"), Column(d, "id", "s")), when_matched=[MergeAction(dialect=d, action_type=MergeActionType.DELETE)], ) @@ -382,11 +394,33 @@ def test_top_n_clause_variants(self): assert d.format_top_n_clause(5, percentage=True) == ("TOP 5 PERCENT", ()) def test_lateral_renders_as_apply(self): + from rhosocial.activerecord.backend.expression.core import Literal, Subquery + from rhosocial.activerecord.backend.expression.objects import Table + from rhosocial.activerecord.backend.expression.query_sources import ( + LateralExpression, + ) + from rhosocial.activerecord.backend.expression.statements.dql import ( + QueryExpression, + ) + d = SQLServerDialect((16, 0, 0)) - cross = d.format_lateral_expression("SELECT 1", (), "x") - outer = d.format_lateral_expression("SELECT 1", (), "x", join_type="LEFT") - assert cross == ("CROSS APPLY (SELECT 1) AS [x]", ()) - assert outer == ("OUTER APPLY (SELECT 1) AS [x]", ()) + + def lateral(join_type): + # The formatter takes the LateralExpression node, which is what + # to_sql() passes; it no longer takes the node's pieces. + query = QueryExpression( + d, select=[Literal(d, 1)], from_=Table(d, "items") + ) + return LateralExpression( + d, Subquery(d, query), join_type=join_type, alias="x" + ) + + cross = d.format_lateral_expression(lateral("INNER")) + outer = d.format_lateral_expression(lateral("LEFT")) + assert cross[0].startswith("CROSS APPLY (") + assert cross[0].endswith(") AS [x]") + assert outer[0].startswith("OUTER APPLY (") + assert outer[0].endswith(") AS [x]") class TestGroupingAndUpsertBasics: @@ -404,6 +438,7 @@ def test_upsert_targets_merge_syntax(self): assert d.get_upsert_syntax_type() == "MERGE" def test_sequence_ddl_snapshots(self): + from rhosocial.activerecord.backend.expression.objects import Sequence from rhosocial.activerecord.backend.expression.statements import ( CreateSequenceExpression, DropSequenceExpression, @@ -411,7 +446,7 @@ def test_sequence_ddl_snapshots(self): d16 = SQLServerDialect((16, 0, 0)) create_sql, create_params = CreateSequenceExpression( - d16, "seq", start=1, increment=2, minvalue=1, maxvalue=100, cycle=False, cache=10 + d16, Sequence(d16, "seq"), start=1, increment=2, minvalue=1, maxvalue=100, cycle=False, cache=10 ).to_sql() assert create_sql == ( "CREATE SEQUENCE [seq] START WITH 1 INCREMENT BY 2 " @@ -419,7 +454,10 @@ def test_sequence_ddl_snapshots(self): ) assert create_params == () # IF EXISTS only appears from 2012 onwards. - assert DropSequenceExpression(SQLServerDialect((10, 0, 0)), "seq", if_exists=True).to_sql()[0] == ( - "DROP SEQUENCE [seq]" - ) - assert DropSequenceExpression(d16, "seq", if_exists=True).to_sql()[0] == "DROP SEQUENCE IF EXISTS [seq]" + d10 = SQLServerDialect((10, 0, 0)) + assert DropSequenceExpression( + d10, Sequence(d10, "seq"), if_exists=True + ).to_sql()[0] == "DROP SEQUENCE [seq]" + assert DropSequenceExpression( + d16, Sequence(d16, "seq"), if_exists=True + ).to_sql()[0] == "DROP SEQUENCE IF EXISTS [seq]" diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_expression_roundtrip_all.py index 87630a3..8894b75 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_expression_roundtrip_all.py @@ -1,75 +1,501 @@ # tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_expression_roundtrip_all.py """ -Functional serialization coverage for SQL Server expression classes. +Serialization and SQL coverage for every expression class SQL Server renders. -Every expression class defined in ``rhosocial.activerecord.backend.impl -.sqlserver.expression`` must round-trip losslessly through dict / JSON / XML -encodings, and produce identical ``to_sql()`` where the SQL Server dialect -supports it. +Two packages are in scope: the backend's own +``rhosocial.activerecord.backend.impl.sqlserver.expression``, and core's +``rhosocial.activerecord.backend.expression``. The second one used to be +absent, and its absence is why a core class with no SQL Server formatter could +sit in the tree unnoticed -- the backend matrix could not see it, and core's own +matrix renders against the Dummy dialect, where it renders. + +Why ``to_sql()`` is classified rather than caught +================================================ + +This matrix used the testsuite's ``sql_consistent``, whose body was:: + + try: + expected = instance.to_sql() + except Exception: + return + +Every render failure was therefore a green tick, and the three SQL comparisons +that follow never ran. A formatter reading a field that no longer existed was +indistinguishable from a formatter for a feature SQL Server does not have. The +cost was not abstract: this rewrite found a formatter whose signature no caller +could satisfy, so every ``LateralExpression`` on SQL Server raised ``TypeError`` +and no test said so -- and, by collecting core's own classes, four core +expressions whose formatter this backend had shadowed with a different +signature. Both are described where they are fixed and, for the four, where they +are pinned. + +Each outcome is now named and asserted: + +* **renders** -- all three encodings must restore byte-identical SQL *and* + byte-identical bind parameters. +* a member of :data:`LEGITIMATE_NON_RENDERS` -- a class that cannot render for a + reason belonging to its own tree. Each entry pins the exception type *and* a + message fragment, so a class that started failing for a different reason fails + here instead of staying quietly green. +* ``UnsupportedFeatureError`` from a class *not* named in the dict -- SQL Server + does not model the feature. Asserted as exactly that type, so a subclass raised + for an unrelated reason is still visible rather than passing. +* **anything else** -- a failure naming the class and the exception. + +The dict is read before the ``UnsupportedFeatureError`` branch, because core now +reports a *missing formatter* through that same type; see +:func:`assert_sql_roundtrip_classified`. + +And when a class cannot be constructed +====================================== + +``make_instance(...) is None`` becomes a skip, but only for a class named in +:data:`UNCONSTRUCTIBLE`, and :func:`test_unconstructible_list_is_exact` pins that +tuple against what the constructor really skips. Both directions fail: a class +that gains a constructor, and a class that starts failing to build. + +Discovery does not use ``ExpressionRegistry._registry``. That registry is +process-global and grows as sibling test modules import their own backends, so +reading it made this matrix's contents depend on which files pytest happened to +import first. :func:`collect_expression_classes` walks a package instead, which +is deterministic. """ +import inspect +from typing import Dict, Tuple, Type + import pytest +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.core import Column, Literal +from rhosocial.activerecord.backend.expression.objects import ( + Database, + Function, + Index, + Schema, + Sequence, + Table, + Trigger, + Type, + View, +) +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.serialization import ExpressionRegistry +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef +from rhosocial.activerecord.backend.expression.statements import ( + ddl_alter, + ddl_table, + ddl_type, + ddl_view, + dml, +) +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + ColumnConstraint, + ColumnConstraintType, + ColumnDefinition, + TableConstraint, + TableConstraintType, +) +from rhosocial.activerecord.backend.expression.statements.dml import ( + MergeAction, + MergeActionType, +) +from rhosocial.activerecord.backend.expression.statements.dql import QueryExpression +from rhosocial.activerecord.backend.expression.types import IntegerType from rhosocial.activerecord.testsuite.utils.expression import ( collect_expression_classes, make_instance, register_all, register_special_constructor, roundtrip_expression, - sql_consistent, ) SS_EXPR_PKG = "rhosocial.activerecord.backend.impl.sqlserver.expression" +CORE_EXPR_PKG = "rhosocial.activerecord.backend.expression" + + +def _collect(pkg: str) -> Dict[str, type]: + """Every concrete expression class one package defines. + + Walked rather than read from the registry, for the reason in the module + docstring. Two packages export the same class under different names in + different places, so the first name in sorted order wins and the matrix does + not assert one class twice. + """ + ExpressionRegistry._auto_register_builtins() + collected = collect_expression_classes(pkg) + by_identity: Dict[int, str] = {} + for fqn, cls in sorted(collected.items()): + by_identity.setdefault(id(cls), fqn) + return { + fqn: cls + for cls in collected.values() + if not inspect.isabstract(cls) + for fqn in [by_identity[id(cls)]] + } + + +CORE_REGISTERED = _collect(CORE_EXPR_PKG) +SS_REGISTERED = _collect(SS_EXPR_PKG) +REGISTERED: Dict[str, type] = {**CORE_REGISTERED, **SS_REGISTERED} +register_all(REGISTERED) + + +# --------------------------------------------------------------------------- +# Special constructors: a real value where the introspective guess is a lie +# --------------------------------------------------------------------------- +# +# ``make_instance`` reads each required parameter's annotation and guesses: +# ``"x"`` for a string, ``[]`` for a list, ``IntegerType()`` for a type. That is +# right for a name and wrong for everything that wants a catalogue object -- a +# Table, an Index, a Type -- because a bare ``"x"`` is not one, and the formatter +# now refuses it by name. It is also wrong for the containers that require at +# least one member (CASE, WINDOW, temporal options), for the constraints that +# must render without bind parameters, and for the enums the guess supplies a +# string for. +# +# Every registration below replaces a guess that would otherwise have produced an +# instance SQL Server cannot render. Suffixes are relative to the expression +# package because ``make_instance`` matches with ``str.endswith`` and several +# modules export identically named classes. + + +def _table_obj(dialect, name="t"): + """A table with a bare name and no namespace.""" + return Table(dialect, name) + + +def _schema_obj(dialect): + """A Schema, for the CREATE/DROP SCHEMA statements the guess cannot name.""" + return Schema(dialect, "app") + + +def _database_obj(dialect): + """A Database, for CREATE/DROP/ALTER DATABASE.""" + return Database(dialect, "app") + + +def _sequence_obj(dialect): + """A Sequence, for the three SEQUENCE statements.""" + return Sequence(dialect, "s") + -CLASSES = collect_expression_classes(SS_EXPR_PKG) -register_all(CLASSES) +def _type_obj(dialect): + """A Type, for CREATE/DROP TYPE.""" + return Type(dialect, "t") -def _register_sqlserver_specials(): - from rhosocial.activerecord.backend.expression.core import Column +def _index_obj(dialect, name="i"): + """An Index, for the index statements.""" + return Index(dialect, name) + + +def _view_obj(dialect, name="v"): + """A View, for the view statements.""" + return View(dialect, name) + + +def _alias_type_definition(dialect): + """A SQL Server alias TYPE definition, bound to the dialect. + + The testsuite registers the Dummy backend's definition for CREATE TYPE, + which SQL Server cannot spell: a TYPE definition is part of the statement, + not a parameter, so its body must render as SQL rather than a bind value. + """ + from rhosocial.activerecord.backend.impl.sqlserver.expression.ddl.type import ( + SQLServerAliasTypeDefinition, + ) + + return SQLServerAliasTypeDefinition(dialect, IntegerType(dialect), nullability=True) + + +def _column_predicate(dialect): + """A predicate comparing two columns, so it renders with no bind parameters.""" + return ComparisonPredicate(dialect, "=", Column(dialect, "a"), Column(dialect, "b")) + + +def _one_column_query(dialect): + """A single-column ``SELECT`` over a table, for CREATE VIEW and TABLE AS. + + Used by the CREATE VIEW and materialized-view registrations, where the + guess would otherwise supply a bare string for the query slot. + """ + return QueryExpression( + dialect, select=[Column(dialect, "id")], from_=_table_obj(dialect) + ) + + +def _integer_column(dialect, name="col"): + """A column definition carrying a *dialect-bound* type. + + The binding is load-bearing: ``to_sql()`` dispatches on the type through its + own dialect, so an unbound ``IntegerType()`` raises the moment anything + renders it. + """ + return ddl_table.ColumnDefinition(dialect, name, IntegerType(dialect)) + + +def _sqlserver_collation(dialect): + from rhosocial.activerecord.backend.impl.sqlserver.collation import ( + SQLServerCollation, + ) + + return SQLServerCollation.LATIN1_GENERAL_CI_AS + + +def register_specials(): + """Replace every introspective guess that would cost a real assertion.""" + # -- the FROM side ------------------------------------------------------ + register_special_constructor( + "sources.relation.NamedRelationRef", + lambda d: NamedRelationRef(d, _table_obj(d)), + ) + # CREATE VIEW needs a query, and the guess supplies the string "x" for it. + register_special_constructor( + "statements.ddl_view.CreateViewExpression", + lambda d: ddl_view.CreateViewExpression( + d, view=_view_obj(d), query=_one_column_query(d) + ), + ) + + # -- column pieces ------------------------------------------------------ + # Overrides the shared testsuite factory, which binds no dialect to its type. + register_special_constructor( + "statements.ddl_table.ColumnDefinition", _integer_column + ) + + # -- containers that must not be empty ---------------------------------- + from rhosocial.activerecord.backend.expression.advanced_functions import ( + CaseExpression, + WindowClause, + WindowDefinition, + WindowSpecification, + ) + register_special_constructor( + "advanced_functions.CaseExpression", + lambda d: CaseExpression( + d, cases=[(_column_predicate(d), Literal(d, 1))], else_result=Literal(d, 0) + ), + ) + register_special_constructor( + "advanced_functions.WindowSpecification", + lambda d: WindowSpecification(d, partition_by=["a"]), + ) + register_special_constructor( + "advanced_functions.WindowDefinition", + lambda d: WindowDefinition(d, "w", WindowSpecification(d, partition_by=["a"])), + ) + register_special_constructor( + "advanced_functions.WindowClause", + lambda d: WindowClause(d, [WindowDefinition(d, "w", WindowSpecification(d, partition_by=["a"]))]), + ) + # An empty options dict is refused by the formatter, so a time-travel clause + # needs an actual option. + register_special_constructor( + "datetime.TemporalOptionsExpression", + lambda d: __import__( + "rhosocial.activerecord.backend.expression.datetime", + fromlist=["TemporalOptionsExpression"], + ).TemporalOptionsExpression(d, {"as_of": "2020-01-01"}), + ) + # The guess supplies the string "x", which SQL Server's collation validator + # rightly refuses. + register_special_constructor( + "collation.CollateExpression", + lambda d: __import__( + "rhosocial.activerecord.backend.expression.collation", + fromlist=["CollateExpression"], + ).CollateExpression(d, Column(d, "a"), _sqlserver_collation(d)), + ) + + # -- ALTER TABLE actions ------------------------------------------------ + register_special_constructor( + "ddl_alter.AddColumn", lambda d: ddl_alter.AddColumn(d, _integer_column(d)) + ) + register_special_constructor( + "ddl_alter.AddTableConstraint", + lambda d: ddl_alter.AddTableConstraint( + d, TableConstraint(d, TableConstraintType.PRIMARY_KEY, name="c", columns=["a"]) + ), + ) + register_special_constructor( + "ddl_alter.AddIndex", lambda d: ddl_alter.AddIndex(d, ddl_table.IndexDefinition(d, "i", ["a"])) + ) + + # -- constraints that must render without bind parameters ---------------- + register_special_constructor( + "ddl_table.ColumnConstraint", + lambda d: ColumnConstraint(d, ColumnConstraintType.NOT_NULL, name="c"), + ) + register_special_constructor( + "ddl_table.TableConstraint", + lambda d: TableConstraint(d, TableConstraintType.PRIMARY_KEY, name="c", columns=["a"]), + ) + # REFERENCES needs at least one referenced column; the guess supplies none. + register_special_constructor( + "ddl_table.ReferencesClause", + lambda d: ddl_table.ReferencesClause(d, Table(d, "other"), ["b"]), + ) + + # -- statements holding objects the guess cannot name -------------------- + # Each of these was found by running the matrix, not by reading it: the + # introspective guess hands the statement a bare ``"x"``, and the formatter + # now refuses that by name. + # + # The keys are spelled *identically* to the testsuite's own where they + # overlap, because ``make_instance`` returns the **first** registry key whose + # suffix matches -- and the testsuite populates its registry at import time, + # so a new, shorter key is never consulted for a class an earlier, longer key + # already claims. Registering ``ddl_type.CreateTypeExpression`` next to the + # testsuite's ``statements.ddl_type.CreateTypeExpression`` would be silent + # dead code; re-using the key replaces the entry. That is why the two TYPE + # registrations below keep the ``statements.`` prefix. + from rhosocial.activerecord.backend.expression.statements import ( + ddl_comment, + ddl_index, + ddl_truncate, + ) + + register_special_constructor( + "ddl_truncate.TruncateExpression", + lambda d: ddl_truncate.TruncateExpression(d, _table_obj(d)), + ) + register_special_constructor( + "ddl_table.CreateTableExpression", + lambda d: ddl_table.CreateTableExpression(d, _table_obj(d), [_integer_column(d)]), + ) + register_special_constructor( + "ddl_table.DropTableExpression", + lambda d: ddl_table.DropTableExpression(d, _table_obj(d)), + ) + register_special_constructor( + "ddl_view.DropViewExpression", + lambda d: ddl_view.DropViewExpression(d, _view_obj(d)), + ) + register_special_constructor( + "ddl_index.DropFulltextIndexExpression", + lambda d: ddl_index.DropFulltextIndexExpression( + d, _index_obj(d), _table_obj(d) + ), + ) + register_special_constructor( + "ddl_index.CreateFulltextIndexExpression", + lambda d: ddl_index.CreateFulltextIndexExpression( + d, index=_index_obj(d), table=_table_obj(d), columns=["a"] + ), + ) + # A COMMENT ON is DDL: its comment must render as a literal rather than a + # bind parameter, so the guess's bare string is replaced by one with a name. + register_special_constructor( + "ddl_comment.CommentOnExpression", + lambda d: ddl_comment.CommentOnExpression(d, "table", _table_obj(d), comment="c"), + ) + register_special_constructor( + "ddl_trigger.DropTriggerExpression", + lambda d: __import__( + "rhosocial.activerecord.backend.expression.statements.ddl_trigger", + fromlist=["DropTriggerExpression"], + ).DropTriggerExpression(d, Trigger(d, "trg")), + ) + # A TYPE definition must be a definition object, and the one the testsuite + # registers is the Dummy's -- which SQL Server cannot spell, so the statement + # reports the feature as unsupported rather than rendering. SQL Server's own + # alias definition is used instead, so it renders and the round-trip is + # asserted. Same key as the testsuite's, deliberately. + register_special_constructor( + "statements.ddl_type.CreateTypeExpression", + lambda d: ddl_type.CreateTypeExpression( + d, _type_obj(d), _alias_type_definition(d) + ), + ) + + # -- MERGE --------------------------------------------------------------- + register_special_constructor( + "dml.MergeAction", + lambda d: MergeAction( + d, + MergeActionType.UPDATE, + {"a": Literal(d, 1)}, + _column_predicate(d), + "matched", + ), + ) + register_special_constructor( + "dml.MergeExpression", + lambda d: dml.MergeExpression( + d, + target_table=_table_obj(d), + source=NamedRelationRef(d, Table(d, "src")), + on_condition=_column_predicate(d), + when_matched=[ + MergeAction( + d, + MergeActionType.UPDATE, + {"a": Literal(d, 1)}, + _column_predicate(d), + "matched", + ) + ], + ), + ) + + # -- SQL Server's own expressions --------------------------------------- def pivot(d): from rhosocial.activerecord.backend.impl.sqlserver.expression.pivot import ( PivotExpression, ) + return PivotExpression(d, aggregate_function="SUM", value_column="amount", pivot_column="q") def columnstore(d): from rhosocial.activerecord.backend.impl.sqlserver.expression.columnstore import ( SQLServerColumnstoreIndexExpression, ) + return SQLServerColumnstoreIndexExpression(d, "cci", "t", columns=["a"]) - def partition(d): + def partition_range(d): from rhosocial.activerecord.backend.impl.sqlserver.expression.partition import ( SQLServerPartitionByRangeClause, + SQLServerPartitionFunctionExpression, + SQLServerPartitionRangeDirection, + SQLServerPartitionSchemeExpression, ) + return SQLServerPartitionByRangeClause( d, keys=[Column(d, "id")], partition_scheme="ps" ) + def partition_function(d): + from rhosocial.activerecord.backend.impl.sqlserver.expression.partition import ( + SQLServerPartitionFunctionExpression, + ) + + return SQLServerPartitionFunctionExpression(d, "pf_sales", "DATE", ["2020-01-01"]) + + def partition_scheme(d): + from rhosocial.activerecord.backend.impl.sqlserver.expression.partition import ( + SQLServerPartitionSchemeExpression, + ) + + return SQLServerPartitionSchemeExpression(d, "ps_sales", "pf_sales", ["PRIMARY"]) + def alias_type(d): - from rhosocial.activerecord.backend.expression.types import IntegerType from rhosocial.activerecord.backend.impl.sqlserver.expression.ddl.type import ( SQLServerAliasTypeDefinition, ) + return SQLServerAliasTypeDefinition(d, IntegerType(d), nullability=True) def table_type(d): - from rhosocial.activerecord.backend.expression.statements import ( - ColumnConstraint, - ColumnConstraintType, - ColumnDefinition, - TableConstraint, - TableConstraintType, - ) - from rhosocial.activerecord.backend.expression.types import IntegerType from rhosocial.activerecord.backend.impl.sqlserver.expression.ddl.type import ( SQLServerTableTypeDefinition, ) from rhosocial.activerecord.backend.impl.sqlserver.expression.index import ( SQLServerIndexDefinition, ) + return SQLServerTableTypeDefinition( d, columns=[ @@ -77,16 +503,12 @@ def table_type(d): d, "id", IntegerType(d), - constraints=[ - ColumnConstraint(d, ColumnConstraintType.NOT_NULL), - ], + constraints=[ColumnConstraint(d, ColumnConstraintType.NOT_NULL)], ) ], constraints=[ TableConstraint( - d, - TableConstraintType.PRIMARY_KEY, - columns=["id"], + d, TableConstraintType.PRIMARY_KEY, columns=["id"] ) ], indexes=[SQLServerIndexDefinition(d, "ix_id", ["id"])], @@ -96,65 +518,793 @@ def clr_type(d): from rhosocial.activerecord.backend.impl.sqlserver.expression.ddl.type import ( SQLServerClrTypeDefinition, ) + return SQLServerClrTypeDefinition(d, "assembly", "Namespace.Class") def drop_type(d): from rhosocial.activerecord.backend.impl.sqlserver.expression.ddl.type import ( SQLServerDropTypeExpression, ) - return SQLServerDropTypeExpression(d, "type_name") + + return SQLServerDropTypeExpression(d, Type(d, "type_name")) def rename_type(d): from rhosocial.activerecord.backend.impl.sqlserver.expression.ddl.type import ( SQLServerRenameTypeExpression, ) + return SQLServerRenameTypeExpression(d, "old_name", "new_name") - register_special_constructor("pivot.PivotExpression", pivot) + def column_definition(d): + from rhosocial.activerecord.backend.impl.sqlserver.expression.column import ( + SQLServerColumnDefinition, + ) + + return SQLServerColumnDefinition(d, "id", IntegerType(d)) + + def alter_column(d): + from rhosocial.activerecord.backend.impl.sqlserver.expression.alter_column import ( + SQLServerAlterColumn, + ) + + return SQLServerAlterColumn(d, "c", data_type="INT") + + def create_table(d): + from rhosocial.activerecord.backend.impl.sqlserver.expression.create_table import ( + SQLServerCreateTableExpression, + ) + + return SQLServerCreateTableExpression(d, _table_obj(d), [_integer_column(d)]) + + def as_graph_table(d): + from rhosocial.activerecord.backend.impl.sqlserver.expression.ddl.graph import ( + SQLServerAsGraphTableExpression, + SQLServerGraphTableKind, + ) + + return SQLServerAsGraphTableExpression(d, SQLServerGraphTableKind.NODE) + + def create_function(d): + from rhosocial.activerecord.backend.impl.sqlserver.expression.ddl.routine import ( + SQLServerCreateFunctionExpression, + ) + + return SQLServerCreateFunctionExpression( + d, "fn", returns="INT", body="SELECT 1" + ) + + def create_procedure(d): + from rhosocial.activerecord.backend.impl.sqlserver.expression.ddl.routine import ( + SQLServerCreateProcedureExpression, + ) + + return SQLServerCreateProcedureExpression(d, "p", body="SELECT 1") + + def create_trigger(d): + from rhosocial.activerecord.backend.impl.sqlserver.expression.ddl.trigger import ( + SQLServerCreateTriggerExpression, + ) + + return SQLServerCreateTriggerExpression( + d, "trg", "t", timing="AFTER", events=["INSERT"], body="SELECT 1", + ) + + def sqlserver_merge(d): + from rhosocial.activerecord.backend.impl.sqlserver.expression.dml import ( + SQLServerMergeExpression, + ) + + return SQLServerMergeExpression( + d, + target_table=_table_obj(d), + source=NamedRelationRef(d, Table(d, "src")), + on_condition=_column_predicate(d), + ) + + def option_hint(d): + from rhosocial.activerecord.backend.impl.sqlserver.expression.option_hint import ( + maxdop_hint, + SQLServerOptionHintClause, + ) + + return SQLServerOptionHintClause(d, [maxdop_hint(4)]) + + def pseudo_column(d): + from rhosocial.activerecord.backend.impl.sqlserver.expression.graph import ( + SQLServerGraphPseudoColumn, + ) + + return SQLServerGraphPseudoColumn(d, "$node_id", table="g") + + def graph_pattern(d): + from rhosocial.activerecord.backend.impl.sqlserver.expression.graph import ( + SQLServerGraphEdgeRef, + SQLServerGraphNodeRef, + SQLServerGraphPattern, + ) + + return SQLServerGraphPattern( + d, + SQLServerGraphNodeRef(d, "people"), + [(SQLServerGraphEdgeRef(d, "knows"), SQLServerGraphNodeRef(d, "friends"))], + ) + + def match_predicate(d): + from rhosocial.activerecord.backend.impl.sqlserver.expression.graph import ( + SQLServerMatchPredicate, + ) + + return SQLServerMatchPredicate(d, graph_pattern(d)) + + def shortest_path(d): + from rhosocial.activerecord.backend.impl.sqlserver.expression.graph import ( + SQLServerShortestPathExpression, + ) + + return SQLServerShortestPathExpression(d, graph_pattern(d), 1, 3) + + # ``make_instance`` matches suffixes with ``str.endswith``, and core also has + # a ``pivot.PivotExpression``. The longer suffix keeps this registration on + # the SQL Server class instead of capturing core's as well. Core's own + # PivotExpression is left to the guess: SQL Server's PIVOT spelling needs an + # aggregate the guess does not supply, and it is pinned as a non-render below + # rather than given a constructor here. + register_special_constructor( + "sqlserver.expression.pivot.PivotExpression", pivot + ) register_special_constructor( "columnstore.SQLServerColumnstoreIndexExpression", columnstore ) register_special_constructor( - "partition.SQLServerPartitionByRangeClause", partition + "partition.SQLServerPartitionByRangeClause", partition_range + ) + register_special_constructor( + "partition.SQLServerPartitionFunctionExpression", partition_function + ) + register_special_constructor( + "partition.SQLServerPartitionSchemeExpression", partition_scheme ) register_special_constructor("type.SQLServerAliasTypeDefinition", alias_type) register_special_constructor("type.SQLServerTableTypeDefinition", table_type) register_special_constructor("type.SQLServerClrTypeDefinition", clr_type) register_special_constructor("type.SQLServerDropTypeExpression", drop_type) register_special_constructor("type.SQLServerRenameTypeExpression", rename_type) + register_special_constructor("column.SQLServerColumnDefinition", column_definition) + register_special_constructor("alter_column.SQLServerAlterColumn", alter_column) + register_special_constructor( + "create_table.SQLServerCreateTableExpression", create_table + ) + register_special_constructor( + "ddl.graph.SQLServerAsGraphTableExpression", as_graph_table + ) + register_special_constructor( + "ddl.routine.SQLServerCreateFunctionExpression", create_function + ) + register_special_constructor( + "ddl.routine.SQLServerCreateProcedureExpression", create_procedure + ) + register_special_constructor( + "ddl.trigger.SQLServerCreateTriggerExpression", create_trigger + ) + register_special_constructor("dml.SQLServerMergeExpression", sqlserver_merge) + register_special_constructor("option_hint.SQLServerOptionHintClause", option_hint) + register_special_constructor("graph.SQLServerGraphPseudoColumn", pseudo_column) + register_special_constructor("graph.SQLServerGraphPattern", graph_pattern) + register_special_constructor("graph.SQLServerMatchPredicate", match_predicate) + register_special_constructor( + "graph.SQLServerShortestPathExpression", shortest_path + ) + + +register_specials() + + +# --------------------------------------------------------------------------- +# Lists that cannot grow or shrink silently +# --------------------------------------------------------------------------- + +#: Classes the introspective constructor cannot build. +#: +#: Each is a real coverage gap, named here so it is visible rather than lost. +#: :func:`test_unconstructible_list_is_exact` pins the tuple against what the +#: constructor really skips, in both directions: a class that gains a +#: constructor fails here until this entry is removed, and a class that starts +#: failing to build fails here too. Neither can become a quiet skip. +#: +#: The reasons are grouped by cause, not by module, and all four are now the same +#: cause: a required parameter the introspective guess cannot satisfy, because it +#: sits behind a defaulted positional or is a member of a closed set. Registering a +#: constructor is the way to retire any of them. +#: +#: The four XML classes this tuple used to hold were never a gap in the classes. +#: ``XMLTableExpression`` wants ``columns: Sequence[XMLTableColumn]`` and the three +#: forest-shaped ones each want a ``Sequence`` of their own item type; the guess +#: read that annotation's *alias* name, ``"Sequence"``, decided it named a +#: catalogue object and handed the parameter a ``Sequence`` instead of a list. The +#: classes were fine and the harness was wrong. They construct now, and each one +#: turns out to be a genuine capability gap rather than a render, which is why all +#: four moved to :data:`LEGITIMATE_NON_RENDERS` rather than to a render assertion. +UNCONSTRUCTIBLE = ( + # ALTER CONSTRAINT. `name` and `constraint_type` sit behind defaulted + # positionals and are keyword-only, so the introspective constructor skips + # them and the class refuses an incomplete action (`ValueError: constraint_name + # must be a non-empty string`, because the defaulted `constraint_name` stays + # None). + "rhosocial.activerecord.backend.expression.statements.ddl_alter.AlterConstraint", + # VALIDATE CONSTRAINT. Same shape as AlterConstraint: the required `name` is + # keyword-only behind a defaulted positional. + "rhosocial.activerecord.backend.expression.statements.ddl_alter.ValidateConstraint", + # ADD DOMAIN CHECK. Widens a SQLPredicate into a DomainCheckConstraint and + # needs one; the guess supplies a bare comparison whose literal would have + # to render as a bind parameter, which DDL cannot carry. (It supplies a bare + # *string* in fact: the annotation names a `DomainCheckConstraint`, whose + # `\bDomain\b` fragment does not match inside the longer word, so the guess + # falls through to its name-based default and the class raises + # `TypeError: check must be a DomainCheckConstraint instance, got str`.) + "rhosocial.activerecord.backend.expression.statements.ddl_domain.AddDomainCheckAction", + # ENUM type. Its `values` list is keyword-only behind a defaulted positional, + # so the introspective constructor skips it and the type declares no members + # (`ValueError: EnumType requires values`). + "rhosocial.activerecord.backend.expression.types.enum_.EnumType", +) + +#: Message fragments the pins below match on. +_NO_FORMATTER = "does not declare its dialect formatting method name" +#: The *dispatch* failure -- ``format_method`` named a method the dialect does +#: not define, so ``to_sql()`` never called anything. Core reported that as +#: ``AttributeError`` and now reports it as ``UnsupportedFeatureError``, which is +#: the type a dialect uses when it *knows* a statement and refuses it. The two are +#: the same type now, so the fragment is what separates them: this is the one +#: phrase only the dispatch's suggestion contains, offering to mix in a mixin. +#: A class that stopped being a dispatch failure and became a genuine refusal +#: would raise the same type and fail on the fragment instead. +_NO_SUCH_METHOD = "mix in the mixin that provides it" +_NO_SUCH_TYPE = "does not support the generic type" + +#: Classes that construct but cannot render, for a reason belonging to their own +#: tree rather than to a defect. Each entry pins the exception type *and* a +#: message fragment, so a class that starts failing for a *different* reason +#: fails here instead of passing quietly -- which is the whole point, given that +#: this matrix used to swallow every one of these. +LEGITIMATE_NON_RENDERS = { + # ---- bases that name an expression category, not a renderable thing ---- + # Each of these is a base class that deliberately declares no `format_method`, + # so `to_sql()` reports that there is nothing to dispatch. They are not + # `inspect.isabstract` -- they are concrete enough to construct -- so the + # registry keeps them, and each is pinned to its exact message so a base that + # started rendering fails here instead of passing quietly. + "rhosocial.activerecord.backend.expression.bases.SQLPredicate": ( + NotImplementedError, + _NO_FORMATTER, + ), + "rhosocial.activerecord.backend.expression.bases.SQLValueExpression": ( + NotImplementedError, + _NO_FORMATTER, + ), + # The roots of the object tree. Each concrete object overrides `format_method` + # with its own `format_*_object`; the base names only what every catalogue + # object has in common. + "rhosocial.activerecord.backend.expression.objects.base.SchemaObject": ( + NotImplementedError, + _NO_FORMATTER, + ), + "rhosocial.activerecord.backend.expression.objects.relation.RelationObject": ( + NotImplementedError, + _NO_FORMATTER, + ), + "rhosocial.activerecord.backend.expression.objects.routine.RoutineObject": ( + NotImplementedError, + _NO_FORMATTER, + ), + "rhosocial.activerecord.backend.expression.objects.type_.TypeObject": ( + NotImplementedError, + _NO_FORMATTER, + ), + # Expression-category bases whose concrete members each name their own + # formatter: an ALTER TABLE action, an INSERT row source, a transaction + # step, a temporal value, an introspection query. + "rhosocial.activerecord.backend.expression.statements.ddl_alter.AlterTableAction": ( + NotImplementedError, + _NO_FORMATTER, + ), + "rhosocial.activerecord.backend.expression.statements.dml.InsertDataSource": ( + NotImplementedError, + _NO_FORMATTER, + ), + "rhosocial.activerecord.backend.expression.transaction.TransactionExpression": ( + NotImplementedError, + _NO_FORMATTER, + ), + "rhosocial.activerecord.backend.expression.datetime._TemporalValueExpression": ( + NotImplementedError, + _NO_FORMATTER, + ), + "rhosocial.activerecord.backend.expression.introspection.IntrospectionExpression": ( + NotImplementedError, + _NO_FORMATTER, + ), + + # ---- the root of the row-source tree ---- + # No concrete source renders through `format_table_source`; each overrides + # `format_method`. Rendering the base would mean inventing SQL for an object + # that carries nothing but an alias. Dispatch failure, so UnsupportedFeatureError. + "rhosocial.activerecord.backend.expression.sources.base.TableSource": ( + UnsupportedFeatureError, + _NO_SUCH_METHOD, + ), + + # ---- features SQL Server does not model ---- + # Each of these names a `format_method` the dialect does not define, so the + # dispatch fails before any formatter is called: `to_sql()` reports that the + # dialect declares no such method, not that it knows the statement and refuses + # it. Those are now the *same* exception type -- core raised AttributeError + # for the first and unified it to UnsupportedFeatureError with everything + # else -- so the pinned type no longer tells the two apart and the pinned + # fragment does. See _NO_SUCH_METHOD. + # + # SQL/XML (thirteen): SQL Server's native XML/Xquery is not the SQL/XML + # feature set, which is why every SQLXML* protocol is in the dialect's + # negative list. + # + # Nine are the XMLAGG / XMLCOMMENT / XMLElement / XMLEXISTS / XMLPARSE / + # XMLPI / XMLQUERY / XMLROOT / XMLSERIALIZE family; four more are XMLTABLE, + # XMLATTRIBUTES, XMLCONCAT and XMLFOREST. All thirteen give the same answer + # to the same question -- `SQLServerDialect` declares no `format_xml*` method + # at all, so the dispatch refuses each by name before any formatter is + # reached. The second four only became visible when the testsuite's + # introspective constructor stopped reading a `Sequence[...]` annotation as a + # request for a `Sequence` catalogue object; see the note above + # UNCONSTRUCTIBLE. They are not a new gap in SQL Server -- the other nine + # had been saying the same thing all along. + "rhosocial.activerecord.backend.expression.xml.XMLAggExpression": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + "rhosocial.activerecord.backend.expression.xml.XMLAttributesExpression": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + "rhosocial.activerecord.backend.expression.xml.XMLCommentExpression": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + "rhosocial.activerecord.backend.expression.xml.XMLConcatExpression": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + "rhosocial.activerecord.backend.expression.xml.XMLElementExpression": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + "rhosocial.activerecord.backend.expression.xml.XMLExistsExpression": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + "rhosocial.activerecord.backend.expression.xml.XMLForestExpression": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + "rhosocial.activerecord.backend.expression.xml.XMLPIExpression": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + "rhosocial.activerecord.backend.expression.xml.XMLParseExpression": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + "rhosocial.activerecord.backend.expression.xml.XMLQueryExpression": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + "rhosocial.activerecord.backend.expression.xml.XMLRootExpression": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + "rhosocial.activerecord.backend.expression.xml.XMLSerializeExpression": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + "rhosocial.activerecord.backend.expression.xml.XMLTableExpression": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + # CREATE/ALTER/DROP DOMAIN (nine): SQL Server has no DOMAIN. The three + # statements and their five actions plus the check constraint and the domain + # value all name a formatter the dialect does not have. + "rhosocial.activerecord.backend.expression.statements.ddl_domain.CreateDomainExpression": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + "rhosocial.activerecord.backend.expression.statements.ddl_domain.AlterDomainExpression": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + "rhosocial.activerecord.backend.expression.statements.ddl_domain.DropDomainExpression": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + "rhosocial.activerecord.backend.expression.statements.ddl_domain.DomainCheckConstraint": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + "rhosocial.activerecord.backend.expression.statements.ddl_domain.DomainValueExpression": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + "rhosocial.activerecord.backend.expression.statements.ddl_domain.DropDomainCheckAction": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + "rhosocial.activerecord.backend.expression.statements.ddl_domain.DropDomainDefaultAction": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + "rhosocial.activerecord.backend.expression.statements.ddl_domain.DropDomainNotNullAction": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + "rhosocial.activerecord.backend.expression.statements.ddl_domain.RenameDomainAction": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + "rhosocial.activerecord.backend.expression.statements.ddl_domain.SetDomainDefaultAction": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + "rhosocial.activerecord.backend.expression.statements.ddl_domain.SetDomainNotNullAction": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + # SQL/PGQ property graphs (eight): no GRAPH_TABLE, so no graph DDL either. + # The negative list records GraphTableSupport; these are the same gap seen + # from the expression tree. + "rhosocial.activerecord.backend.expression.graph.GraphTableExpression": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + "rhosocial.activerecord.backend.expression.graph.VertexTable": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + "rhosocial.activerecord.backend.expression.graph.EdgeTable": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + "rhosocial.activerecord.backend.expression.graph.ColumnsClause": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + "rhosocial.activerecord.backend.expression.graph.TablePropertiesClause": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + "rhosocial.activerecord.backend.expression.graph.CreatePropertyGraphExpression": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + "rhosocial.activerecord.backend.expression.graph.AlterPropertyGraphExpression": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + "rhosocial.activerecord.backend.expression.graph.DropPropertyGraphExpression": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + # PIVOT / UNPIVOT (two): SQL Server spells PIVOT its own way, through + # SQLServerPivotExpression. Core's generic PIVOT is not the same statement. + "rhosocial.activerecord.backend.expression.pivot.PivotExpression": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + "rhosocial.activerecord.backend.expression.pivot.UnpivotExpression": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + # COMMENT ON: SQL Server has no standalone COMMENT ON statement, which the + # dialect's negative list records as CommentSupport. + "rhosocial.activerecord.backend.expression.statements.ddl_comment.CommentOnExpression": ( + UnsupportedFeatureError, _NO_SUCH_METHOD + ), + + # ---- the root of the type tree ---- + # Every concrete type declares its own generic `name`, which is what + # `format_data_type` dispatches on; the root declares none, so there is + # nothing to dispatch. TypeError, not UnsupportedFeatureError, because the + # class is incomplete rather than the dialect being unable. + "rhosocial.activerecord.backend.expression.types._base.DataType": ( + TypeError, + "does not declare a valid generic type name", + ), + # Core types with no SQL Server spelling (eight). SQL Server has no ARRAY, + # no JSONB, no TIMESTAMPTZ, and spells BINARY as its own types, so these + # declare a generic name the dialect does not implement. This is a + # capability gap in this backend's type table, not a defect in the class. + "rhosocial.activerecord.backend.expression.types.array.ArrayType": ( + TypeError, _NO_SUCH_TYPE + ), + "rhosocial.activerecord.backend.expression.types.binary.BinaryType": ( + TypeError, _NO_SUCH_TYPE + ), + "rhosocial.activerecord.backend.expression.types.binary.VarBinaryType": ( + TypeError, _NO_SUCH_TYPE + ), + "rhosocial.activerecord.backend.expression.types.datetime_.IntervalType": ( + TypeError, _NO_SUCH_TYPE + ), + "rhosocial.activerecord.backend.expression.types.datetime_.TimeTzType": ( + TypeError, _NO_SUCH_TYPE + ), + "rhosocial.activerecord.backend.expression.types.datetime_.TimestampTzType": ( + TypeError, _NO_SUCH_TYPE + ), + "rhosocial.activerecord.backend.expression.types.json_.JsonBType": ( + TypeError, _NO_SUCH_TYPE + ), + "rhosocial.activerecord.backend.expression.types.uuid_.UUIDType": ( + TypeError, _NO_SUCH_TYPE + ), + + # ---- core expressions SQL Server has no T-SQL spelling for ---- + # + # These four are the shadowing problem, and they are the reason this matrix + # exists in this form. SQL Server's own T-SQL DDL for full-text indexes, + # functions and triggers is spelled by formatters that took over the *core + # formatter names*: ``format_create_fulltext_index_statement`` here takes + # ``(table, columns, key_index, catalog_name)`` where core's takes the + # expression, and ``format_create_function_statement`` takes a + # ``SQLServerCreateFunctionExpression`` where core's takes a + # ``CreateFunctionExpression``. So when the core expression dispatches -- + # which is what ``format_method`` does -- it calls a function whose + # signature it cannot satisfy. + # + # This is a recorded gap, not an oversight, and each entry pins the exact + # failure so the shape of it cannot change unnoticed. The fix is to rename + # the backend's formatters (``format_sqlserver_create_fulltext_index_statement`` + # and friends) so the core names are free again, and to point the backend's + # own expressions at the renamed methods. That is a rename across five files + # and four protocol declarations, which is its own change with its own + # review; it is not smuggled in here behind a comment. + "rhosocial.activerecord.backend.expression.statements.ddl_index.CreateFulltextIndexExpression": ( + TypeError, + "missing 3 required positional arguments", + ), + "rhosocial.activerecord.backend.expression.statements.ddl_index.DropFulltextIndexExpression": ( + ValueError, + "name must be a non-empty string", + ), + "rhosocial.activerecord.backend.expression.statements.ddl_function.CreateFunctionExpression": ( + ValueError, + "function name is required", + ), + "rhosocial.activerecord.backend.expression.statements.ddl_trigger.DropTriggerExpression": ( + ValueError, + "trigger name is required", + ), +} + + +# --------------------------------------------------------------------------- +# The local SQL assertion: classify the outcome instead of swallowing it +# --------------------------------------------------------------------------- + + +def assert_sql_roundtrip_classified( + fqn: str, instance, dialect +) -> str: + """Assert an expression's SQL survives the round-trip, or say precisely why not. + Four outcomes, each asserted: -_register_sqlserver_specials() + * **renders** -- all three encodings must restore byte-identical SQL *and* + byte-identical bind parameters. + * a member of :data:`LEGITIMATE_NON_RENDERS` -- unrenderable by design, + asserted as its exact type *and* message fragment. + * ``UnsupportedFeatureError`` from a class *not* named in the dict -- SQL + Server does not model the feature. Asserted as exactly that type, so a + subclass raised for an unrelated reason is still visible rather than passing. + * **anything else** -- a failure naming the class and the exception. + The dict is consulted before the ``UnsupportedFeatureError`` branch, not + after. A missing formatter is now reported through that same type, so with the + ``except`` clause in front a pinned dispatch-failure entry would never be + looked up: its type and message would go unasserted on this path and the + table would have stopped saying which of the two causes each class has. + Checking the pin first keeps both halves of it load-bearing. -@pytest.fixture(params=[fqn for fqn in sorted(CLASSES)], ids=sorted(CLASSES)) + Args: + fqn: Fully qualified name of the class, used in every message. + instance: The expression to render. + dialect: The dialect to render it against. + + Returns: + A short string naming the branch taken, so the classification + distribution can be reported. + + Raises: + AssertionError: On a round-trip mismatch, on an unexpected exception + type, or when a class's rendering outcome changed. + """ + from rhosocial.activerecord.backend.expression.serialization import ( + deserialize, + deserialize_json, + deserialize_xml, + serialize, + serialize_json, + serialize_xml, + ) + + try: + expected_sql, expected_params = instance.to_sql() + except Exception as exc: + if fqn in LEGITIMATE_NON_RENDERS: + expected_type, fragment = LEGITIMATE_NON_RENDERS[fqn] + assert type(exc) is expected_type, ( + f"{fqn}: LEGITIMATE_NON_RENDERS pins this class as a legitimate " + f"non-render raising {expected_type.__name__}, but it raised " + f"{type(exc).__name__}: {exc}" + ) + assert fragment in str(exc), ( + f"{fqn}: expected {expected_type.__name__} and was expected to say " + f"{fragment!r}, but it said: {exc}" + ) + return "non-render" + if type(exc) is UnsupportedFeatureError: + return "unsupported" + raise AssertionError( + f"{fqn}: to_sql() raised {type(exc).__name__}, which is neither a " + f"render nor a classified non-render, and this is a defect.\n" + f" UnsupportedFeatureError means the dialect lacks the feature and " + f"is always allowed.\n" + f" A class that cannot render for a reason belonging to its own " + f"tree belongs in LEGITIMATE_NON_RENDERS.\n" + f" Exception: {exc}" + ) from exc + + for channel, decoded in ( + ("dict", deserialize(serialize(instance), dialect)), + ("json", deserialize_json(serialize_json(instance), dialect)), + ("xml", deserialize_xml(serialize_xml(instance), dialect)), + ): + decoded_sql, decoded_params = decoded.to_sql() + assert decoded_sql == expected_sql, ( + f"{fqn}: {channel} round-trip changed the SQL.\n" + f" original: {expected_sql!r}\n" + f" {channel}: {decoded_sql!r}" + ) + assert decoded_params == expected_params, ( + f"{fqn}: {channel} round-trip changed the bind parameters.\n" + f" original: {expected_params!r}\n" + f" {channel}: {decoded_params!r}" + ) + return "rendered" + + +@pytest.fixture(params=sorted(REGISTERED), ids=sorted(REGISTERED)) def sqlserver_expr_case(request, sqlserver_dialect): + """One expression instance per collected class, or a pinned skip. + + A class that cannot be built is a skip only if it is named in + :data:`UNCONSTRUCTIBLE`; anything else fails here rather than disappearing, + because an unnamed skip is a coverage hole with no marker. + """ fqn = request.param - cls = CLASSES[fqn] + cls = REGISTERED[fqn] instance, source = make_instance(cls, sqlserver_dialect) if instance is None: - pytest.skip(f"{fqn}: {source}") + assert fqn in UNCONSTRUCTIBLE, ( + f"{fqn} cannot be built by the generic constructor ({source}) and is " + f"not in UNCONSTRUCTIBLE. Either register a special constructor for " + f"it or add it to the tuple with a reason -- do not let it disappear " + f"into a skip." + ) + pytest.skip(f"{fqn}: pinned in UNCONSTRUCTIBLE, cannot be constructed ({source})") return fqn, instance class TestSQLServerExpressionRoundtrip: - """All constructible SQL Server expression classes round-trip losslessly.""" + """All constructible expression classes round-trip through all encodings.""" def test_get_params_roundtrip(self, sqlserver_expr_case, sqlserver_dialect): fqn, instance = sqlserver_expr_case roundtrip_expression(fqn, instance, sqlserver_dialect) - def test_to_sql_consistent(self, sqlserver_expr_case, sqlserver_dialect): + def test_to_sql_roundtrip_classified(self, sqlserver_expr_case, sqlserver_dialect): + """A render must survive the round-trip; a non-render must be classified.""" fqn, instance = sqlserver_expr_case - sql_consistent(fqn, instance, sqlserver_dialect) + assert_sql_roundtrip_classified(fqn, instance, sqlserver_dialect) -def test_core_expressions_also_roundtrip(sqlserver_dialect): - from rhosocial.activerecord.backend.expression.core import Column, Literal - from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +class TestMatrixIntegrity: + """Guards on the matrix and its lists, so neither can quietly change.""" - expr = ComparisonPredicate( - sqlserver_dialect, "=", Column(sqlserver_dialect, "a"), Literal(sqlserver_dialect, 1) - ) - roundtrip_expression("core", expr, sqlserver_dialect) - sql_consistent("core", expr, sqlserver_dialect) \ No newline at end of file + def test_unconstructible_list_is_exact(self, sqlserver_dialect): + """Pin the unconstructible tuple against what the constructor really skips. + + Two directions are checked. A class named here that now builds has gained + a constructor and the entry is stale; a class that fails to build without + being named would become a silent skip. Both fail here. + """ + ExpressionRegistry._auto_register_builtins() + actual = tuple( + sorted( + fqn + for fqn in REGISTERED + if make_instance(REGISTERED[fqn], sqlserver_dialect)[0] is None + ) + ) + assert actual == tuple(sorted(UNCONSTRUCTIBLE)), ( + "the set of expression classes the generic constructor cannot build " + "changed.\n" + f" now skipped but not named: " + f"{sorted(set(actual) - set(UNCONSTRUCTIBLE))}\n" + f" named but now built: " + f"{sorted(set(UNCONSTRUCTIBLE) - set(actual))}\n" + "Each new entry needs a reason in the comment above UNCONSTRUCTIBLE." + ) + + def test_unconstructible_entries_are_real_classes(self): + """Every entry names a class that was actually collected. + + A typo in the tuple would otherwise exempt nothing while still reading as + a deliberate decision. + """ + unknown = set(UNCONSTRUCTIBLE) - set(REGISTERED) + assert not unknown, ( + f"UNCONSTRUCTIBLE names classes that were not registered: {sorted(unknown)}" + ) + + def test_legitimate_non_renders_are_real_classes(self): + """Every pinned non-render names a class that was actually collected.""" + unknown = set(LEGITIMATE_NON_RENDERS) - set(REGISTERED) + assert not unknown, ( + f"LEGITIMATE_NON_RENDERS names classes that were not registered: " + f"{sorted(unknown)}" + ) + + def test_pinned_non_render_really_does_not_render(self, sqlserver_dialect): + """Each pinned entry still raises what it claims, for the stated reason. + + Without this, an entry could sit in the dict for a class that renders + perfectly well, and the matrix would be asserting nothing about it. + """ + for fqn, (expected_type, fragment) in LEGITIMATE_NON_RENDERS.items(): + instance, source = make_instance(REGISTERED[fqn], sqlserver_dialect) + assert instance is not None, ( + f"{fqn} is pinned as a non-render but could not be constructed " + f"({source})" + ) + with pytest.raises(expected_type) as exc_info: + instance.to_sql() + assert fragment in str(exc_info.value), ( + f"{fqn}: expected the message to mention {fragment!r}, got: " + f"{exc_info.value}" + ) + + def test_matrix_covers_both_packages(self): + """The matrix covers every concrete class both packages define. + + Re-walked here rather than trusting the module-level collection, so a + class that appeared after import is caught. The package walk is used + rather than the registry because the registry also holds whatever + backends other test modules happened to import -- which is exactly the + non-determinism this matrix moved off. + """ + ExpressionRegistry._auto_register_builtins() + for pkg in (CORE_EXPR_PKG, SS_EXPR_PKG): + expected = set(_collect(pkg)) + covered = { + fqn for fqn in REGISTERED if fqn.startswith(f"{pkg}.") + } + assert expected == covered, ( + f"the set of classes {pkg} defines changed after collection.\n" + f" now defined but not covered: {sorted(expected - covered)}\n" + f" covered but no longer defined: {sorted(covered - expected)}" + ) + assert len(REGISTERED) > 300, ( + f"only {len(REGISTERED)} classes collected; the package walk may " + f"have stopped early" + ) + + def test_every_covered_class_is_registered_for_deserialization(self): + """A class in the matrix can be found again when deserializing. + + Deserialization looks the class up by name, so a class the matrix renders + but the registry cannot resolve would round-trip into the wrong thing or + nothing at all. + """ + ExpressionRegistry._auto_register_builtins() + unresolved = sorted(set(REGISTERED) - set(ExpressionRegistry._registry)) + assert not unresolved, ( + f"the matrix covers classes the registry cannot resolve: {unresolved}" + ) + + def test_coverage_report(self, sqlserver_dialect): + """Surface what the matrix covers, so coverage stays transparent.""" + ExpressionRegistry._auto_register_builtins() + branches = {"rendered": 0, "unsupported": 0, "non-render": 0} + constructible = [] + for fqn in sorted(REGISTERED): + instance, source = make_instance(REGISTERED[fqn], sqlserver_dialect) + if instance is None: + continue + constructible.append(fqn) + branches[ + assert_sql_roundtrip_classified(fqn, instance, sqlserver_dialect) + ] += 1 + assert constructible + print( + f"\nexpression matrix: {len(REGISTERED)} collected, " + f"{len(constructible)} constructible, " + f"{len(UNCONSTRUCTIBLE)} pinned-unconstructible; " + f"rendered {branches['rendered']}, " + f"unsupported {branches['unsupported']}, " + f"pinned non-render {branches['non-render']}" + ) + for fqn in UNCONSTRUCTIBLE: + print(f" not constructible: {fqn}") \ No newline at end of file diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_expressions.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_expressions.py index 663835d..80a8562 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_expressions.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_expressions.py @@ -18,13 +18,17 @@ def dialect(self): def test_offset_fetch_basic(self, dialect): """Test basic OFFSET FETCH formatting.""" - from rhosocial.activerecord.backend.expression import QueryExpression, Column, TableExpression + from rhosocial.activerecord.backend.expression import ( + QueryExpression, + Column, + ) + from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.query_parts import LimitOffsetClause query = QueryExpression( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name")], - from_=TableExpression(dialect, "users"), + from_=Table(dialect, "users"), limit_offset=LimitOffsetClause(dialect, limit=10, offset=0), ) @@ -34,13 +38,17 @@ def test_offset_fetch_basic(self, dialect): def test_offset_fetch_with_offset(self, dialect): """Test OFFSET FETCH with non-zero offset.""" - from rhosocial.activerecord.backend.expression import QueryExpression, Column, TableExpression + from rhosocial.activerecord.backend.expression import ( + QueryExpression, + Column, + ) + from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.query_parts import LimitOffsetClause query = QueryExpression( dialect=dialect, select=[Column(dialect, "id")], - from_=TableExpression(dialect, "products"), + from_=Table(dialect, "products"), limit_offset=LimitOffsetClause(dialect, limit=20, offset=10), ) @@ -50,7 +58,11 @@ def test_offset_fetch_with_offset(self, dialect): def test_offset_only_no_fetch(self, dialect): """Test OFFSET with LIMIT (SQL Server requires LIMIT with OFFSET).""" - from rhosocial.activerecord.backend.expression import QueryExpression, Column, TableExpression + from rhosocial.activerecord.backend.expression import ( + QueryExpression, + Column, + ) + from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.query_parts import LimitOffsetClause # In SQL Server, you cannot have OFFSET without LIMIT @@ -58,7 +70,7 @@ def test_offset_only_no_fetch(self, dialect): query = QueryExpression( dialect=dialect, select=[Column(dialect, "id")], - from_=TableExpression(dialect, "items"), + from_=Table(dialect, "items"), limit_offset=LimitOffsetClause(dialect, limit=10, offset=5), ) @@ -81,13 +93,13 @@ def test_output_clause_insert(self, dialect): ValuesSource, ReturningClause, Column, - TableExpression, ) + from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.core import Literal insert = InsertExpression( dialect=dialect, - into=TableExpression(dialect, "users"), + into=Table(dialect, "users"), columns=["name"], source=ValuesSource(dialect, [[Literal(dialect, "Alice")]]), returning=ReturningClause(dialect, [Column(dialect, "id")]), @@ -105,13 +117,13 @@ def test_output_clause_multiple_columns(self, dialect): ValuesSource, ReturningClause, Column, - TableExpression, ) + from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.core import Literal insert = InsertExpression( dialect=dialect, - into=TableExpression(dialect, "products"), + into=Table(dialect, "products"), columns=["name", "price"], source=ValuesSource(dialect, [[Literal(dialect, "Widget"), Literal(dialect, 9.99)]]), returning=ReturningClause(dialect, [Column(dialect, "id"), Column(dialect, "created_at")]), @@ -132,11 +144,12 @@ def dialect(self): def test_basic_index(self, dialect): """Test basic CREATE INDEX.""" from rhosocial.activerecord.backend.expression import CreateIndexExpression + from rhosocial.activerecord.backend.expression.objects import Index, Table idx = CreateIndexExpression( dialect=dialect, - index_name="idx_users_email", - table_name="users", + index=Index(dialect, "idx_users_email"), + table=Table(dialect, "users"), columns=["email"], ) @@ -148,11 +161,12 @@ def test_basic_index(self, dialect): def test_unique_index(self, dialect): """Test CREATE UNIQUE INDEX.""" from rhosocial.activerecord.backend.expression import CreateIndexExpression + from rhosocial.activerecord.backend.expression.objects import Index, Table idx = CreateIndexExpression( dialect=dialect, - index_name="idx_users_email_unique", - table_name="users", + index=Index(dialect, "idx_users_email_unique"), + table=Table(dialect, "users"), columns=["email"], unique=True, ) @@ -163,11 +177,12 @@ def test_unique_index(self, dialect): def test_composite_index(self, dialect): """Test CREATE INDEX with multiple columns.""" from rhosocial.activerecord.backend.expression import CreateIndexExpression + from rhosocial.activerecord.backend.expression.objects import Index, Table idx = CreateIndexExpression( dialect=dialect, - index_name="idx_orders_composite", - table_name="orders", + index=Index(dialect, "idx_orders_composite"), + table=Table(dialect, "orders"), columns=["user_id", "created_at"], ) @@ -187,16 +202,17 @@ def test_inner_join(self, dialect): """Test INNER JOIN formatting.""" from rhosocial.activerecord.backend.expression import ( QueryExpression, - TableExpression, Column, JoinClause, ) + from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate + from rhosocial.activerecord.backend.expression.sources import NamedRelationRef join = JoinClause( dialect=dialect, - left_table=TableExpression(dialect, "users", alias="u"), - right_table=TableExpression(dialect, "orders", alias="o"), + left_table=NamedRelationRef(dialect, Table(dialect, "users"), alias="u"), + right_table=NamedRelationRef(dialect, Table(dialect, "orders"), alias="o"), join_type="INNER JOIN", condition=ComparisonPredicate( dialect, @@ -221,16 +237,17 @@ def test_left_join(self, dialect): """Test LEFT JOIN formatting.""" from rhosocial.activerecord.backend.expression import ( QueryExpression, - TableExpression, Column, JoinClause, ) + from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate + from rhosocial.activerecord.backend.expression.sources import NamedRelationRef join = JoinClause( dialect=dialect, - left_table=TableExpression(dialect, "users", alias="u"), - right_table=TableExpression(dialect, "profiles", alias="p"), + left_table=NamedRelationRef(dialect, Table(dialect, "users"), alias="u"), + right_table=NamedRelationRef(dialect, Table(dialect, "profiles"), alias="p"), join_type="LEFT JOIN", condition=ComparisonPredicate( dialect, diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_niladic_functions.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_niladic_functions.py index 5119980..d53e1fb 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_niladic_functions.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_niladic_functions.py @@ -35,6 +35,7 @@ from rhosocial.activerecord.backend.expression.types import IntegerType, TimestampType from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.expression.objects import Table DQL_OPTIONS = ExecutionOptions(stmt_type=StatementType.DQL) @@ -115,12 +116,12 @@ def test_ddl_default_current_timestamp_niladic(self, sqlserver_backend): # Clean up sqlserver_backend.execute(*DropTableExpression( - dialect=dialect, table=table_name, if_exists=True + dialect=dialect, table=Table(dialect, table_name), if_exists=True ).to_sql()) create = CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), columns=[ ColumnDefinition(dialect, 'id', IntegerType(dialect), constraints=[ ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), @@ -145,7 +146,7 @@ def test_ddl_default_current_timestamp_niladic(self, sqlserver_backend): assert 'ts' in col_names finally: sqlserver_backend.execute(*DropTableExpression( - dialect=dialect, table=table_name, if_exists=True + dialect=dialect, table=Table(dialect, table_name), if_exists=True ).to_sql()) def test_ddl_default_sysdatetime(self, sqlserver_backend): @@ -155,12 +156,12 @@ def test_ddl_default_sysdatetime(self, sqlserver_backend): # Clean up sqlserver_backend.execute(*DropTableExpression( - dialect=dialect, table=table_name, if_exists=True + dialect=dialect, table=Table(dialect, table_name), if_exists=True ).to_sql()) create = CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), columns=[ ColumnDefinition(dialect, 'id', IntegerType(dialect), constraints=[ ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), @@ -184,7 +185,7 @@ def test_ddl_default_sysdatetime(self, sqlserver_backend): assert 'ts' in col_names finally: sqlserver_backend.execute(*DropTableExpression( - dialect=dialect, table=table_name, if_exists=True + dialect=dialect, table=Table(dialect, table_name), if_exists=True ).to_sql()) @@ -216,12 +217,12 @@ async def test_async_ddl_default_current_timestamp_niladic(self, async_sqlserver # Clean up await async_sqlserver_backend.execute(*DropTableExpression( - dialect=dialect, table=table_name, if_exists=True + dialect=dialect, table=Table(dialect, table_name), if_exists=True ).to_sql()) create = CreateTableExpression( dialect=dialect, - table=table_name, + table=Table(dialect, table_name), columns=[ ColumnDefinition(dialect, 'id', IntegerType(dialect), constraints=[ ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), @@ -243,5 +244,5 @@ async def test_async_ddl_default_current_timestamp_niladic(self, async_sqlserver assert 'ts' in col_names finally: await async_sqlserver_backend.execute(*DropTableExpression( - dialect=dialect, table=table_name, if_exists=True + dialect=dialect, table=Table(dialect, table_name), if_exists=True ).to_sql()) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p1_coverage_features.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p1_coverage_features.py index b4fdeeb..24e2800 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p1_coverage_features.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p1_coverage_features.py @@ -48,7 +48,8 @@ def _make_query(d, columns=("a", "b"), where=None, select_into_table=None): - from rhosocial.activerecord.backend.expression import Column, TableExpression + from rhosocial.activerecord.backend.expression import Column + from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.impl.sqlserver.expression import ( SQLServerSelectIntoExpression, ) @@ -56,7 +57,7 @@ def _make_query(d, columns=("a", "b"), where=None, select_into_table=None): return SQLServerSelectIntoExpression( dialect=d, select=[Column(d, col) for col in columns], - from_=TableExpression(d, "t"), + from_=Table(d, "t"), where=where, select_into_table=select_into_table, ) @@ -345,9 +346,10 @@ def dialect(self): return SQLServerDialect(SQL_SERVER_2022) def _alter(self, d, action): + from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import AlterTableExpression - return AlterTableExpression(d, "t", actions=[action]).to_sql()[0] + return AlterTableExpression(d, Table(d, "t"), actions=[action]).to_sql()[0] def _alter_column(self, d, column_name="c", operation="SET DATA TYPE", new_value=None, **options): from rhosocial.activerecord.backend.impl.sqlserver.expression import ( diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p2_coverage_features.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p2_coverage_features.py index d51b3f6..ad9c925 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p2_coverage_features.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p2_coverage_features.py @@ -15,6 +15,7 @@ import pytest from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.objects import Table, View from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect from rhosocial.activerecord.backend.impl.sqlserver.expression.fulltext import ( SQLServerCreateFullTextCatalogExpression, @@ -50,12 +51,11 @@ def _make_query(d, columns=("id", "name"), from_="users", where=None): - from rhosocial.activerecord.backend.expression import Column, QueryExpression, TableExpression - + from rhosocial.activerecord.backend.expression import Column, QueryExpression return QueryExpression( dialect=d, select=[Column(d, col) for col in columns], - from_=TableExpression(d, from_), + from_=Table(d, from_), where=where, ) @@ -101,7 +101,7 @@ def _create_table(self, d, memory_optimized=True, durability="SCHEMA_ONLY", d, memory_optimized=True, durability=durability ) return CreateTableExpression( - d, "t", [col], indexes=indexes, table_options=table_options + d, Table(d, "t"), [col], indexes=indexes, table_options=table_options ) def test_mixin_registered(self, dialect): @@ -214,7 +214,7 @@ def test_create_table_with_hash_index_missing_bucket_count(self, dialect): col.constraints.append(ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)) ct = CreateTableExpression( dialect, - "t", + Table(dialect, "t"), [col], indexes=[SQLServerIndexDefinition(dialect, "ix", ["id"], hash_index=True)], table_options=SQLServerCreateTableOptions(dialect, memory_optimized=True), @@ -251,7 +251,7 @@ def dialect(self): def _view(self, d, view_name="v", columns=("id", "name")): from rhosocial.activerecord.backend.expression import CreateViewExpression - return CreateViewExpression(d, view_name, _make_query(d, columns)) + return CreateViewExpression(d, View(d, view_name), _make_query(d, columns)) def test_supports_indexed_view(self): assert SQLServerDialect(SQL_SERVER_2005).supports_indexed_view() is True @@ -281,7 +281,10 @@ def test_create_view_with_schemabinding_option(self, dialect): ) view = CreateViewExpression( - dialect, "v", _make_query(dialect), options=ViewOptions(schemabinding=True) + dialect, + View(dialect, "v"), + _make_query(dialect), + options=ViewOptions(schemabinding=True), ) sql, _ = view.to_sql() assert sql == "CREATE VIEW [v] WITH SCHEMABINDING AS SELECT [id], [name] FROM [users]" diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_protocol_support.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_protocol_support.py index 380b8f7..5a9cc1f 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_protocol_support.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_protocol_support.py @@ -205,13 +205,14 @@ def test_next_value_for_in_select(self): def test_next_value_for_in_insert_values(self): d = SQLServerDialect(SQL_SERVER_2022) - from rhosocial.activerecord.backend.expression import InsertExpression, TableExpression + from rhosocial.activerecord.backend.expression import InsertExpression + from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ValuesSource seq = SQLServerNextValueForExpression(d, "my_seq") insert = InsertExpression( dialect=d, - into=TableExpression(d, "t"), + into=Table(d, "t"), columns=["id"], source=ValuesSource(d, [[seq]]), ) @@ -227,7 +228,8 @@ def dialect(self): return SQLServerDialect(SQL_SERVER_2022) def _build_merge(self, d): - from rhosocial.activerecord.backend.expression import Column, TableExpression + from rhosocial.activerecord.backend.expression import Column + from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.expression.statements import ( MergeAction, @@ -239,8 +241,8 @@ def _build_merge(self, d): return SQLServerMergeExpression( dialect=d, - target_table="tgt", - source=TableExpression(d, "src"), + target_table=Table(d, "tgt"), + source=Table(d, "src"), on_condition=ComparisonPredicate( d, "=", Column(d, "id", "tgt"), Column(d, "id", "src") ), @@ -513,15 +515,14 @@ def test_format_create_indexed_view_statement(self, dialect): Column, CreateViewExpression, QueryExpression, - TableExpression, ) - + from rhosocial.activerecord.backend.expression.objects import Table, View query = QueryExpression( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name")], - from_=TableExpression(dialect, "users"), + from_=Table(dialect, "users"), ) - view = CreateViewExpression(dialect, "user_ids", query) + view = CreateViewExpression(dialect, View(dialect, "user_ids"), query) sql, params = dialect.format_create_indexed_view_statement(view) assert sql == ( "CREATE VIEW [user_ids] WITH SCHEMABINDING AS " diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_schema_support.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_schema_support.py index 7ed7336..e03cfec 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_schema_support.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_schema_support.py @@ -1,15 +1,22 @@ # tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_schema_support.py -"""Tests for the SchemaSupport capability declared on the SQL Server dialect. +"""Tests for the SQL Server schema and namespace capabilities. SQL Server models named schema namespaces natively (e.g. dbo), so the dialect -must report ``supports_schema()`` as True. IF NOT EXISTS / IF EXISTS variants +must report ``supports_schema()`` as True, and it renders every namespace level +a name can carry -- catalog, schema and name. IF NOT EXISTS / IF EXISTS variants are not supported by the server and must stay False. """ -from rhosocial.activerecord.backend.dialect.protocols import SchemaSupport +from rhosocial.activerecord.backend.dialect.protocols import NamespaceSupport +from rhosocial.activerecord.backend.expression.objects import Schema, Table from rhosocial.activerecord.backend.expression.statements.ddl_schema import ( CreateSchemaExpression, DropSchemaExpression, ) +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + ColumnDefinition, + CreateTableExpression, +) +from rhosocial.activerecord.backend.expression.types import IntegerType from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect @@ -23,8 +30,100 @@ def _dialect(self) -> SQLServerDialect: def test_supports_schema_is_true(self): assert self._dialect().supports_schema() is True - def test_implements_schema_support_protocol(self): - assert isinstance(self._dialect(), SchemaSupport) + def test_implements_namespace_support_protocol(self): + assert isinstance(self._dialect(), NamespaceSupport) + + def test_renders_every_namespace_level(self): + """SQL Server qualifies a name with both its catalog and its schema.""" + d = self._dialect() + assert d.supports_catalog() is True + assert d.supports_catalog_qualification() is True + assert d.supports_schema_qualification() is True + assert Table(d, "t", catalog_name="app", schema_name="dbo").to_sql() == ( + "[app].[dbo].[t]", + (), + ) + + def test_identifier_cap_is_enforced(self): + """A namespace part longer than 128 characters is rejected.""" + d = self._dialect() + try: + Table(d, "t", schema_name="s" * 129).to_sql() + except ValueError as error: + assert "128 characters" in str(error) + else: + raise AssertionError("a 129-character schema name was rendered") + + def test_object_id_name_is_the_bare_name_not_the_bracketed_one(self): + """``OBJECT_ID`` takes bare text, and the bracketed form breaks it. + + This is the failure mode the whole ``OBJECT_ID`` shape exists to prevent. + Every other name SQL Server renders is bracketed, because ``format_identifier`` + brackets it. ``OBJECT_ID`` is handed a *string literal* instead, and SQL + Server parses that literal's text into namespace parts -- brackets included. + So ``OBJECT_ID(N'[app].[dbo].[users]')`` resolves to nothing and returns + NULL for a table that plainly exists, and the ``CREATE TABLE IF NOT EXISTS`` + guard built on it runs the CREATE anyway. + + The two spellings are asserted side by side, so a change that routed the + ``OBJECT_ID`` name through ``format_qualified_name`` fails here even though + both methods would still render without error. + """ + d = self._dialect() + table = Table(d, "users", catalog_name="app", schema_name="dbo") + + bracketed = d.format_qualified_name(table)[0] + bare = d.format_object_id_name(table) + + assert bracketed == "[app].[dbo].[users]" + assert bare == "app.dbo.users" + assert "[" not in bare and "]" not in bare, ( + f"the OBJECT_ID name carries brackets: {bare!r}. SQL Server parses the " + f"string literal's text, so brackets make it resolve to nothing." + ) + + def test_object_id_guard_in_create_table_is_unbracketed(self): + """The one call site: the ``IF NOT EXISTS`` guard on CREATE TABLE. + + Asserted on the rendered statement rather than on the formatter, because the + call site is where a future change would reintroduce the brackets -- the + formatter would still be correct on its own. + """ + d = self._dialect() + table = Table(d, "users", catalog_name="app", schema_name="dbo") + column = ColumnDefinition(d, "id", IntegerType(d)) + expr = CreateTableExpression(d, table, [column], if_not_exists=True) + + sql, _ = expr.to_sql() + guard = sql.splitlines()[0] + + assert guard == "IF OBJECT_ID(N'app.dbo.users', N'U') IS NULL" + assert "[" not in guard, ( + f"the OBJECT_ID guard carries brackets: {guard!r}; it would resolve to " + f"NULL and the CREATE would run unconditionally." + ) + # The CREATE itself is still bracketed: only the OBJECT_ID argument is not. + assert "CREATE TABLE [app].[dbo].[users]" in sql + + def test_object_id_name_still_validates_the_namespace(self): + """The bare spelling must not bypass the namespace checks. + + ``format_object_id_name`` used to join the object's slots itself, which meant + it skipped ``validate_namespace`` entirely: a name SQL Server could not store + produced a bare string instead of an error. The check now runs first, so the + third operation is a different spelling of the same name rather than a + different policy. + """ + d = self._dialect() + over_long = Table(d, "users", schema_name="s" * 129) + try: + d.format_object_id_name(over_long) + except ValueError as error: + assert "128 characters" in str(error) + else: + raise AssertionError( + "a 129-character schema name reached OBJECT_ID unchecked" + ) def test_granular_schema_ddl_capabilities(self): d = self._dialect() @@ -46,17 +145,19 @@ def _dialect(self) -> SQLServerDialect: return SQLServerDialect((16, 0, 0)) def test_create_schema(self): - sql, params = CreateSchemaExpression(self._dialect(), "app").to_sql() - assert sql == "[app]" or sql == "CREATE SCHEMA [app]" + d = self._dialect() + sql, params = CreateSchemaExpression(d, Schema(d, "app")).to_sql() + assert sql == "CREATE SCHEMA [app]" assert "IF NOT EXISTS" not in sql assert params == () def test_drop_schema(self): - sql, _ = DropSchemaExpression(self._dialect(), "app").to_sql() - assert sql == "DROP SCHEMA [app]" or sql == "DROP SCHEMA app" - assert "IF EXISTS" not in sql + d = self._dialect() + sql, _ = DropSchemaExpression(d, Schema(d, "app")).to_sql() + assert sql == "DROP SCHEMA [app]" def test_drop_schema_rejects_unsupported_if_exists(self): """The formatter must never emit IF EXISTS even if requested.""" - sql, _ = DropSchemaExpression(self._dialect(), "app", if_exists=True).to_sql() - assert "IF EXISTS" not in sql + d = self._dialect() + sql, _ = DropSchemaExpression(d, Schema(d, "app"), if_exists=True).to_sql() + assert "IF EXISTS" not in sql \ No newline at end of file diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_database_ddl.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_database_ddl.py index 6700c0b..35c0353 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_database_ddl.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_database_ddl.py @@ -1,6 +1,7 @@ # tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_database_ddl.py """Explicit SQLServerDialect database DDL capability + rendering tests.""" +from rhosocial.activerecord.backend.expression.objects import Database from rhosocial.activerecord.backend.expression.statements.ddl_database import ( CreateDatabaseExpression, DropDatabaseExpression, @@ -19,11 +20,13 @@ def test_database_capabilities(): def test_create_database_renders(): - sql, params = CreateDatabaseExpression(_dialect(), database_name="app").to_sql() + dialect = _dialect() + sql, params = CreateDatabaseExpression(dialect, Database(dialect, "app")).to_sql() assert "CREATE DATABASE" in sql assert params == () def test_drop_database_renders(): - sql, _ = DropDatabaseExpression(_dialect(), database_name="app").to_sql() + dialect = _dialect() + sql, _ = DropDatabaseExpression(dialect, Database(dialect, "app")).to_sql() assert "DROP DATABASE" in sql diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_ddl_improvements.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_ddl_improvements.py index faef2d1..d1d2dc5 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_ddl_improvements.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_ddl_improvements.py @@ -9,8 +9,8 @@ CreateTableExpression, CreateViewExpression, QueryExpression, - TableExpression, ) +from rhosocial.activerecord.backend.expression.objects import Table, View from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ViewCheckOption, @@ -25,7 +25,7 @@ def test_table_declaration_defaults_are_absent(self): dialect = SQLServerDialect(version=(16, 0, 0)) expression = CreateTableExpression( dialect, - "plain_table_defaults", + Table(dialect, "plain_table_defaults"), [ColumnDefinition(dialect, "id", IntegerType(dialect))], ) sql, params = expression.to_sql() @@ -39,7 +39,7 @@ def test_table_inherits_declaration_is_propagated_and_fails_fast(self): dialect = SQLServerDialect(version=(16, 0, 0)) expression = CreateTableExpression( dialect, - "inheriting_table", + Table(dialect, "inheriting_table"), [ColumnDefinition(dialect, "id", IntegerType(dialect))], inherits=["parent_a", "parent_b"], ) @@ -52,7 +52,7 @@ def test_table_tablespace_declaration_is_propagated_and_fails_fast(self): dialect = SQLServerDialect(version=(16, 0, 0)) expression = CreateTableExpression( dialect, - "tablespaced_table", + Table(dialect, "tablespaced_table"), [ColumnDefinition(dialect, "id", IntegerType(dialect))], tablespace="ts_data", ) @@ -69,11 +69,11 @@ def test_create_view_check_option_gated(self): """WITH CHECK OPTION must fail fast when the capability is off.""" dialect = SQLServerDialect() query = QueryExpression( - dialect, select=[Column(dialect, "id")], from_=TableExpression(dialect, "t") + dialect, select=[Column(dialect, "id")], from_=Table(dialect, "t") ) expr = CreateViewExpression( dialect, - view_name="v", + view=View(dialect, "v"), query=query, options=ViewOptions(check_option=ViewCheckOption.CASCADED), ) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_partition.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_partition.py index 0c6fde3..86d939e 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_partition.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_partition.py @@ -10,6 +10,7 @@ CreateTableExpression, ColumnDefinition, ) +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( PartitionClause, PartitionStrategy, @@ -234,7 +235,7 @@ def test_create_table_with_partition(self): ) stmt = CreateTableExpression( - d, "orders", + d, Table(d, "orders"), columns=[col_def, col_def2, col_def3], partition=partition, ) @@ -249,7 +250,7 @@ def test_create_table_without_partition(self): pk = ColumnConstraint(d, ColumnConstraintType.PRIMARY_KEY) col_def = ColumnDefinition(d, "id", IntegerType(d), constraints=[pk]) stmt = CreateTableExpression( - d, "users", + d, Table(d, "users"), columns=[col_def], ) sql, params = stmt.to_sql() @@ -265,7 +266,7 @@ def test_create_table_with_partition_on_old_version(self): col = Column(d, "id") partition = SQLServerPartitionByRangeClause(d, [col], "ps_test") stmt = CreateTableExpression( - d, "test", + d, Table(d, "test"), columns=[col_def], partition=partition, ) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_protocol_conformance.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_protocol_conformance.py index 3149c94..38ef9a3 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_protocol_conformance.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_protocol_conformance.py @@ -44,7 +44,7 @@ def get_all_protocol_methods(proto: type) -> set: def get_all_generic_protocols() -> dict: - """Discover every generic dialect protocol defined in protocols.py.""" + """Discover every generic dialect protocol defined in the protocols package.""" from typing import Protocol discovered = {} @@ -54,6 +54,21 @@ def get_all_generic_protocols() -> dict: return discovered +def _classified_keys(protocols) -> set: + """Every exported name whose protocol is in *protocols*. + + Keyed by the names the protocols package exports rather than by + ``__name__``, so an alias (``DDLTypeSupport`` is kept as a second name for + ``DataTypeSupport``) counts as classified when its target is listed. + """ + listed = {id(protocol) for protocol in protocols} + return { + name + for name, protocol in get_all_generic_protocols().items() + if id(protocol) in listed + } + + # Generic protocols SQLServerDialect satisfies. SQLSERVER_PROTOCOLS = [ dialect_protocols.AdvancedGroupingSupport, @@ -61,18 +76,16 @@ def get_all_generic_protocols() -> dict: dialect_protocols.ArraySupport, dialect_protocols.AutoIncrementSupport, dialect_protocols.CTESupport, - dialect_protocols.ColumnAttributeSupport, dialect_protocols.CollationSupport, + dialect_protocols.ColumnAttributeSupport, dialect_protocols.ConstraintSupport, dialect_protocols.DataTypeSupport, - dialect_protocols.UserDefinedTypeSupport, + dialect_protocols.DDLTypeSupport, dialect_protocols.ExplainSupport, dialect_protocols.FilterClauseSupport, - dialect_protocols.FunctionSupport, dialect_protocols.GeneratedColumnSupport, dialect_protocols.GraphSupport, dialect_protocols.ILIKESupport, - dialect_protocols.IndexSupport, dialect_protocols.IntrospectionSupport, dialect_protocols.JSONSupport, dialect_protocols.JoinSupport, @@ -84,18 +97,57 @@ def get_all_generic_protocols() -> dict: dialect_protocols.QualifyClauseSupport, dialect_protocols.ReturningSupport, dialect_protocols.SQLFunctionSupport, - dialect_protocols.SchemaSupport, - dialect_protocols.SequenceSupport, dialect_protocols.SetOperationSupport, - dialect_protocols.TableSupport, dialect_protocols.TemporalTableSupport, dialect_protocols.TransactionControlSupport, - dialect_protocols.TriggerSupport, dialect_protocols.TruncateSupport, dialect_protocols.UpsertSupport, - dialect_protocols.ViewSupport, dialect_protocols.WildcardSupport, dialect_protocols.WindowFunctionSupport, + # The object protocols: one per kind. SQL Server is the engine that renders + # all three namespace levels, so it inherits NamespaceSupport and every + # kind's protocol, each naming a format__object the dialect provides. + dialect_protocols.NamespaceSupport, + dialect_protocols.TableObjectSupport, + dialect_protocols.ViewObjectSupport, + dialect_protocols.MaterializedViewObjectSupport, + dialect_protocols.ForeignTableObjectSupport, + dialect_protocols.IndexObjectSupport, + dialect_protocols.SequenceObjectSupport, + dialect_protocols.TriggerObjectSupport, + dialect_protocols.RoutineObjectSupport, + dialect_protocols.TypeObjectSupport, + dialect_protocols.SynonymObjectSupport, + # DDL, one protocol per statement expression. + dialect_protocols.CreateTableSupport, + dialect_protocols.CreateTableAsSupport, + dialect_protocols.CreateTableCloneSupport, + dialect_protocols.CreateTableLikeSupport, + dialect_protocols.CreateTableUsingTemplateSupport, + dialect_protocols.DropTableSupport, + dialect_protocols.AlterTableSupport, + dialect_protocols.CreateViewSupport, + dialect_protocols.DropViewSupport, + dialect_protocols.MaterializedViewSupport, + dialect_protocols.CreateIndexSupport, + dialect_protocols.DropIndexSupport, + dialect_protocols.FulltextIndexSupport, + dialect_protocols.CreateSequenceSupport, + dialect_protocols.DropSequenceSupport, + dialect_protocols.AlterSequenceSupport, + dialect_protocols.CreateTriggerSupport, + dialect_protocols.DropTriggerSupport, + dialect_protocols.CreateRoutineSupport, + dialect_protocols.DropRoutineSupport, + dialect_protocols.CreateTypeSupport, + dialect_protocols.DropTypeSupport, + dialect_protocols.AlterTypeSupport, + dialect_protocols.CreateSchemaSupport, + dialect_protocols.DropSchemaSupport, + dialect_protocols.AlterDatabaseSupport, + dialect_protocols.DateTimeSupport, + dialect_protocols.DqlOrderSupport, + dialect_protocols.PivotSupport, ] @@ -109,9 +161,17 @@ def get_all_generic_protocols() -> dict: # SQL Server has no standalone COMMENT ON statement; comments use # sp_addextendedproperty (not modelled as a COMMENT ON expression). dialect_protocols.CommentSupport, - # The generic DatabaseSupport protocol is not composed by SQLServerDialect. - dialect_protocols.DatabaseSupport, - dialect_protocols.DomainSupport, + # CREATE/DROP DATABASE are rendered by SQLServerDatabaseMixin, but the + # per-statement protocols also require the generic option probes + # (supports_database_encoding, supports_undrop_database, and the rest), + # which SQL Server has no spelling for. Tracked gap rather than an + # omission of intent. + dialect_protocols.CreateDatabaseSupport, + dialect_protocols.DropDatabaseSupport, + # SQL Server has no CREATE/ALTER/DROP DOMAIN. + dialect_protocols.CreateDomainSupport, + dialect_protocols.AlterDomainSupport, + dialect_protocols.DropDomainSupport, # SQL Server's native XML/XQuery (FOR XML, OPENXML, .query()/.value()) is not # the standard SQL/XML feature set, so no SQL/XML formatters are exposed. dialect_protocols.SQLXMLSupport, @@ -159,16 +219,9 @@ def test_does_not_implement_protocol(self, dialect, protocol): def test_positive_and_negative_lists_partition_all_protocols(self): """Every generic protocol must be classified for SQL Server.""" - all_protos = { - protocol.__name__ - for protocol in get_all_generic_protocols().values() - } - positive = { - p.__name__ - for p in SQLSERVER_PROTOCOLS - if p.__module__ == dialect_protocols.__name__ - } - negative = {p.__name__ for p in SQLSERVER_NOT_IMPLEMENTED} + all_protos = set(get_all_generic_protocols()) + positive = _classified_keys(SQLSERVER_PROTOCOLS) + negative = _classified_keys(SQLSERVER_NOT_IMPLEMENTED) overlap = positive & negative assert not overlap, f"Protocols in BOTH lists: {sorted(overlap)}" @@ -177,4 +230,4 @@ def test_positive_and_negative_lists_partition_all_protocols(self): assert not unclassified, ( f"Generic protocols not classified for SQL Server: {sorted(unclassified)}. " f"Add each to SQLSERVER_PROTOCOLS or SQLSERVER_NOT_IMPLEMENTED." - ) + ) \ No newline at end of file diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_type_ddl.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_type_ddl.py index cf583b5..dcb69a1 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_type_ddl.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_type_ddl.py @@ -6,7 +6,10 @@ from rhosocial.activerecord.backend.expression import RawSQLPredicate from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.dialect.mixins import UserDefinedTypeMixin -from rhosocial.activerecord.backend.dialect.protocols import UserDefinedTypeSupport +from rhosocial.activerecord.backend.dialect.protocols import ( + CreateTypeSupport, + DropTypeSupport, +) from rhosocial.activerecord.backend.expression.serialization import ( ExpressionRegistry, deserialize, @@ -26,6 +29,7 @@ TableConstraint, TableConstraintType, ) +from rhosocial.activerecord.backend.expression.objects import Table, Type from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType from rhosocial.activerecord.backend.impl.dummy.expression import _DummyTypeAlterAction from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend @@ -58,14 +62,19 @@ def test_type_protocol_and_mro(): dialect = SQLServerDialect(SQL_SERVER_2022) - assert issubclass(SQLServerUserDefinedTypeSupport, UserDefinedTypeSupport) + assert issubclass(SQLServerUserDefinedTypeSupport, CreateTypeSupport) + assert issubclass(SQLServerUserDefinedTypeSupport, DropTypeSupport) assert isinstance(dialect, SQLServerUserDefinedTypeSupport) - assert isinstance(dialect, UserDefinedTypeSupport) + assert isinstance(dialect, CreateTypeSupport) + assert isinstance(dialect, DropTypeSupport) assert isinstance(dialect, SQLServerTypeDDLMixin) assert isinstance(dialect, UserDefinedTypeMixin) mro = SQLServerDialect.__mro__ assert mro.index(SQLServerTypeDDLMixin) < mro.index(UserDefinedTypeMixin) - assert mro.index(SQLServerUserDefinedTypeSupport) < mro.index(UserDefinedTypeSupport) + # A subclass must precede each of its bases, or its extra methods are + # shadowed rather than added to. + assert mro.index(SQLServerUserDefinedTypeSupport) < mro.index(CreateTypeSupport) + assert mro.index(SQLServerUserDefinedTypeSupport) < mro.index(DropTypeSupport) def test_type_capability_version_boundaries(): @@ -87,7 +96,7 @@ def test_type_capability_version_boundaries(): memory_optimized=True, ) with pytest.raises(UnsupportedFeatureError): - CreateTypeExpression(sql2012, "too_old", memory_definition).to_sql() + CreateTypeExpression(sql2012, Type(sql2012, "too_old"), memory_definition).to_sql() assert sql2012.supports_drop_type_if_exists() is False assert sql2016.supports_drop_type_if_exists() is True assert sql2016.supports_create_type_if_not_exists() is False @@ -99,7 +108,7 @@ def test_type_capability_version_boundaries(): columns=[ColumnDefinition(sql2005, "id", IntegerType(sql2005))], ) with pytest.raises(UnsupportedFeatureError): - CreateTypeExpression(sql2005, "too_old", table_definition).to_sql() + CreateTypeExpression(sql2005, Type(sql2005, "too_old"), table_definition).to_sql() def test_clr_definition_is_platform_gated(): @@ -121,7 +130,7 @@ def test_clr_definition_is_platform_gated(): assert fabric.supports_clr_type_definition() is False assert fabric.supports_type_definition(SQLServerClrTypeDefinition) is False with pytest.raises(UnsupportedFeatureError): - CreateTypeExpression(azure, "clr_type", definition).to_sql() + CreateTypeExpression(azure, Type(azure, "clr_type"), definition).to_sql() def test_backend_passes_deployment_target_from_connection_config(): @@ -166,9 +175,8 @@ def test_alias_type_schema_and_identifier_escaping(): ) expression = CreateTypeExpression( dialect, - "name]x", + Type(dialect, "name]x", schema_name="app]schema"), definition, - schema_name="app]schema", ) assert expression.to_sql() == ( @@ -182,9 +190,8 @@ def test_type_name_dot_is_a_single_identifier(): definition = SQLServerAliasTypeDefinition(dialect, IntegerType(dialect)) expression = CreateTypeExpression( dialect, - "type.with.dot", + Type(dialect, "type.with.dot", schema_name="app"), definition, - schema_name="app", ) assert expression.to_sql() == ( @@ -200,7 +207,9 @@ def test_clr_type_sql(): "sample_assembly", "Sample.Namespace.UserType", ) - expression = CreateTypeExpression(dialect, "clr_type", definition, schema_name="app") + expression = CreateTypeExpression( + dialect, Type(dialect, "clr_type", schema_name="app"), definition + ) assert expression.to_sql() == ( "CREATE TYPE [app].[clr_type] EXTERNAL NAME " @@ -235,7 +244,7 @@ def test_table_type_columns_constraints_indexes_and_memory(): indexes=indexes, memory_optimized=True, ) - expression = CreateTypeExpression(dialect, "inventory_type", definition) + expression = CreateTypeExpression(dialect, Type(dialect, "inventory_type"), definition) sql, params = expression.to_sql() assert sql.startswith("CREATE TYPE [inventory_type] AS TABLE (") @@ -255,7 +264,7 @@ def test_memory_table_type_requires_an_index(): ) with pytest.raises(ValueError, match="at least one index"): - CreateTypeExpression(dialect, "missing_index_type", definition).to_sql() + CreateTypeExpression(dialect, Type(dialect, "missing_index_type"), definition).to_sql() def test_non_memory_table_type_keeps_regular_constraints(): @@ -280,7 +289,7 @@ def test_non_memory_table_type_keeps_regular_constraints(): ], ) - sql, params = CreateTypeExpression(dialect, "regular_type", definition).to_sql() + sql, params = CreateTypeExpression(dialect, Type(dialect, "regular_type"), definition).to_sql() assert "PRIMARY KEY ([id])" in sql assert "UNIQUE ([name])" in sql assert "MEMORY_OPTIMIZED" not in sql @@ -310,7 +319,7 @@ def test_memory_table_type_column_key_constraints_use_nonclustered(): memory_optimized=True, ) - sql, params = CreateTypeExpression(dialect, "column_keys_type", definition).to_sql() + sql, params = CreateTypeExpression(dialect, Type(dialect, "column_keys_type"), definition).to_sql() assert "PRIMARY KEY NONCLUSTERED" in sql assert "UNIQUE NONCLUSTERED" in sql assert params == () @@ -360,7 +369,7 @@ def test_memory_table_type_rejects_unsupported_constraints(): dialect, TableConstraintType.FOREIGN_KEY, columns=["id"], - foreign_key_table="other", + foreign_key_table=Table(dialect, "other"), foreign_key_columns=["id"], ) ], @@ -371,7 +380,7 @@ def test_memory_table_type_rejects_unsupported_constraints(): for definition in definitions: with pytest.raises(UnsupportedFeatureError): - CreateTypeExpression(dialect, "invalid_memory_type", definition).to_sql() + CreateTypeExpression(dialect, Type(dialect, "invalid_memory_type"), definition).to_sql() def test_memory_table_type_default_is_supported_from_2014(): @@ -396,7 +405,7 @@ def test_memory_table_type_default_is_supported_from_2014(): memory_optimized=True, ) - sql, params = CreateTypeExpression(dialect, "default_type", definition).to_sql() + sql, params = CreateTypeExpression(dialect, Type(dialect, "default_type"), definition).to_sql() assert "DEFAULT 0" in sql assert params == () @@ -423,7 +432,7 @@ def test_memory_table_type_check_and_unique_version_gates(): memory_optimized=True, ) with pytest.raises(UnsupportedFeatureError, match="2016\\+"): - CreateTypeExpression(sql2014, "check_2014", check_2014).to_sql() + CreateTypeExpression(sql2014, Type(sql2014, "check_2014"), check_2014).to_sql() unique_2014 = SQLServerTableTypeDefinition( sql2014, @@ -441,7 +450,7 @@ def test_memory_table_type_check_and_unique_version_gates(): memory_optimized=True, ) with pytest.raises(UnsupportedFeatureError, match="2016\\+"): - CreateTypeExpression(sql2014, "unique_2014", unique_2014).to_sql() + CreateTypeExpression(sql2014, Type(sql2014, "unique_2014"), unique_2014).to_sql() sql2016 = SQLServerDialect(SQL_SERVER_2016) allowed = SQLServerTableTypeDefinition( @@ -471,7 +480,7 @@ def test_memory_table_type_check_and_unique_version_gates(): indexes=[SQLServerIndexDefinition(sql2016, "ix_id", ["id"])], memory_optimized=True, ) - sql, params = CreateTypeExpression(sql2016, "keys_2016", allowed).to_sql() + sql, params = CreateTypeExpression(sql2016, Type(sql2016, "keys_2016"), allowed).to_sql() assert "CHECK (1 = 1)" in sql assert "UNIQUE NONCLUSTERED" in sql assert params == () @@ -505,14 +514,14 @@ def test_memory_table_type_index_limit_before_2017(version): definition = _memory_type_with_indexes(dialect, 9) with pytest.raises(UnsupportedFeatureError, match="8 indexes"): - CreateTypeExpression(dialect, "too_many_indexes", definition).to_sql() + CreateTypeExpression(dialect, Type(dialect, "too_many_indexes"), definition).to_sql() def test_memory_table_type_index_limit_removed_in_2017(): dialect = SQLServerDialect(SQL_SERVER_2017) definition = _memory_type_with_indexes(dialect, 9) - sql, params = CreateTypeExpression(dialect, "many_indexes", definition).to_sql() + sql, params = CreateTypeExpression(dialect, Type(dialect, "many_indexes"), definition).to_sql() assert sql.count("INDEX ") == 9 assert params == () @@ -538,7 +547,7 @@ def test_memory_table_type_include_requires_2019(version): ) with pytest.raises(UnsupportedFeatureError, match="2019\\+"): - CreateTypeExpression(dialect, "include_type", definition).to_sql() + CreateTypeExpression(dialect, Type(dialect, "include_type"), definition).to_sql() def test_memory_table_type_include_is_supported_from_2019(): @@ -560,7 +569,7 @@ def test_memory_table_type_include_is_supported_from_2019(): memory_optimized=True, ) - sql, params = CreateTypeExpression(dialect, "include_type", definition).to_sql() + sql, params = CreateTypeExpression(dialect, Type(dialect, "include_type"), definition).to_sql() assert "INCLUDE ([payload])" in sql assert params == () @@ -581,7 +590,7 @@ def test_table_type_hash_index_sql(): ], memory_optimized=True, ) - sql, params = CreateTypeExpression(dialect, "hash_type", definition).to_sql() + sql, params = CreateTypeExpression(dialect, Type(dialect, "hash_type"), definition).to_sql() assert "NONCLUSTERED HASH ([id]) WITH (BUCKET_COUNT = 1024)" in sql assert params == () @@ -590,24 +599,26 @@ def test_create_and_drop_type_fail_fast_options(): dialect = SQLServerDialect(SQL_SERVER_2022) definition = SQLServerAliasTypeDefinition(dialect, IntegerType(dialect)) + status = Type(dialect, "status") + with pytest.raises(UnsupportedFeatureError): - CreateTypeExpression(dialect, "status", definition, if_not_exists=True).to_sql() + CreateTypeExpression(dialect, status, definition, if_not_exists=True).to_sql() with pytest.raises(UnsupportedFeatureError): - CreateTypeExpression(dialect, "status", definition, or_replace=True).to_sql() + CreateTypeExpression(dialect, status, definition, or_replace=True).to_sql() with pytest.raises(ValueError): - SQLServerDropTypeExpression(dialect, "status", cascade=True, restrict=True) + SQLServerDropTypeExpression(dialect, status, cascade=True, restrict=True) with pytest.raises(UnsupportedFeatureError): - SQLServerDropTypeExpression(dialect, "status", cascade=True).to_sql() + SQLServerDropTypeExpression(dialect, status, cascade=True).to_sql() with pytest.raises(UnsupportedFeatureError): - SQLServerDropTypeExpression(dialect, "status", restrict=True).to_sql() + SQLServerDropTypeExpression(dialect, status, restrict=True).to_sql() + app_status = Type(dialect, "status", schema_name="app") pre_2016 = SQLServerDialect(SQL_SERVER_2012) with pytest.raises(UnsupportedFeatureError): - DropTypeExpression(pre_2016, "status", schema_name="app", if_exists=True).to_sql() + DropTypeExpression(pre_2016, app_status, if_exists=True).to_sql() assert DropTypeExpression( dialect, - "status", - schema_name="app", + app_status, if_exists=True, ).to_sql() == ("DROP TYPE IF EXISTS [app].[status]", ()) @@ -615,7 +626,7 @@ def test_create_and_drop_type_fail_fast_options(): def test_alter_type_is_explicitly_unsupported(): dialect = SQLServerDialect(SQL_SERVER_2022) action = _DummyTypeAlterAction(dialect, "renamed") - expression = AlterTypeExpression(dialect, "status", [action]) + expression = AlterTypeExpression(dialect, Type(dialect, "status"), [action]) with pytest.raises(UnsupportedFeatureError, match="ALTER TYPE"): expression.to_sql() @@ -646,7 +657,7 @@ def test_type_expressions_are_explicitly_registered(): dialect, columns=[ColumnDefinition(dialect, "id", IntegerType(dialect))], ), - SQLServerDropTypeExpression(dialect, "type_name"), + SQLServerDropTypeExpression(dialect, Type(dialect, "type_name")), SQLServerRenameTypeExpression(dialect, "old_name", "new_name"), ) for expression in expressions: @@ -672,10 +683,10 @@ def test_table_type_rejects_foreign_key_constraint(): dialect, TableConstraintType.FOREIGN_KEY, columns=["id"], - foreign_key_table="other", + foreign_key_table=Table(dialect, "other"), foreign_key_columns=["id"], ) ], ) with pytest.raises(UnsupportedFeatureError): - CreateTypeExpression(dialect, "invalid_type", definition).to_sql() + CreateTypeExpression(dialect, Type(dialect, "invalid_type"), definition).to_sql() From 4469d80d4fe32b76a0a2f228c43ac1a946354e68 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 6 Oct 2026 20:12:27 +0800 Subject: [PATCH 02/10] fix(sqlserver): declare the sequence capabilities the engine actually has SQL Server 2012 introduced SEQUENCE, but SQLServerSequenceMixin declared only supports_create_sequence and supports_drop_sequence and left supports_sequence() and every option probe to core's defaults, which are False. Once core's 9dc18ab gated the three sequence DDL formatters on those probes, the declaration and the renderer disagreed: the dialect answered "no sequence object" and "no option" for capabilities the engine has spelled since 2012. Add supports_sequence(), supports_alter_sequence(), supports_sequence_if_not_exists, supports_sequence_if_exists, the four new option probes (start, increment, minvalue, maxvalue) and the remaining ones (cycle, cache, order, owned_by), each version-gated to what the server accepts. Verified against the CREATE/ALTER/DROP SEQUENCE (Transact-SQL) reference and against live 2019, 2022 and 2025 containers: START WITH, INCREMENT BY, MINVALUE, MAXVALUE, CYCLE/NO CYCLE and CACHE are accepted; IF NOT EXISTS, ORDER/NOORDER and OWNED BY are syntax errors; DROP SEQUENCE IF EXISTS is a 2016 (13.x) addition. The local format_create_sequence_statement and format_drop_sequence_statement are deleted: SQL Server's spelling is exactly what core's SequenceMixin emits once the probes answer, so one renderer serves every dialect and the probes are the only place that decides. The local create silently dropped an ORDER request, and the local drop gated IF EXISTS on 2012 rather than 2016, so a 2012-2014 server would have received a clause it rejects. The local format_alter_sequence_statement is kept because SQL Server's ALTER grammar genuinely differs: it restarts with RESTART WITH and rejects START WITH (error 11710, confirmed live), and has no ORDER or OWNED BY. It now consults the probes and raises for the words the engine cannot spell instead of dropping them. format_next_value_for now takes the concrete SQLServerNextValueForExpression and reads its sequence_name field, replacing the Any-typed argument, getattr guess and str() fallback that could not name what it accepted. The protocol declaration moves with it; the single call site passes the expression itself. --- .../backend/impl/sqlserver/dialect.py | 140 +++++++++--------- .../backend/impl/sqlserver/mixins/sequence.py | 99 ++++++++++--- .../impl/sqlserver/protocols/sequence.py | 9 +- .../backend/test_dialect_version_branches.py | 20 ++- 4 files changed, 176 insertions(+), 92 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index bd5c1f3..1c975cd 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -220,8 +220,6 @@ DropTableExpression, CreateSchemaExpression, DropSchemaExpression, - CreateSequenceExpression, - DropSequenceExpression, AlterSequenceExpression, MergeExpression, TruncateExpression, @@ -1018,100 +1016,102 @@ def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[ # --- SQL Server-specific format overrides --- - def format_create_sequence_statement(self, expr: "CreateSequenceExpression") -> Tuple[str, tuple]: - """Format CREATE SEQUENCE for SQL Server (2012+). - - SQL Server uses: - - CREATE SEQUENCE [schema.]name - - START WITH, INCREMENT BY, MINVALUE, MAXVALUE - - CYCLE | NO CYCLE, CACHE, NO ORDER (ORDER not supported) - - No IF NOT EXISTS support, no OWNED BY - - Raises: - TypeError: ``expr.sequence`` is not a Sequence. A table handed here - would render as a well-formed ``CREATE SEQUENCE`` over that - table's name. - """ - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - - if not isinstance(expr.sequence, Sequence): - raise TypeError( - f"CreateSequenceExpression.sequence must be a Sequence, " - f"got {type(expr.sequence).__name__}" - ) - if getattr(expr, "if_not_exists", False): - raise UnsupportedFeatureError( - self.name, "CREATE SEQUENCE IF NOT EXISTS", - "SQL Server has no CREATE SEQUENCE IF NOT EXISTS syntax.", - ) - if getattr(expr, "owned_by", None): - raise UnsupportedFeatureError( - self.name, "SEQUENCE OWNED BY", - "SQL Server sequences have no OWNED BY clause.", - ) - parts = ["CREATE SEQUENCE"] - parts.append(expr.sequence.to_sql()[0]) - if expr.start is not None: - parts.append(f"START WITH {expr.start}") - if expr.increment is not None: - parts.append(f"INCREMENT BY {expr.increment}") - if expr.minvalue is not None: - parts.append(f"MINVALUE {expr.minvalue}") - if expr.maxvalue is not None: - parts.append(f"MAXVALUE {expr.maxvalue}") - if expr.cycle: - parts.append("CYCLE") - else: - parts.append("NO CYCLE") - if expr.cache is not None: - parts.append(f"CACHE {expr.cache}") - return " ".join(parts), () - - def format_drop_sequence_statement(self, expr: "DropSequenceExpression") -> Tuple[str, tuple]: - """Format DROP SEQUENCE for SQL Server (2012+). - - Raises: - TypeError: ``expr.sequence`` is not a Sequence. A table handed here - would render as a well-formed ``DROP SEQUENCE`` over that - table's name. - """ - if not isinstance(expr.sequence, Sequence): - raise TypeError( - f"DropSequenceExpression.sequence must be a Sequence, " - f"got {type(expr.sequence).__name__}" - ) - parts = ["DROP SEQUENCE"] - if expr.if_exists and self.version >= SQL_SERVER_2012: - parts.append("IF EXISTS") - parts.append(expr.sequence.to_sql()[0]) - return " ".join(parts), () + # CREATE SEQUENCE and DROP SEQUENCE have no local copy: SQL Server's + # spelling is exactly the one core's SequenceMixin emits, and that mixin now + # consults supports_sequence() and the option probes before every clause. + # Answering the probes in SQLServerSequenceMixin is therefore the whole + # implementation, and one renderer serves every dialect. def format_alter_sequence_statement(self, expr: "AlterSequenceExpression") -> Tuple[str, tuple]: """Format ALTER SEQUENCE for SQL Server. + SQL Server's ALTER SEQUENCE grammar differs from the standard form core + renders: it changes the start point with ``RESTART WITH`` and has no + ``START WITH`` clause -- the engine rejects that argument with error + 11710 -- and it has no ``ORDER``/``OWNED BY`` clause either. The + formatter is kept here rather than deferred to ``SequenceMixin`` so + those words are never emitted, while the option probes still decide + which of the shared options this version accepts. + Raises: TypeError: ``expr.sequence`` is not a Sequence. A table handed here would render as a well-formed ``ALTER SEQUENCE`` over that table's name. + UnsupportedFeatureError: If the dialect has no sequence object, or + the expression asks for an option SQL Server's ALTER SEQUENCE + cannot spell. """ if not isinstance(expr.sequence, Sequence): raise TypeError( f"AlterSequenceExpression.sequence must be a Sequence, " f"got {type(expr.sequence).__name__}" ) + if not self.supports_sequence(): + raise UnsupportedFeatureError( + self.name, "ALTER SEQUENCE", + f"{self.name} has no sequence object to alter." + ) parts = [f"ALTER SEQUENCE {expr.sequence.to_sql()[0]}"] if expr.restart is not None: parts.append(f"RESTART WITH {expr.restart}") + if expr.start is not None: + # ALTER SEQUENCE changes the start point with RESTART WITH, not + # START WITH; ``supports_sequence_start`` answers for CREATE, where + # the words are legal, so this is a statement-grammar fact. + raise UnsupportedFeatureError( + self.name, "ALTER SEQUENCE START", + "SQL Server's ALTER SEQUENCE has no START WITH clause; use " + "RESTART WITH to change the start point.", + ) if expr.increment is not None: + if not self.supports_sequence_increment(): + raise UnsupportedFeatureError( + self.name, "ALTER SEQUENCE INCREMENT", + f"{self.name} does not support the INCREMENT BY sequence option." + ) parts.append(f"INCREMENT BY {expr.increment}") if expr.minvalue is not None: + if not self.supports_sequence_minvalue(): + raise UnsupportedFeatureError( + self.name, "ALTER SEQUENCE MINVALUE", + f"{self.name} does not support the MINVALUE sequence option." + ) parts.append(f"MINVALUE {expr.minvalue}") if expr.maxvalue is not None: + if not self.supports_sequence_maxvalue(): + raise UnsupportedFeatureError( + self.name, "ALTER SEQUENCE MAXVALUE", + f"{self.name} does not support the MAXVALUE sequence option." + ) parts.append(f"MAXVALUE {expr.maxvalue}") if expr.cycle is not None: - parts.append("CYCLE" if expr.cycle else "NO CYCLE") + if expr.cycle: + if not self.supports_sequence_cycle(): + raise UnsupportedFeatureError( + self.name, "ALTER SEQUENCE CYCLE", + f"{self.name} does not support the CYCLE sequence option." + ) + parts.append("CYCLE") + elif self.supports_sequence_cycle(): + # NO CYCLE is the default; only spell it where it is legal. + parts.append("NO CYCLE") if expr.cache is not None: + if not self.supports_sequence_cache(): + raise UnsupportedFeatureError( + self.name, "ALTER SEQUENCE CACHE", + f"{self.name} does not support the CACHE sequence option." + ) parts.append(f"CACHE {expr.cache}") + if expr.order is not None: + raise UnsupportedFeatureError( + self.name, "ALTER SEQUENCE ORDER", + f"{self.name} does not support the ORDER sequence option." + ) + if expr.owned_by is not None: + raise UnsupportedFeatureError( + self.name, "ALTER SEQUENCE OWNED BY", + f"{self.name} does not support the OWNED BY sequence option." + ) return " ".join(parts), () def format_truncate_statement(self, expr: "TruncateExpression") -> Tuple[str, tuple]: diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/sequence.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/sequence.py index 199e800..c64edc6 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/sequence.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/sequence.py @@ -1,18 +1,21 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/mixins/sequence.py """SQL Server SEQUENCE capability mixin. -SQL Server 2012 introduced SEQUENCE objects. This mixin provides capability -detection and the ``NEXT VALUE FOR`` expression formatter used by +SQL Server 2012 introduced SEQUENCE objects. This mixin provides the capability +declarations that decide which sequence DDL a given version accepts, and the +``NEXT VALUE FOR`` expression formatter used by ``SQLServerNextValueForExpression``. """ -from typing import Any, Tuple +from typing import TYPE_CHECKING, Tuple from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.objects import Sequence +from .version_constants import SQL_SERVER_2012, SQL_SERVER_2016 -_SQL_SERVER_NEXT_VALUE_FOR_VERSION = (11, 0, 0) +if TYPE_CHECKING: # pragma: no cover + from ..expression.sequence import SQLServerNextValueForExpression class SQLServerSequenceMixin: @@ -22,43 +25,103 @@ def supports_sequence_as_data_type(self) -> bool: """SQL Server sequences are not usable as column data types.""" return False - def format_next_value_for(self, sequence: Any) -> Tuple[str, tuple]: + def format_next_value_for( + self, expr: "SQLServerNextValueForExpression" + ) -> Tuple[str, tuple]: """Format NEXT VALUE FOR expression (SQL Server 2012+). Args: - sequence: A ``SQLServerNextValueForExpression`` or a plain - sequence name string. + expr: The ``SQLServerNextValueForExpression`` to render. Returns: Tuple of (SQL string, params tuple) such as ``(NEXT VALUE FOR [my_seq], ())``. """ - if self.version < _SQL_SERVER_NEXT_VALUE_FOR_VERSION: # type: ignore[attr-defined] + if self.version < SQL_SERVER_2012: # type: ignore[attr-defined] raise UnsupportedFeatureError( self.name, # type: ignore[attr-defined] "NEXT VALUE FOR", "requires SQL Server 2012+", ) - sequence_name = getattr(sequence, "sequence_name", None) - if sequence_name is None: - sequence_name = str(sequence) - - # The sequence expressions hold one flat name string, and + # The expression holds one flat name string, and # ``NEXT VALUE FOR dbo.my_seq`` is the documented spelling for a # schema-qualified sequence. The namespace is therefore read out of the # text once, into a Sequence object, and rendered from there -- the # formatter itself never re-derives which part is the schema. - quoted = self.qualified_object_name_from_dotted(Sequence, sequence_name) + quoted = self.qualified_object_name_from_dotted(Sequence, expr.sequence_name) return f"NEXT VALUE FOR {quoted}", () - # --- Sequence capability declarations (moved from dialect.py) --- + # --- Sequence capability declarations --- + # + # SQL Server 2012 introduced SEQUENCE. The master switch and every option + # the engine spells are version-gated on that introduction; the options it + # has no spelling for answer False at every version. Determined from the + # CREATE/ALTER/DROP SEQUENCE (Transact-SQL) reference syntax: + # + # CREATE: START WITH, INCREMENT BY, MINVALUE, MAXVALUE, CYCLE, CACHE + # -- no IF NOT EXISTS, no ORDER/NOORDER, no OWNED BY + # ALTER: RESTART WITH, INCREMENT BY, MINVALUE, MAXVALUE, CYCLE, CACHE + # -- no START WITH, no ORDER/NOORDER, no OWNED BY + # DROP: IF EXISTS applies from SQL Server 2016 (13.x) onwards + # + # The CACHE minimum of 2 and the cycling-sequence cache bound are value + # constraints, not support questions; a probe answers whether the option + # exists, so they are deliberately not answered here. + + def supports_sequence(self) -> bool: + """SQL Server 2012+ has SEQUENCE objects.""" + return self.version >= SQL_SERVER_2012 # type: ignore[attr-defined] def supports_create_sequence(self) -> bool: - """SQL Server 2012+ supports SEQUENCE objects.""" - return self.version >= _SQL_SERVER_NEXT_VALUE_FOR_VERSION # type: ignore[attr-defined] + """SQL Server 2012+ supports CREATE SEQUENCE.""" + return self.version >= SQL_SERVER_2012 # type: ignore[attr-defined] def supports_drop_sequence(self) -> bool: """SQL Server 2012+ supports DROP SEQUENCE.""" - return self.version >= _SQL_SERVER_NEXT_VALUE_FOR_VERSION # type: ignore[attr-defined] + return self.version >= SQL_SERVER_2012 # type: ignore[attr-defined] + + def supports_alter_sequence(self) -> bool: + """SQL Server 2012+ supports ALTER SEQUENCE.""" + return self.version >= SQL_SERVER_2012 # type: ignore[attr-defined] + + def supports_sequence_if_not_exists(self) -> bool: + """SQL Server has no CREATE SEQUENCE IF NOT EXISTS.""" + return False + + def supports_sequence_if_exists(self) -> bool: + """DROP SEQUENCE IF EXISTS arrived in SQL Server 2016 (13.x).""" + return self.version >= SQL_SERVER_2016 # type: ignore[attr-defined] + + def supports_sequence_start(self) -> bool: + """CREATE SEQUENCE accepts START WITH from SQL Server 2012.""" + return self.version >= SQL_SERVER_2012 # type: ignore[attr-defined] + + def supports_sequence_increment(self) -> bool: + """CREATE/ALTER SEQUENCE accept INCREMENT BY from SQL Server 2012.""" + return self.version >= SQL_SERVER_2012 # type: ignore[attr-defined] + + def supports_sequence_minvalue(self) -> bool: + """CREATE/ALTER SEQUENCE accept MINVALUE from SQL Server 2012.""" + return self.version >= SQL_SERVER_2012 # type: ignore[attr-defined] + + def supports_sequence_maxvalue(self) -> bool: + """CREATE/ALTER SEQUENCE accept MAXVALUE from SQL Server 2012.""" + return self.version >= SQL_SERVER_2012 # type: ignore[attr-defined] + + def supports_sequence_cycle(self) -> bool: + """CREATE/ALTER SEQUENCE accept CYCLE / NO CYCLE from SQL Server 2012.""" + return self.version >= SQL_SERVER_2012 # type: ignore[attr-defined] + + def supports_sequence_cache(self) -> bool: + """CREATE/ALTER SEQUENCE accept CACHE from SQL Server 2012.""" + return self.version >= SQL_SERVER_2012 # type: ignore[attr-defined] + + def supports_sequence_order(self) -> bool: + """SQL Server has no ORDER / NOORDER sequence option.""" + return False + + def supports_sequence_owned_by(self) -> bool: + """SQL Server has no OWNED BY sequence clause.""" + return False diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/sequence.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/sequence.py index d1ff930..c775b5e 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/sequence.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/sequence.py @@ -1,8 +1,13 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/protocols/sequence.py -from typing import Any, Protocol, Tuple, runtime_checkable +from typing import TYPE_CHECKING, Protocol, Tuple, runtime_checkable + +if TYPE_CHECKING: # pragma: no cover + from ..expression.sequence import SQLServerNextValueForExpression @runtime_checkable class SQLServerSequenceSupport(Protocol): def supports_sequence_as_data_type(self) -> bool: ... - def format_next_value_for(self, sequence: Any) -> Tuple[str, tuple]: ... \ No newline at end of file + def format_next_value_for( + self, expr: "SQLServerNextValueForExpression" + ) -> Tuple[str, tuple]: ... diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect_version_branches.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect_version_branches.py index 3aeb750..b5755aa 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect_version_branches.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect_version_branches.py @@ -453,11 +453,27 @@ def test_sequence_ddl_snapshots(self): "MINVALUE 1 MAXVALUE 100 NO CYCLE CACHE 10" ) assert create_params == () - # IF EXISTS only appears from 2012 onwards. + # The sequence object itself arrives in 2012, so a version without one + # refuses the statement instead of rendering it. DROP SEQUENCE IF EXISTS + # is a 2016 (13.x) addition, and a requested IF EXISTS the version cannot + # spell raises rather than being dropped. d10 = SQLServerDialect((10, 0, 0)) + with pytest.raises(UnsupportedFeatureError): + DropSequenceExpression( + d10, Sequence(d10, "seq"), if_exists=True + ).to_sql() + d11 = SQLServerDialect(SQL_SERVER_2012) assert DropSequenceExpression( - d10, Sequence(d10, "seq"), if_exists=True + d11, Sequence(d11, "seq") ).to_sql()[0] == "DROP SEQUENCE [seq]" + with pytest.raises(UnsupportedFeatureError): + DropSequenceExpression( + d11, Sequence(d11, "seq"), if_exists=True + ).to_sql() + d13 = SQLServerDialect(SQL_SERVER_2016) + assert DropSequenceExpression( + d13, Sequence(d13, "seq"), if_exists=True + ).to_sql()[0] == "DROP SEQUENCE IF EXISTS [seq]" assert DropSequenceExpression( d16, Sequence(d16, "seq"), if_exists=True ).to_sql()[0] == "DROP SEQUENCE IF EXISTS [seq]" From 7602a38ee6bb2068ea1ef28dadc2001595f619f4 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 6 Oct 2026 22:46:13 +0800 Subject: [PATCH 03/10] fix(sqlserver): refuse ALTER SEQUENCE START through its own probe SQL Server's CREATE SEQUENCE accepts START WITH, but ALTER SEQUENCE does not: it changes the start point with RESTART WITH, and the engine rejects START WITH there outright with argument error 11710. The probe supports_sequence_start describes the CREATE clause, so it cannot stand in for the ALTER one. Declare supports_alter_sequence_start on SQLServerSequenceMixin and answer it False, then gate the start field of format_alter_sequence_statement on that probe instead of refusing the clause as a bare statement-grammar fact. The refusal is unchanged in effect, but it now names the option and fails closed from a declared capability rather than an inline raise. A probe answering True by default would let a dialect emit START WITH on ALTER SEQUENCE and hand the server SQL it rejects, so the explicit False is what keeps the two clauses honest. Live check against SQL Server 2025: ALTER SEQUENCE ... START WITH 5 is rejected with 11710, while RESTART WITH 5 and INCREMENT BY 5 are accepted. --- .../backend/impl/sqlserver/dialect.py | 23 ++++++---- .../backend/impl/sqlserver/mixins/sequence.py | 17 ++++++++ .../backend/test_dialect_comprehensive.py | 43 +++++++++++++++++++ .../backend/test_dialect_version_branches.py | 12 ++++++ 4 files changed, 87 insertions(+), 8 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index 1c975cd..6682974 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -1031,7 +1031,11 @@ def format_alter_sequence_statement(self, expr: "AlterSequenceExpression") -> Tu 11710 -- and it has no ``ORDER``/``OWNED BY`` clause either. The formatter is kept here rather than deferred to ``SequenceMixin`` so those words are never emitted, while the option probes still decide - which of the shared options this version accepts. + which of the shared options this version accepts. A requested ``start`` + is gated on :meth:`supports_alter_sequence_start`, the ALTER-side + question, rather than on the CREATE-side + :meth:`supports_sequence_start`; the two clauses are different and only + the former decides this statement. Raises: TypeError: ``expr.sequence`` is not a Sequence. A table handed here @@ -1056,13 +1060,16 @@ def format_alter_sequence_statement(self, expr: "AlterSequenceExpression") -> Tu parts.append(f"RESTART WITH {expr.restart}") if expr.start is not None: # ALTER SEQUENCE changes the start point with RESTART WITH, not - # START WITH; ``supports_sequence_start`` answers for CREATE, where - # the words are legal, so this is a statement-grammar fact. - raise UnsupportedFeatureError( - self.name, "ALTER SEQUENCE START", - "SQL Server's ALTER SEQUENCE has no START WITH clause; use " - "RESTART WITH to change the start point.", - ) + # START WITH. ``supports_alter_sequence_start`` is the ALTER-side + # question and is kept apart from ``supports_sequence_start``, + # which answers for CREATE, where the words are legal. + if not self.supports_alter_sequence_start(): + raise UnsupportedFeatureError( + self.name, "ALTER SEQUENCE START", + "SQL Server's ALTER SEQUENCE has no START WITH clause; use " + "RESTART WITH to change the start point.", + ) + parts.append(f"START WITH {expr.start}") if expr.increment is not None: if not self.supports_sequence_increment(): raise UnsupportedFeatureError( diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/sequence.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/sequence.py index c64edc6..a8fb908 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/sequence.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/sequence.py @@ -98,6 +98,23 @@ def supports_sequence_start(self) -> bool: """CREATE SEQUENCE accepts START WITH from SQL Server 2012.""" return self.version >= SQL_SERVER_2012 # type: ignore[attr-defined] + def supports_alter_sequence_start(self) -> bool: + """Whether ALTER SEQUENCE accepts the START WITH option. + + SQL Server does not. ALTER SEQUENCE changes the start point with + RESTART WITH, and the engine rejects START WITH there outright with + argument error 11710. The CREATE-side spelling is legal, so + :meth:`supports_sequence_start` answers a different question and cannot + stand in for this one. + + Stated explicitly rather than left to the shared default, because the + direction of that default is what keeps the dialect honest: a probe that + answered ``True`` by default would let a formatter emit START WITH on + ALTER SEQUENCE and hand the server SQL it rejects. Answering ``False`` + fails closed. + """ + return False + def supports_sequence_increment(self) -> bool: """CREATE/ALTER SEQUENCE accept INCREMENT BY from SQL Server 2012.""" return self.version >= SQL_SERVER_2012 # type: ignore[attr-defined] diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect_comprehensive.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect_comprehensive.py index e73e82a..3e65db6 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect_comprehensive.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect_comprehensive.py @@ -231,6 +231,49 @@ def test_drop_sequence_if_exists(self, dialect): sql, params = dialect.format_drop_sequence_statement(expr) assert "DROP SEQUENCE IF EXISTS [test_seq]" in sql + def test_alter_sequence_start_probe_is_false(self, dialect): + """The ALTER-side START question is separate from CREATE's and is False.""" + assert dialect.supports_sequence_start() is True + assert dialect.supports_alter_sequence_start() is False + + def test_alter_sequence_start_is_refused(self, dialect): + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + from rhosocial.activerecord.backend.expression.objects import Sequence + from rhosocial.activerecord.backend.expression.statements import AlterSequenceExpression + + expr = AlterSequenceExpression( + dialect=dialect, + sequence=Sequence(dialect, "test_seq"), + start=5, + ) + with pytest.raises(UnsupportedFeatureError) as exc_info: + expr.to_sql() + assert "ALTER SEQUENCE START" in str(exc_info.value) + + def test_alter_sequence_restart_renders(self, dialect): + from rhosocial.activerecord.backend.expression.objects import Sequence + from rhosocial.activerecord.backend.expression.statements import AlterSequenceExpression + + sql, params = AlterSequenceExpression( + dialect=dialect, + sequence=Sequence(dialect, "test_seq"), + restart=5, + ).to_sql() + assert sql == "ALTER SEQUENCE [test_seq] RESTART WITH 5" + assert params == () + + def test_alter_sequence_increment_renders(self, dialect): + from rhosocial.activerecord.backend.expression.objects import Sequence + from rhosocial.activerecord.backend.expression.statements import AlterSequenceExpression + + sql, params = AlterSequenceExpression( + dialect=dialect, + sequence=Sequence(dialect, "test_seq"), + increment=5, + ).to_sql() + assert sql == "ALTER SEQUENCE [test_seq] INCREMENT BY 5" + assert params == () + class TestSQLServerDialectSchema: """SCHEMA DDL tests.""" diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect_version_branches.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect_version_branches.py index b5755aa..8f89a5b 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect_version_branches.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect_version_branches.py @@ -207,6 +207,18 @@ def test_drop_table_if_exists_capability(self, version, if_exists_bit): def test_sequence_objects_2012_gate(self, version, sequence): assert SQLServerDialect(version).supports_create_sequence() is sequence + def test_alter_sequence_start_is_refused_at_every_version(self): + """ALTER SEQUENCE never accepts START WITH, whatever the version. + + The CREATE-side probe is version-gated on the 2012 introduction; the + ALTER-side probe is not, because no version of the engine spells the + clause. Keeping the two apart is what this asserts. + """ + for version in VERSIONS.values(): + d = SQLServerDialect(version) + assert d.supports_sequence_start() is (version >= SQL_SERVER_2012) + assert d.supports_alter_sequence_start() is False + class TestRenderingSnapshots: def test_temporary_table_gets_hash_prefix(self): From 9cd15d01fa4ee11ce8f4057a338b979858754bab Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 7 Oct 2026 03:17:14 +0800 Subject: [PATCH 04/10] fix(sqlserver): declare the identity-column capabilities the engine actually has The dialect's identity spelling is IDENTITY(seed, increment): it overrides the standard GENERATED ... AS IDENTITY formatter, and SQL Server 2019 / 2022 / 2025 all refuse the standard grammar (measured). Declare the probes core now consults, and gate the formatter on them: - supports_identity_column() = True -- IDENTITY(seed, increment) is this dialect's identity expression, measured to execute; - supports_identity_generation_always() = False -- the identity property has no generation mode. ALWAYS was previously rendered byte-identical to BY DEFAULT ( IDENTITY(1, 1)); it is now refused by name; - supports_identity_start() / _increment() = True -- the property's seed and increment, both measured to take effect; - supports_identity_minvalue() / _maxvalue() / _cycle() = False -- every candidate spelling was measured refused on all three servers; - supports_auto_increment_column() = False -- IDENTITY(seed, inc) is not AUTO_INCREMENT. The formatter refuses a requested option by name rather than dropping it. Execution confirmation covers the rendered DDL on all three servers, with sentinels that must come back REJECTED. --- .../backend/impl/sqlserver/dialect.py | 10 +- .../backend/impl/sqlserver/mixins/__init__.py | 2 + .../impl/sqlserver/mixins/identity_column.py | 180 ++++++++++++++ .../backend/test_identity_clause_sqlserver.py | 217 ++++++++++++++++ .../test_identity_execution_confirmation.py | 235 ++++++++++++++++++ .../test_sqlserver_protocol_conformance.py | 3 +- 6 files changed, 640 insertions(+), 7 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins/identity_column.py create mode 100644 tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_identity_clause_sqlserver.py create mode 100644 tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_identity_execution_confirmation.py diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index 6682974..f4aa7cc 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -132,6 +132,7 @@ TransactionControlMixin, AutoIncrementMixin, + IdentityColumnMixin, GeneratedColumnMixin, TriggerMixin, PartitionMixin, @@ -145,6 +146,7 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from .collation import validate_sqlserver_collation_name from .alter_table_modifier import SQLServerAlterColumnModifierMixin +from .mixins.identity_column import SQLServerIdentityColumnMixin from .mixins.sequence import SQLServerSequenceMixin from .mixins.pivot import SQLServerPivotMixin from .mixins.graph import SQLServerGraphMixin @@ -289,6 +291,7 @@ class SQLServerDialect( SQLServerAlterColumnModifierMixin, # Before DDLColumnMixin to override format_*_action SQLServerProtocolSupportMixin, # SQL Server protocol contract implementations SQLServerSequenceMixin, # NEXT VALUE FOR formatter (2012+) + SQLServerIdentityColumnMixin, # IDENTITY(seed, increment) formatter + probes SQLServerPivotMixin, # PIVOT / UNPIVOT formatters (2005+) SQLServerGraphMixin, # SQL Graph: node/edge tables + MATCH predicate (2017+/2019+) SQLServerColumnstoreIndexMixin, # columnstore index DDL (2012+/2014+/2022+) @@ -356,6 +359,7 @@ class SQLServerDialect( TransactionControlMixin, AutoIncrementMixin, + IdentityColumnMixin, GeneratedColumnMixin, TriggerMixin, PartitionMixin, @@ -729,12 +733,6 @@ def format_create_table_statement( return if_not_exists_guard + ' '.join(parts), tuple(all_params) - def format_identity_clause(self, expr) -> Tuple[str, tuple]: - """SQL Server renders identity as ``IDENTITY(seed, increment)``.""" - seed = expr.start if expr.start is not None else 1 - increment = expr.increment if expr.increment is not None else 1 - return f" IDENTITY({seed}, {increment})", () - def format_column_definition( self, col_def: "ColumnDefinition", diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py index 6d3e248..5656cbd 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py @@ -4,6 +4,7 @@ from .backend_mixin import SQLServerBackendMixin from .partition import SQLServerPartitionMixin from .sequence import SQLServerSequenceMixin +from .identity_column import SQLServerIdentityColumnMixin from .pivot import SQLServerPivotMixin from .graph import SQLServerGraphMixin from .columnstore import SQLServerColumnstoreIndexMixin @@ -46,6 +47,7 @@ "SQLServerBackendMixin", "SQLServerPartitionMixin", "SQLServerSequenceMixin", + "SQLServerIdentityColumnMixin", "SQLServerPivotMixin", "SQLServerGraphMixin", "SQLServerColumnstoreIndexMixin", diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/identity_column.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/identity_column.py new file mode 100644 index 0000000..fd955c0 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/identity_column.py @@ -0,0 +1,180 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/identity_column.py +"""SQL Server identity-column capability declarations and formatter. + +SQL Server spells an identity column as a *property* of the column: +``IDENTITY(seed, increment)``. That is not the SQL-standard clause +``GENERATED {ALWAYS|BY DEFAULT} AS IDENTITY``, and the two carry different +parameter spaces. The dialect therefore declares exactly which parts of the +standard clause its own spelling can express: + +* the mechanism itself (:meth:`supports_identity_column`) **is** this + dialect's ``IDENTITY(seed, increment)`` -- measured to execute on SQL + Server 2019 / 2022 / 2025; +* ``seed`` / ``increment`` are the two parameters the property takes; +* the generation mode ``ALWAYS`` and the sequence attributes + MINVALUE / MAXVALUE / CYCLE have no spelling in the property at all. + Every candidate spelling was measured refused by all three servers, so + those probes answer ``False`` and the formatter refuses a request for them + by name instead of silently dropping it. + +``AUTO_INCREMENT`` is a different mechanism again -- a *parameterless* column +marker carried by ``AutoIncrementClause`` -- and SQL Server has no such +marker, so :meth:`supports_auto_increment_column` answers ``False``. +""" + +from typing import TYPE_CHECKING, Tuple + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.expression.statements import IdentityClause + + +class SQLServerIdentityColumnMixin: + """SQL Server's ``IDENTITY(seed, increment)`` identity column. + + The probes are the dialect's declaration of what its identity spelling + can express; the formatter consults every one of them and fails closed. + """ + + def supports_identity_column(self) -> bool: + """SQL Server accepts ``IDENTITY(seed, increment)`` at every version. + + The override in :meth:`format_identity_clause` renders SQL Server's + own spelling, and that spelling is this dialect's identity + expression -- not a fallback for the standard grammar, which every + measured version refuses (2019 / 2022 / 2025). + """ + return True + + def supports_identity_generation_always(self) -> bool: + """SQL Server's identity property has no ``ALWAYS`` generation mode. + + ``GENERATED ALWAYS AS IDENTITY`` is refused by the server on + 2019 / 2022 / 2025 (measured), and appending ``ALWAYS`` to the + property is refused too. Answering ``False`` makes the formatter + refuse an ``ALWAYS`` request by name; the request is never + downgraded to a bare ``IDENTITY(seed, increment)``. + """ + return False + + def supports_identity_start(self) -> bool: + """The identity property's first argument is the seed (start value).""" + return True + + def supports_identity_increment(self) -> bool: + """The identity property's second argument is the increment.""" + return True + + def supports_identity_minvalue(self) -> bool: + """SQL Server's identity property has no MINVALUE option. + + Measured on 2019 / 2022 / 2025: every candidate spelling (standard + ``MINVALUE``, ``NO MINVALUE``, ``WITH MINVALUE``) is refused. + """ + return False + + def supports_identity_maxvalue(self) -> bool: + """SQL Server's identity property has no MAXVALUE option. + + Measured on 2019 / 2022 / 2025: every candidate spelling (standard + ``MAXVALUE``, ``NO MAXVALUE``, ``WITH MAXVALUE``) is refused. + """ + return False + + def supports_identity_cycle(self) -> bool: + """SQL Server's identity property has no CYCLE option. + + Measured on 2019 / 2022 / 2025: every candidate spelling (``CYCLE``, + ``NO CYCLE``, ``WITH CYCLE``) is refused. + """ + return False + + def supports_auto_increment_column(self) -> bool: + """SQL Server has no bare ``AUTO_INCREMENT`` column marker. + + Its generated-value spelling is the parameterised identity property, + a different mechanism from the parameterless marker. Answering + ``False`` keeps ``AutoIncrementClause`` fail-closed: it refuses + rather than rendering a token the server rejects. + """ + return False + + def format_identity_clause(self, expr: "IdentityClause") -> Tuple[str, tuple]: + """Render SQL Server's ``IDENTITY(seed, increment)``. + + Every gate fails closed and refuses by name rather than dropping the + clause or an option: + + * :meth:`supports_identity_column` gates the clause as a whole; + * :meth:`supports_identity_generation_always` gates ``ALWAYS`` (this + dialect's property has no generation mode, so an ``ALWAYS`` request + is refused, never silently rendered as the bare property); + * :meth:`supports_identity_start` / ``_increment`` gate the seed and + increment, the two parameters the property actually takes; + * :meth:`supports_identity_minvalue` / ``_maxvalue`` / ``_cycle`` + gate the sequence attributes the property cannot spell. + + Args: + expr: The ``IdentityClause`` carrying the identity parameters. + + Returns: + A ``(sql, params)`` tuple with a leading space. + + Raises: + UnsupportedFeatureError: If the dialect cannot express the + clause, the requested generation mode, or one of the + requested options. + """ + if not self.supports_identity_column(): + raise UnsupportedFeatureError( + self.name, # type: ignore[attr-defined] + "IDENTITY column", + f"{self.name} does not support IDENTITY columns.", # type: ignore[attr-defined] + ) + generation = (expr.generation or "BY DEFAULT").upper() + if generation == "ALWAYS" and not self.supports_identity_generation_always(): + raise UnsupportedFeatureError( + self.name, # type: ignore[attr-defined] + "IDENTITY GENERATED ALWAYS", + f"{self.name} cannot express GENERATED ALWAYS for an identity " # type: ignore[attr-defined] + f"column; its IDENTITY property has no generation mode.", + ) + if expr.start is not None: + if not self.supports_identity_start(): + raise UnsupportedFeatureError( + self.name, # type: ignore[attr-defined] + "IDENTITY START", + f"{self.name} does not support the START WITH identity option.", # type: ignore[attr-defined] + ) + if expr.increment is not None: + if not self.supports_identity_increment(): + raise UnsupportedFeatureError( + self.name, # type: ignore[attr-defined] + "IDENTITY INCREMENT", + f"{self.name} does not support the INCREMENT BY identity option.", # type: ignore[attr-defined] + ) + if expr.minvalue is not None: + if not self.supports_identity_minvalue(): + raise UnsupportedFeatureError( + self.name, # type: ignore[attr-defined] + "IDENTITY MINVALUE", + f"{self.name} does not support the MINVALUE identity option.", # type: ignore[attr-defined] + ) + if expr.maxvalue is not None: + if not self.supports_identity_maxvalue(): + raise UnsupportedFeatureError( + self.name, # type: ignore[attr-defined] + "IDENTITY MAXVALUE", + f"{self.name} does not support the MAXVALUE identity option.", # type: ignore[attr-defined] + ) + if expr.cycle is not None: + if not self.supports_identity_cycle(): + raise UnsupportedFeatureError( + self.name, # type: ignore[attr-defined] + "IDENTITY CYCLE", + f"{self.name} does not support the CYCLE identity option.", # type: ignore[attr-defined] + ) + seed = expr.start if expr.start is not None else 1 + increment = expr.increment if expr.increment is not None else 1 + return f" IDENTITY({seed}, {increment})", () diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_identity_clause_sqlserver.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_identity_clause_sqlserver.py new file mode 100644 index 0000000..d9b9b46 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_identity_clause_sqlserver.py @@ -0,0 +1,217 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_identity_clause_sqlserver.py +"""SQL Server's identity column: ``IDENTITY(seed, increment)`` and its gate. + +Measured on live SQL Server 2019 / 2022 / 2025 (the execution confirmation +lives in ``test_identity_execution_confirmation.py``): + +* ``IDENTITY(seed, increment)`` executes at every version. It is this + dialect's identity expression, so ``supports_identity_column()`` is + ``True`` -- it is not a fallback for the standard grammar, which every + measured version refuses. +* The identity property has no generation mode: ``GENERATED ALWAYS AS + IDENTITY`` is refused by the server, and so is appending ``ALWAYS`` to the + property. ``supports_identity_generation_always()`` is therefore ``False`` + and the formatter refuses an ``ALWAYS`` request by name instead of + rendering the bare property (which is byte-identical to ``BY DEFAULT``). +* ``seed`` / ``increment`` are the property's two arguments, so their probes + are ``True``. +* MINVALUE / MAXVALUE / CYCLE have no spelling in the property at all; every + candidate spelling was measured refused on all three versions, so their + probes are ``False`` and the formatter refuses a request for them. +* ``AUTO_INCREMENT`` is a different mechanism (parameterless marker), which + SQL Server has no spelling for. +""" + +import pytest + +from rhosocial.activerecord.base import IdentityAttribute +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression import ColumnDefinition +from rhosocial.activerecord.backend.expression.statements import ( + AutoIncrementClause, + IdentityClause, +) +from rhosocial.activerecord.backend.expression.types import IntegerType +from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect + + +@pytest.fixture +def dialect(): + return SQLServerDialect((16, 0, 0)) + + +def _withdrawn(probe_name: str) -> SQLServerDialect: + """A dialect that answers one identity probe ``False``. + + The shape being modelled: a version of the engine whose identity property + lost one parameter. The gate must refuse exactly that parameter and keep + rendering the rest, or the per-option probes would be decorative. + """ + subclass = type( + f"SQLServerWithout_{probe_name}", + (SQLServerDialect,), + {probe_name: lambda self: False}, + ) + return subclass((16, 0, 0)) + + +class TestIdentityProbeDeclarations: + """The declared capability table, one probe at a time.""" + + def test_mechanism_and_two_property_arguments_are_declared(self, dialect): + assert dialect.supports_identity_column() is True + assert dialect.supports_identity_start() is True + assert dialect.supports_identity_increment() is True + + def test_always_is_declined(self, dialect): + """The property has no generation mode; ALWAYS cannot be expressed.""" + assert dialect.supports_identity_generation_always() is False + + def test_sequence_attributes_are_declined(self, dialect): + """MINVALUE / MAXVALUE / CYCLE have no spelling in the property.""" + assert dialect.supports_identity_minvalue() is False + assert dialect.supports_identity_maxvalue() is False + assert dialect.supports_identity_cycle() is False + + def test_auto_increment_marker_is_declined(self, dialect): + """``IDENTITY(seed, inc)`` is not ``AUTO_INCREMENT``.""" + assert dialect.supports_auto_increment_column() is False + + +class TestIdentityRendering: + """``IDENTITY(seed, increment)`` and the two parameters it carries.""" + + def test_bare_renders_the_property_defaults(self, dialect): + assert IdentityClause(dialect).to_sql() == (" IDENTITY(1, 1)", ()) + + def test_by_default_renders_the_same_property(self, dialect): + # SQL Server has one property; BY DEFAULT is its implied mode. + assert IdentityClause(dialect, "BY DEFAULT").to_sql() == (" IDENTITY(1, 1)", ()) + + def test_start_is_the_seed(self, dialect): + assert IdentityClause(dialect, start=100).to_sql() == (" IDENTITY(100, 1)", ()) + + def test_increment_is_the_increment(self, dialect): + assert IdentityClause(dialect, increment=5).to_sql() == (" IDENTITY(1, 5)", ()) + + def test_seed_and_increment_together(self, dialect): + assert IdentityClause(dialect, start=100, increment=5).to_sql() == ( + " IDENTITY(100, 5)", + (), + ) + + +class TestAlwaysIsRefusedNotDowngraded: + """The behaviour change: ``ALWAYS`` fails closed instead of degrading.""" + + def test_always_is_refused_by_name(self, dialect): + with pytest.raises(UnsupportedFeatureError, match="IDENTITY GENERATED ALWAYS"): + IdentityClause(dialect, "ALWAYS").to_sql() + + def test_always_does_not_render_the_by_default_sql(self, dialect): + """The refusal is the only outcome; the bare property never comes out. + + Before this round ``ALWAYS`` rendered byte-identical to ``BY DEFAULT`` + (`` IDENTITY(1, 1)``), i.e. the request was silently degraded. Pin the + difference: the same constructor arguments that render for ``BY + DEFAULT`` must raise for ``ALWAYS``. + """ + assert IdentityClause(dialect, "BY DEFAULT").to_sql()[0] == " IDENTITY(1, 1)" + with pytest.raises(UnsupportedFeatureError): + IdentityClause(dialect, "ALWAYS").to_sql() + + def test_always_with_parameters_is_still_refused(self, dialect): + with pytest.raises(UnsupportedFeatureError, match="IDENTITY GENERATED ALWAYS"): + IdentityClause(dialect, "ALWAYS", start=100, increment=5).to_sql() + + +class TestUnspellableOptionsAreRefusedByName: + """Each parameter without a spelling is refused, never dropped.""" + + @pytest.mark.parametrize( + "kwargs,feature", + [ + ({"minvalue": 1}, "IDENTITY MINVALUE"), + ({"maxvalue": 100}, "IDENTITY MAXVALUE"), + ({"cycle": True}, "IDENTITY CYCLE"), + ({"cycle": False}, "IDENTITY CYCLE"), + ], + ids=["minvalue", "maxvalue", "cycle", "nocycle"], + ) + def test_option_is_refused_by_name(self, dialect, kwargs, feature): + with pytest.raises(UnsupportedFeatureError, match=feature): + IdentityClause(dialect, **kwargs).to_sql() + + +class TestGatesAreLive: + """Withdrawing one probe must change exactly that gate. + + These are the mutation checks: if the formatter ignored a probe, the + refusal would not follow the declaration. + """ + + def test_master_switch_withdrawn_refuses_even_with_options(self): + dialect = _withdrawn("supports_identity_column") + with pytest.raises(UnsupportedFeatureError, match="IDENTITY column"): + IdentityClause(dialect).to_sql() + with pytest.raises(UnsupportedFeatureError, match="IDENTITY column"): + IdentityClause(dialect, start=1, increment=1).to_sql() + + def test_start_withdrawn_refuses_start_but_not_increment(self): + dialect = _withdrawn("supports_identity_start") + with pytest.raises(UnsupportedFeatureError, match="IDENTITY START"): + IdentityClause(dialect, start=100).to_sql() + assert IdentityClause(dialect, increment=5).to_sql()[0] == " IDENTITY(1, 5)" + + def test_increment_withdrawn_refuses_increment_but_not_start(self): + dialect = _withdrawn("supports_identity_increment") + with pytest.raises(UnsupportedFeatureError, match="IDENTITY INCREMENT"): + IdentityClause(dialect, increment=5).to_sql() + assert IdentityClause(dialect, start=100).to_sql()[0] == " IDENTITY(100, 1)" + + def test_generation_always_gate_withdrawn_refuses_always(self): + dialect = _withdrawn("supports_identity_generation_always") + with pytest.raises(UnsupportedFeatureError, match="IDENTITY GENERATED ALWAYS"): + IdentityClause(dialect, "ALWAYS").to_sql() + assert IdentityClause(dialect, "BY DEFAULT").to_sql()[0] == " IDENTITY(1, 1)" + + +class TestAutoIncrementMarkerIsRefused: + """The parameterless marker is a different mechanism and fails closed.""" + + def test_marker_is_refused_by_name(self, dialect): + with pytest.raises(UnsupportedFeatureError, match="AUTO_INCREMENT column"): + AutoIncrementClause(dialect).to_sql() + + +class TestColumnDefinitionUsesTheIdentityClause: + """The path a user hits: an ``IdentityAttribute`` on a column definition.""" + + def test_identity_attribute_renders_the_property(self, dialect): + column = ColumnDefinition( + dialect, + "id", + IntegerType(dialect), + attributes=[IdentityAttribute(start=100, increment=5)], + ) + assert column.to_sql() == ("[id] INT IDENTITY(100, 5)", ()) + + def test_always_identity_attribute_is_refused(self, dialect): + column = ColumnDefinition( + dialect, + "id", + IntegerType(dialect), + attributes=[IdentityAttribute(generation="ALWAYS", start=100)], + ) + with pytest.raises(UnsupportedFeatureError, match="IDENTITY GENERATED ALWAYS"): + column.to_sql() + + def test_unspellable_identity_attribute_is_refused(self, dialect): + column = ColumnDefinition( + dialect, + "id", + IntegerType(dialect), + attributes=[IdentityAttribute(minvalue=1)], + ) + with pytest.raises(UnsupportedFeatureError, match="IDENTITY MINVALUE"): + column.to_sql() diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_identity_execution_confirmation.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_identity_execution_confirmation.py new file mode 100644 index 0000000..3afc941 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_identity_execution_confirmation.py @@ -0,0 +1,235 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_identity_execution_confirmation.py +"""Execution confirmation for SQL Server's identity column. + +Rendering is not execution: a formatter can produce SQL that looks perfect and +is still rejected by the server. Every verdict here is the server's own answer, +and every accepted verdict is guarded by a sentinel that must come back +REJECTED -- a classifier that cannot see a rejection would make the +confirmation vacuous. + +The SQL rendered by the dialect is what is executed: the ``CreateTableExpression`` +is built with an AR-layer ``IdentityAttribute``, exactly the path an application +uses, and the rendered statement is handed to +:func:`confirm_expression_execution`. Three outcomes stay distinct: + +* ACCEPTED -- the server accepted the SQL; +* REJECTED -- raised as ``ExecutionConfirmationError``; this is the defect the + helper exists to catch; +* NOT_RENDERED -- the dialect refused the expression (``UnsupportedFeatureError``), + which is fail-closed behaviour working as designed, not a rejection. + +Measured on 2019 / 2022 / 2025: ``IDENTITY(seed, increment)`` executes and +honours seed/increment; the standard ``GENERATED ... AS IDENTITY`` grammar is +refused by the server; MINVALUE / MAXVALUE / CYCLE have no spelling. +""" + +import pytest + +from rhosocial.activerecord.base import IdentityAttribute +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression import ( + ColumnDefinition, + CreateTableExpression, +) +from rhosocial.activerecord.backend.expression.execution_testing import ( + ExecutionConfirmationError, + ExecutionOutcome, + classify_execution, + confirm_expression_execution, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.statements import AutoIncrementClause +from rhosocial.activerecord.backend.expression.types import IntegerType, TextType + + +def _drop_sql(table_name: str) -> str: + return ( + f"IF OBJECT_ID(N'dbo.{table_name}', N'U') IS NOT NULL " + f"DROP TABLE dbo.{table_name}" + ) + + +def _drop(backend, table_name: str) -> None: + backend.execute(_drop_sql(table_name)) + + +def _create_table(dialect, table_name: str, *, generation=None, **identity_kwargs): + attribute = IdentityAttribute( + generation=generation or "BY DEFAULT", **identity_kwargs + ) + return CreateTableExpression( + dialect=dialect, + table=Table(dialect, table_name), + columns=[ + ColumnDefinition( + dialect, + "id", + IntegerType(dialect), + attributes=[attribute], + ), + ColumnDefinition(dialect, "v", TextType(dialect)), + ], + ) + + +class TestSentinelSeesRejection: + """Before any ACCEPTED verdict, prove the classifier can see a rejection.""" + + def test_invalid_sql_is_classified_rejected(self, sqlserver_backend): + assert ( + classify_execution(sqlserver_backend, "THIS IS NOT SQL") + is ExecutionOutcome.REJECTED + ) + + def test_helper_raises_on_a_server_rejection(self, sqlserver_backend): + """A rendered statement the server refuses must raise, not pass. + + The table is created once (ACCEPTED), then the identical statement is + confirmed again: the server refuses it with "There is already an object + named ...", and the helper must surface that as + ``ExecutionConfirmationError`` rather than swallow it. + """ + dialect = sqlserver_backend.dialect + table_name = "ar_identity_sentinel" + create = _create_table(dialect, table_name, start=100, increment=5) + _drop(sqlserver_backend, table_name) + try: + outcome = confirm_expression_execution(sqlserver_backend, create) + assert outcome is ExecutionOutcome.ACCEPTED + with pytest.raises(ExecutionConfirmationError): + confirm_expression_execution(sqlserver_backend, create) + finally: + _drop(sqlserver_backend, table_name) + + +class TestIdentityExecutes: + """The dialect's identity spelling is accepted and its parameters take.""" + + def test_bare_identity_executes(self, sqlserver_backend): + dialect = sqlserver_backend.dialect + table_name = "ar_identity_exec_bare" + create = _create_table(dialect, table_name) + sql, _ = create.to_sql() + assert "IDENTITY(1, 1)" in sql + _drop(sqlserver_backend, table_name) + try: + outcome = confirm_expression_execution( + sqlserver_backend, create, teardown=[(_drop_sql(table_name), ())] + ) + assert outcome is ExecutionOutcome.ACCEPTED + finally: + _drop(sqlserver_backend, table_name) + + def test_seed_and_increment_execute_and_take_effect(self, sqlserver_backend): + """``IDENTITY(100, 5)`` is accepted *and* the server starts at 100. + + Acceptance alone would not distinguish the property's arguments from + the defaults, so two rows are inserted and the generated ids are read + back: they must be 100 and 105. + """ + dialect = sqlserver_backend.dialect + table_name = "ar_identity_exec_seed" + create = _create_table(dialect, table_name, start=100, increment=5) + sql, _ = create.to_sql() + assert "IDENTITY(100, 5)" in sql + _drop(sqlserver_backend, table_name) + try: + outcome = confirm_expression_execution(sqlserver_backend, create) + assert outcome is ExecutionOutcome.ACCEPTED + sqlserver_backend.execute( + f"INSERT INTO dbo.{table_name} (v) VALUES (N'a'), (N'b')" + ) + result = sqlserver_backend.execute( + f"SELECT id FROM dbo.{table_name} ORDER BY id" + ) + assert [row["id"] for row in result.data] == [100, 105] + finally: + _drop(sqlserver_backend, table_name) + + +class TestUnsupportedRequestsAreNotRendered: + """A refused expression is NOT_RENDERED -- never REJECTED, never executed.""" + + def test_always_request_is_not_rendered(self, sqlserver_backend): + """The behaviour change: ALWAYS fails closed instead of degrading.""" + dialect = sqlserver_backend.dialect + expr = _create_table(dialect, "ar_identity_exec_always", generation="ALWAYS") + outcome = confirm_expression_execution(sqlserver_backend, expr) + assert outcome is ExecutionOutcome.NOT_RENDERED + with pytest.raises(UnsupportedFeatureError, match="IDENTITY GENERATED ALWAYS"): + expr.to_sql() + + @pytest.mark.parametrize( + "identity_kwargs,feature", + [ + ({"minvalue": 1}, "IDENTITY MINVALUE"), + ({"maxvalue": 100}, "IDENTITY MAXVALUE"), + ({"cycle": True}, "IDENTITY CYCLE"), + ({"cycle": False}, "IDENTITY CYCLE"), + ], + ids=["minvalue", "maxvalue", "cycle", "nocycle"], + ) + def test_unspellable_option_is_not_rendered( + self, sqlserver_backend, identity_kwargs, feature + ): + dialect = sqlserver_backend.dialect + expr = _create_table( + dialect, "ar_identity_exec_option", **identity_kwargs + ) + outcome = confirm_expression_execution(sqlserver_backend, expr) + assert outcome is ExecutionOutcome.NOT_RENDERED + with pytest.raises(UnsupportedFeatureError, match=feature): + expr.to_sql() + + def test_auto_increment_marker_is_not_rendered(self, sqlserver_backend): + dialect = sqlserver_backend.dialect + assert dialect.supports_auto_increment_column() is False + outcome = confirm_expression_execution( + sqlserver_backend, AutoIncrementClause(dialect) + ) + assert outcome is ExecutionOutcome.NOT_RENDERED + + +class TestServerRefusesTheStandardSpellings: + """The declarations are measured, not assumed: the server's own answers. + + These raw statements are the candidates the dialect refuses to render. The + server must refuse them too -- otherwise refusing them would be a false + negative, and the corresponding probe would have to be ``True``. + """ + + @pytest.mark.parametrize( + "table_name,statement", + [ + ( + "ar_identity_std_always", + "CREATE TABLE dbo.{name} (id INT GENERATED ALWAYS AS IDENTITY)", + ), + ( + "ar_identity_std_default", + "CREATE TABLE dbo.{name} (id INT GENERATED BY DEFAULT AS IDENTITY)", + ), + ( + "ar_identity_std_minvalue", + "CREATE TABLE dbo.{name} (id INT IDENTITY(1,1) MINVALUE 1)", + ), + ( + "ar_identity_std_maxvalue", + "CREATE TABLE dbo.{name} (id INT IDENTITY(1,1) MAXVALUE 100)", + ), + ( + "ar_identity_std_cycle", + "CREATE TABLE dbo.{name} (id INT IDENTITY(1,1) CYCLE)", + ), + ], + ids=["always", "by-default", "minvalue", "maxvalue", "cycle"], + ) + def test_standard_spelling_is_rejected(self, sqlserver_backend, table_name, statement): + _drop(sqlserver_backend, table_name) + try: + assert ( + classify_execution(sqlserver_backend, statement.format(name=table_name)) + is ExecutionOutcome.REJECTED + ) + finally: + _drop(sqlserver_backend, table_name) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_protocol_conformance.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_protocol_conformance.py index 38ef9a3..da9f783 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_protocol_conformance.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_protocol_conformance.py @@ -74,7 +74,7 @@ def _classified_keys(protocols) -> set: dialect_protocols.AdvancedGroupingSupport, dialect_protocols.AlterTableModifierSupport, dialect_protocols.ArraySupport, - dialect_protocols.AutoIncrementSupport, + dialect_protocols.AutoIncrementColumnSupport, dialect_protocols.CTESupport, dialect_protocols.CollationSupport, dialect_protocols.ColumnAttributeSupport, @@ -86,6 +86,7 @@ def _classified_keys(protocols) -> set: dialect_protocols.GeneratedColumnSupport, dialect_protocols.GraphSupport, dialect_protocols.ILIKESupport, + dialect_protocols.IdentityColumnSupport, dialect_protocols.IntrospectionSupport, dialect_protocols.JSONSupport, dialect_protocols.JoinSupport, From c16c73312154c35901851cc18e6447edf7ff1b9c Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 7 Oct 2026 05:40:45 +0800 Subject: [PATCH 05/10] fix(sqlserver): gate the identity ORDER and CACHE options instead of dropping them The dialect overrides format_identity_clause to render its own IDENTITY(seed, increment) property. That override gated minvalue / maxvalue / cycle and then jumped straight to seed / increment -- so a request for order or cache, the two fields core added this round, rendered the bare property ( IDENTITY(1, 1)) with the request silently dropped, while the same method refused cycle. Two behaviours in one method. Add the two gates. supports_identity_order() and supports_identity_cache() answer False -- measured on 2019 / 2022 / 2025: ORDER, NO ORDER, CACHE and NO CACHE are syntax errors in the property -- so the refusal is by name, IDENTITY ORDER / IDENTITY CACHE, never a downgrade. Add a general walk test that reads every option field from IdentityClause.__init__ itself and asserts the formatter renders each one or refuses it by name, never renders it byte-identical to the bare clause. Withdrawing a gate fails the walk with "the option was silently dropped", and a self-test proves the walk rejects a formatter that drops options, so the next field core adds cannot reopen this hole. Execution confirmation runs the refusal verdict on all three servers, and the raw candidate spellings join the measured-rejection list -- ORDER, NO ORDER, CACHE and NO CACHE are refused by 2019 / 2022 / 2025, with the existing sentinels still proving the classifier can see a rejection. --- .../impl/sqlserver/mixins/identity_column.py | 43 ++++- .../backend/test_identity_clause_sqlserver.py | 166 +++++++++++++++++- .../test_identity_execution_confirmation.py | 63 ++++++- 3 files changed, 258 insertions(+), 14 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/identity_column.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/identity_column.py index fd955c0..48e8f04 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/identity_column.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/identity_column.py @@ -12,10 +12,10 @@ Server 2019 / 2022 / 2025; * ``seed`` / ``increment`` are the two parameters the property takes; * the generation mode ``ALWAYS`` and the sequence attributes - MINVALUE / MAXVALUE / CYCLE have no spelling in the property at all. - Every candidate spelling was measured refused by all three servers, so - those probes answer ``False`` and the formatter refuses a request for them - by name instead of silently dropping it. + MINVALUE / MAXVALUE / CYCLE / ORDER / CACHE have no spelling in the + property at all. Every candidate spelling was measured refused by all + three servers, so those probes answer ``False`` and the formatter refuses + a request for them by name instead of silently dropping it. ``AUTO_INCREMENT`` is a different mechanism again -- a *parameterless* column marker carried by ``AutoIncrementClause`` -- and SQL Server has no such @@ -90,6 +90,22 @@ def supports_identity_cycle(self) -> bool: """ return False + def supports_identity_order(self) -> bool: + """SQL Server's identity property has no ORDER option. + + Measured on 2019 / 2022 / 2025: both candidate spellings (``ORDER``, + ``NO ORDER``) are refused as syntax errors. + """ + return False + + def supports_identity_cache(self) -> bool: + """SQL Server's identity property has no CACHE option. + + Measured on 2019 / 2022 / 2025: both candidate spellings + (``CACHE ``, ``NO CACHE``) are refused as syntax errors. + """ + return False + def supports_auto_increment_column(self) -> bool: """SQL Server has no bare ``AUTO_INCREMENT`` column marker. @@ -112,8 +128,9 @@ def format_identity_clause(self, expr: "IdentityClause") -> Tuple[str, tuple]: is refused, never silently rendered as the bare property); * :meth:`supports_identity_start` / ``_increment`` gate the seed and increment, the two parameters the property actually takes; - * :meth:`supports_identity_minvalue` / ``_maxvalue`` / ``_cycle`` - gate the sequence attributes the property cannot spell. + * :meth:`supports_identity_minvalue` / ``_maxvalue`` / ``_cycle`` / + ``_order`` / ``_cache`` gate the sequence attributes the property + cannot spell. Args: expr: The ``IdentityClause`` carrying the identity parameters. @@ -175,6 +192,20 @@ def format_identity_clause(self, expr: "IdentityClause") -> Tuple[str, tuple]: "IDENTITY CYCLE", f"{self.name} does not support the CYCLE identity option.", # type: ignore[attr-defined] ) + if expr.cache is not None: + if not self.supports_identity_cache(): + raise UnsupportedFeatureError( + self.name, # type: ignore[attr-defined] + "IDENTITY CACHE", + f"{self.name} does not support the CACHE identity option.", # type: ignore[attr-defined] + ) + if expr.order is not None: + if not self.supports_identity_order(): + raise UnsupportedFeatureError( + self.name, # type: ignore[attr-defined] + "IDENTITY ORDER", + f"{self.name} does not support the ORDER identity option.", # type: ignore[attr-defined] + ) seed = expr.start if expr.start is not None else 1 increment = expr.increment if expr.increment is not None else 1 return f" IDENTITY({seed}, {increment})", () diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_identity_clause_sqlserver.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_identity_clause_sqlserver.py index d9b9b46..81687a2 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_identity_clause_sqlserver.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_identity_clause_sqlserver.py @@ -15,13 +15,22 @@ rendering the bare property (which is byte-identical to ``BY DEFAULT``). * ``seed`` / ``increment`` are the property's two arguments, so their probes are ``True``. -* MINVALUE / MAXVALUE / CYCLE have no spelling in the property at all; every - candidate spelling was measured refused on all three versions, so their - probes are ``False`` and the formatter refuses a request for them. +* MINVALUE / MAXVALUE / CYCLE / ORDER / CACHE have no spelling in the + property at all; every candidate spelling was measured refused on all three + versions, so their probes are ``False`` and the formatter refuses a request + for them by name. +* ``TestNoOptionIsSilentlyDropped`` walks every option field read from + ``IdentityClause.__init__`` and asserts the formatter renders each one or + refuses it by name -- a field core adds later cannot be silently dropped + the way ``order`` and ``cache`` were before the gates existed. * ``AUTO_INCREMENT`` is a different mechanism (parameterless marker), which SQL Server has no spelling for. """ +import inspect +import types +import typing + import pytest from rhosocial.activerecord.base import IdentityAttribute @@ -68,10 +77,12 @@ def test_always_is_declined(self, dialect): assert dialect.supports_identity_generation_always() is False def test_sequence_attributes_are_declined(self, dialect): - """MINVALUE / MAXVALUE / CYCLE have no spelling in the property.""" + """MINVALUE / MAXVALUE / CYCLE / ORDER / CACHE have no spelling.""" assert dialect.supports_identity_minvalue() is False assert dialect.supports_identity_maxvalue() is False assert dialect.supports_identity_cycle() is False + assert dialect.supports_identity_order() is False + assert dialect.supports_identity_cache() is False def test_auto_increment_marker_is_declined(self, dialect): """``IDENTITY(seed, inc)`` is not ``AUTO_INCREMENT``.""" @@ -135,14 +146,159 @@ class TestUnspellableOptionsAreRefusedByName: ({"maxvalue": 100}, "IDENTITY MAXVALUE"), ({"cycle": True}, "IDENTITY CYCLE"), ({"cycle": False}, "IDENTITY CYCLE"), + ({"order": True}, "IDENTITY ORDER"), + ({"order": False}, "IDENTITY ORDER"), + ({"cache": 10}, "IDENTITY CACHE"), + ({"cache": 0}, "IDENTITY CACHE"), + ], + ids=[ + "minvalue", + "maxvalue", + "cycle", + "nocycle", + "order", + "no-order", + "cache", + "no-cache", ], - ids=["minvalue", "maxvalue", "cycle", "nocycle"], ) def test_option_is_refused_by_name(self, dialect, kwargs, feature): with pytest.raises(UnsupportedFeatureError, match=feature): IdentityClause(dialect, **kwargs).to_sql() +# ``IdentityClause``'s option fields are read from the expression's own +# signature, not from a list here: a field core adds later is walked +# automatically, so it cannot be silently dropped the way ``order`` and +# ``cache`` were before the gates existed. +_OPTION_UNION_TYPES = (typing.Union,) +if hasattr(types, "UnionType"): # Python 3.10+ + _OPTION_UNION_TYPES += (types.UnionType,) + +# Sentinel floor, not the coverage list: the walk must at least see the +# options that existed when it was written. What is actually walked is +# whatever ``IdentityClause.__init__`` declares; this set only proves the +# signature introspection did not silently shrink to nothing. +_OPTIONS_AT_WALK_BIRTH = frozenset( + {"start", "increment", "minvalue", "maxvalue", "cycle", "order", "cache"} +) + + +def _option_fields() -> dict: + """The keyword-only option fields, read from the expression itself.""" + params = inspect.signature(IdentityClause.__init__).parameters + return { + name: param + for name, param in params.items() + if param.kind is inspect.Parameter.KEYWORD_ONLY + } + + +def _probe_values(field_name: str, param: inspect.Parameter) -> tuple: + """Non-default probe values for one option field. + + Both sides of the domain are probed for the types the options use today: + ``True`` and ``False`` for a flag (``NO ORDER`` is as much a request as + ``ORDER``), ``100`` and ``0`` for a number (``CACHE 0`` is as much a + request as ``CACHE 10``). A field with an unknown type fails the walk + with the reason instead of being probed with a meaningless value. + """ + annotation = param.annotation + if annotation is inspect.Parameter.empty: + raise AssertionError( + f"IdentityClause option {field_name!r} is unannotated; this walk " + f"cannot choose a probe value for it." + ) + if typing.get_origin(annotation) in _OPTION_UNION_TYPES: + args = tuple( + arg for arg in typing.get_args(annotation) if arg is not type(None) + ) + annotation = args[0] if args else annotation + if annotation is bool: + return (True, False) + if annotation is int: + return (100, 0) + raise AssertionError( + f"no probe value is known for IdentityClause option {field_name!r} " + f"(type {annotation!r}); extend this walk so the new option is still " + f"rendered or refused by name, never dropped." + ) + + +def _walk_options(dialect) -> dict: + """Render-or-refuse every option; return the verdicts, raise on a drop. + + The property under test: for every option field of ``IdentityClause``, a + clause requesting that option either renders SQL that differs from the + bare clause, or raises ``UnsupportedFeatureError`` naming the option. + Rendering the bare clause byte-for-byte is the silent drop this walk + exists to catch. + """ + options = _option_fields() + assert set(options) >= _OPTIONS_AT_WALK_BIRTH, ( + f"the signature walk found only {sorted(options)}; it must at least " + f"see the options that existed when this test was written" + ) + bare_sql, _ = IdentityClause(dialect).to_sql() + verdicts = {} + for name, param in options.items(): + for value in _probe_values(name, param): + clause = IdentityClause(dialect, **{name: value}) + try: + sql, _ = clause.to_sql() + except UnsupportedFeatureError as exc: + if f"IDENTITY {name.upper()}" not in str(exc): + raise AssertionError( + f"option {name!r}={value!r} was refused without " + f"naming it: {exc}" + ) from exc + verdicts[(name, value)] = "refused" + else: + if sql == bare_sql: + raise AssertionError( + f"option {name!r}={value!r} rendered {sql!r}, " + f"byte-identical to the bare clause {bare_sql!r}: " + f"the option was silently dropped." + ) + verdicts[(name, value)] = "rendered" + return verdicts + + +class TestNoOptionIsSilentlyDropped: + """Every ``IdentityClause`` option is rendered or refused by name. + + The option list is read from ``IdentityClause.__init__`` itself, so the + walk covers a field core adds later the moment it exists -- it cannot be + silently dropped the way ``order`` and ``cache`` were. + """ + + def test_every_option_is_rendered_or_refused_by_name(self, dialect): + verdicts = _walk_options(dialect) + # Sentinels: both branches of the walk must be exercised, or a walk + # that only ever saw one of them could look healthy while blind. + assert "refused" in verdicts.values(), verdicts + assert "rendered" in verdicts.values(), verdicts + # The two fields this round added: both sides of each request refused. + for key in (("order", True), ("order", False), ("cache", 100), ("cache", 0)): + assert verdicts[key] == "refused", verdicts + + def test_the_walk_rejects_a_formatter_that_drops_options(self): + """The walk is live: a formatter that drops an option must fail it. + + The dropping formatter below is the shape this dialect had before the + gates existed -- the bare property for every request. If the walk + could not see that, it would certify the defect it was written to + catch. + """ + + class DroppingDialect(SQLServerDialect): + def format_identity_clause(self, expr): + return " IDENTITY(1, 1)", () + + with pytest.raises(AssertionError, match="silently dropped"): + _walk_options(DroppingDialect((16, 0, 0))) + + class TestGatesAreLive: """Withdrawing one probe must change exactly that gate. diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_identity_execution_confirmation.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_identity_execution_confirmation.py index 3afc941..9ed8093 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_identity_execution_confirmation.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_identity_execution_confirmation.py @@ -20,7 +20,8 @@ Measured on 2019 / 2022 / 2025: ``IDENTITY(seed, increment)`` executes and honours seed/increment; the standard ``GENERATED ... AS IDENTITY`` grammar is -refused by the server; MINVALUE / MAXVALUE / CYCLE have no spelling. +refused by the server; MINVALUE / MAXVALUE / CYCLE / ORDER / CACHE have no +spelling. """ import pytest @@ -38,7 +39,10 @@ confirm_expression_execution, ) from rhosocial.activerecord.backend.expression.objects import Table -from rhosocial.activerecord.backend.expression.statements import AutoIncrementClause +from rhosocial.activerecord.backend.expression.statements import ( + AutoIncrementClause, + IdentityClause, +) from rhosocial.activerecord.backend.expression.types import IntegerType, TextType @@ -189,6 +193,33 @@ def test_auto_increment_marker_is_not_rendered(self, sqlserver_backend): ) assert outcome is ExecutionOutcome.NOT_RENDERED + @pytest.mark.parametrize( + "identity_kwargs,feature", + [ + ({"order": True}, "IDENTITY ORDER"), + ({"order": False}, "IDENTITY ORDER"), + ({"cache": 10}, "IDENTITY CACHE"), + ({"cache": 0}, "IDENTITY CACHE"), + ], + ids=["order", "no-order", "cache", "no-cache"], + ) + def test_order_and_cache_requests_are_not_rendered( + self, sqlserver_backend, identity_kwargs, feature + ): + """ORDER / CACHE have no spelling in ``IDENTITY(seed, increment)``. + + The AR-layer ``IdentityAttribute`` does not carry these fields yet, so + the request is built at the clause level -- the unit the formatter + gates. The verdict is NOT_RENDERED: the dialect refuses before the + server is ever asked, and the refusal names the option. + """ + dialect = sqlserver_backend.dialect + expr = IdentityClause(dialect, **identity_kwargs) + outcome = confirm_expression_execution(sqlserver_backend, expr) + assert outcome is ExecutionOutcome.NOT_RENDERED + with pytest.raises(UnsupportedFeatureError, match=feature): + expr.to_sql() + class TestServerRefusesTheStandardSpellings: """The declarations are measured, not assumed: the server's own answers. @@ -221,8 +252,34 @@ class TestServerRefusesTheStandardSpellings: "ar_identity_std_cycle", "CREATE TABLE dbo.{name} (id INT IDENTITY(1,1) CYCLE)", ), + ( + "ar_identity_std_order", + "CREATE TABLE dbo.{name} (id INT IDENTITY(1,1) ORDER)", + ), + ( + "ar_identity_std_no_order", + "CREATE TABLE dbo.{name} (id INT IDENTITY(1,1) NO ORDER)", + ), + ( + "ar_identity_std_cache", + "CREATE TABLE dbo.{name} (id INT IDENTITY(1,1) CACHE 10)", + ), + ( + "ar_identity_std_no_cache", + "CREATE TABLE dbo.{name} (id INT IDENTITY(1,1) NO CACHE)", + ), + ], + ids=[ + "always", + "by-default", + "minvalue", + "maxvalue", + "cycle", + "order", + "no-order", + "cache", + "no-cache", ], - ids=["always", "by-default", "minvalue", "maxvalue", "cycle"], ) def test_standard_spelling_is_rejected(self, sqlserver_backend, table_name, statement): _drop(sqlserver_backend, table_name) From 8feb28ab6a1c6ea053d7e495bfda39715daf0bab Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 7 Oct 2026 12:43:44 +0800 Subject: [PATCH 06/10] fix(sqlserver): ask the sequence probes before refusing ALTER ORDER/OWNED BY format_alter_sequence_statement refused ORDER and OWNED BY with a hard-coded raise, while every neighbouring option in the same method consulted its own probe. supports_sequence_order() and supports_sequence_owned_by() answer False for SQL Server, so the output was right by accident: flipping either probe in a subclass changed nothing, because the formatter never asked. Gate both options the way the neighbours are gated -- consult the probe, refuse by name when it answers False, render the clause when it accepts. The output is byte-identical: both probes still answer False, so a request for either is still refused. Snapshots across 2012/2016/2022 and live 2019/2022/2025 before and after differ by zero bytes; all live verdicts and sentinels met on the three servers. Add test_sequence_probes_sqlserver.py. Its test_every_sequence_option_probe_is_load_bearing walks every sequence option probe on CREATE/ALTER/DROP, flips each one in a subclass, and asserts the behaviour flips with it; it was red on ALTER order, NO ORDER and owned_by before the gates. A self-test feeds the walk the pre-fix hard-coded shape and asserts it reports a decorative probe, and a completeness check refuses to let a declared probe fall out of the walk. --- .../backend/impl/sqlserver/dialect.py | 37 +- .../backend/test_sequence_probes_sqlserver.py | 404 ++++++++++++++++++ 2 files changed, 427 insertions(+), 14 deletions(-) create mode 100644 tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sequence_probes_sqlserver.py diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index f4aa7cc..a6bd954 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -1028,12 +1028,14 @@ def format_alter_sequence_statement(self, expr: "AlterSequenceExpression") -> Tu ``START WITH`` clause -- the engine rejects that argument with error 11710 -- and it has no ``ORDER``/``OWNED BY`` clause either. The formatter is kept here rather than deferred to ``SequenceMixin`` so - those words are never emitted, while the option probes still decide - which of the shared options this version accepts. A requested ``start`` - is gated on :meth:`supports_alter_sequence_start`, the ALTER-side - question, rather than on the CREATE-side - :meth:`supports_sequence_start`; the two clauses are different and only - the former decides this statement. + every option is gated on this dialect's own probe before its clause is + emitted; a probe that answers ``False`` makes the formatter refuse the + request by name rather than drop the clause. Both ``ORDER`` and + ``OWNED BY`` answer ``False``, so those words are never emitted for + this dialect. A requested ``start`` is gated on + :meth:`supports_alter_sequence_start`, the ALTER-side question, rather + than on the CREATE-side :meth:`supports_sequence_start`; the two + clauses are different and only the former decides this statement. Raises: TypeError: ``expr.sequence`` is not a Sequence. A table handed here @@ -1108,15 +1110,22 @@ def format_alter_sequence_statement(self, expr: "AlterSequenceExpression") -> Tu ) parts.append(f"CACHE {expr.cache}") if expr.order is not None: - raise UnsupportedFeatureError( - self.name, "ALTER SEQUENCE ORDER", - f"{self.name} does not support the ORDER sequence option." - ) + if not self.supports_sequence_order(): + raise UnsupportedFeatureError( + self.name, "ALTER SEQUENCE ORDER", + f"{self.name} does not support the ORDER sequence option." + ) + parts.append("ORDER" if expr.order else "NO ORDER") if expr.owned_by is not None: - raise UnsupportedFeatureError( - self.name, "ALTER SEQUENCE OWNED BY", - f"{self.name} does not support the OWNED BY sequence option." - ) + if not self.supports_sequence_owned_by(): + raise UnsupportedFeatureError( + self.name, "ALTER SEQUENCE OWNED BY", + f"{self.name} does not support the OWNED BY sequence option." + ) + if expr.owned_by: + parts.append(f"OWNED BY {expr.owned_by}") + else: + parts.append("OWNED BY NONE") return " ".join(parts), () def format_truncate_statement(self, expr: "TruncateExpression") -> Tuple[str, tuple]: diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sequence_probes_sqlserver.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sequence_probes_sqlserver.py new file mode 100644 index 0000000..78f1937 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sequence_probes_sqlserver.py @@ -0,0 +1,404 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sequence_probes_sqlserver.py +"""Every SQL Server sequence option probe must be load-bearing. + +A capability probe is *load-bearing* when the formatter's decision follows its +answer: flip the probe and the rendered statement (or the refusal) flips with +it. A probe whose answer can change while the behaviour does not is +*decorative* -- a capability declaration nobody consults. + +This file exists because ``format_alter_sequence_statement`` refused ``ORDER`` +and ``OWNED BY`` unconditionally, without asking +``supports_sequence_order()`` / ``supports_sequence_owned_by()``, while every +neighbouring option in the same method was gated on its own probe. Both probes +answer ``False`` for SQL Server, so the rendered output was correct by +accident: the refusal did not follow the declaration, and the declaration +could not be corrected by a subclass without editing the formatter. The two +gates added in this round make the probes load-bearing; this guard keeps them +that way. + +The walk covers every option probe on the dialect. ``CREATE`` and ``DROP`` +SEQUENCE render through core's ``SequenceMixin`` (this backend has no local +copy); ``ALTER`` renders through this dialect's override. The probes are the +dialect's in all three cases, so all three statements are walked. Probes that +answer about the statement or object rather than one of its options are named +in :data:`NON_OPTION_PROBES` and are outside the walk. + +The guard is deliberately able to fail: ``test_hardcoded_order_refusal_...`` +feeds the comparison a dialect whose formatter ignores a probe (the shape this +method had before the gates existed) and asserts the guard reports it +decorative. +""" + +from typing import NamedTuple + +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.objects import Sequence +from rhosocial.activerecord.backend.expression.statements.ddl_sequence import ( + AlterSequenceExpression, + CreateSequenceExpression, + DropSequenceExpression, +) +from rhosocial.activerecord.backend.impl.sqlserver.dialect import ( + SQL_SERVER_2022, + SQLServerDialect, +) + + +@pytest.fixture +def dialect(): + return SQLServerDialect(SQL_SERVER_2022) + + +def _seq(dialect): + return Sequence(dialect, "probe_seq") + + +class _ProbeCase(NamedTuple): + """One option request and the probe that must decide it. + + ``statement`` and ``expression_cls`` say which formatter renders the + request; ``feature`` is the name the refusal must carry when the probe + answers ``False``; ``kwargs`` request the option; ``probe_name`` is the + capability that must answer for it. + """ + + label: str + statement: str + expression_cls: type + option: str + feature: str + probe_name: str + kwargs: dict + + +#: Every sequence option probe, exercised on every statement that carries the +#: option. CREATE and DROP go through core's formatter, ALTER through this +#: dialect's; all three consult this dialect's probes. +SEQUENCE_OPTION_PROBES = [ + _ProbeCase( + "create-if-not-exists", "CREATE", CreateSequenceExpression, + "if_not_exists", "CREATE SEQUENCE IF NOT EXISTS", + "supports_sequence_if_not_exists", {"if_not_exists": True}, + ), + _ProbeCase( + "create-start", "CREATE", CreateSequenceExpression, + "start", "SEQUENCE START", + "supports_sequence_start", {"start": 1}, + ), + _ProbeCase( + "create-increment", "CREATE", CreateSequenceExpression, + "increment", "SEQUENCE INCREMENT", + "supports_sequence_increment", {"increment": 1}, + ), + _ProbeCase( + "create-minvalue", "CREATE", CreateSequenceExpression, + "minvalue", "SEQUENCE MINVALUE", + "supports_sequence_minvalue", {"minvalue": 1}, + ), + _ProbeCase( + "create-maxvalue", "CREATE", CreateSequenceExpression, + "maxvalue", "SEQUENCE MAXVALUE", + "supports_sequence_maxvalue", {"maxvalue": 1000}, + ), + _ProbeCase( + "create-cycle", "CREATE", CreateSequenceExpression, + "cycle", "SEQUENCE CYCLE", + "supports_sequence_cycle", {"cycle": True}, + ), + _ProbeCase( + "create-cache", "CREATE", CreateSequenceExpression, + "cache", "SEQUENCE CACHE", + "supports_sequence_cache", {"cache": 10}, + ), + _ProbeCase( + "create-order", "CREATE", CreateSequenceExpression, + "order", "SEQUENCE ORDER", + "supports_sequence_order", {"order": True}, + ), + _ProbeCase( + "create-owned-by", "CREATE", CreateSequenceExpression, + "owned_by", "SEQUENCE OWNED BY", + "supports_sequence_owned_by", {"owned_by": "dbo.orders.id"}, + ), + _ProbeCase( + "alter-start", "ALTER", AlterSequenceExpression, + "start", "ALTER SEQUENCE START", + "supports_alter_sequence_start", {"start": 1}, + ), + _ProbeCase( + "alter-increment", "ALTER", AlterSequenceExpression, + "increment", "ALTER SEQUENCE INCREMENT", + "supports_sequence_increment", {"increment": 1}, + ), + _ProbeCase( + "alter-minvalue", "ALTER", AlterSequenceExpression, + "minvalue", "ALTER SEQUENCE MINVALUE", + "supports_sequence_minvalue", {"minvalue": 1}, + ), + _ProbeCase( + "alter-maxvalue", "ALTER", AlterSequenceExpression, + "maxvalue", "ALTER SEQUENCE MAXVALUE", + "supports_sequence_maxvalue", {"maxvalue": 1000}, + ), + _ProbeCase( + "alter-cycle", "ALTER", AlterSequenceExpression, + "cycle", "ALTER SEQUENCE CYCLE", + "supports_sequence_cycle", {"cycle": True}, + ), + _ProbeCase( + "alter-cache", "ALTER", AlterSequenceExpression, + "cache", "ALTER SEQUENCE CACHE", + "supports_sequence_cache", {"cache": 10}, + ), + _ProbeCase( + "alter-order", "ALTER", AlterSequenceExpression, + "order", "ALTER SEQUENCE ORDER", + "supports_sequence_order", {"order": True}, + ), + _ProbeCase( + "alter-no-order", "ALTER", AlterSequenceExpression, + "NO ORDER", "ALTER SEQUENCE ORDER", + "supports_sequence_order", {"order": False}, + ), + _ProbeCase( + "alter-owned-by", "ALTER", AlterSequenceExpression, + "owned_by", "ALTER SEQUENCE OWNED BY", + "supports_sequence_owned_by", {"owned_by": "dbo.orders.id"}, + ), + _ProbeCase( + "drop-if-exists", "DROP", DropSequenceExpression, + "if_exists", "DROP SEQUENCE IF EXISTS", + "supports_sequence_if_exists", {"if_exists": True}, + ), +] + +#: Sequence probes that answer about the statement or the sequence object, not +#: about one of its options. ``supports_sequence`` is the master switch every +#: sequence formatter consults; the other four are declaration-only answers +#: that no formatter in core or this backend reads, so no option behaviour can +#: follow them. They are named here rather than silently skipped. +NON_OPTION_PROBES = frozenset( + { + "supports_sequence", + "supports_create_sequence", + "supports_drop_sequence", + "supports_alter_sequence", + "supports_sequence_as_data_type", + } +) + +#: Sentinel floor, not the coverage list: the walk must at least see the +#: option probes that existed when it was written. Hard-coded on purpose -- +#: deriving it from ``SEQUENCE_OPTION_PROBES`` would let a case and its probe +#: be deleted together without this floor moving. +_OPTION_PROBES_AT_WALK_BIRTH = frozenset( + { + "supports_sequence_if_not_exists", + "supports_sequence_if_exists", + "supports_sequence_start", + "supports_alter_sequence_start", + "supports_sequence_increment", + "supports_sequence_minvalue", + "supports_sequence_maxvalue", + "supports_sequence_cycle", + "supports_sequence_cache", + "supports_sequence_order", + "supports_sequence_owned_by", + } +) + + +def _declared_sequence_probes() -> set: + """Every ``supports_*sequence*`` capability declared on the dialect.""" + declared = set() + for name in dir(SQLServerDialect): + if not name.startswith("supports_"): + continue + tail = name[len("supports_"):] + if not tail.startswith( + ("sequence", "create_sequence", "drop_sequence", "alter_sequence") + ): + continue + if callable(getattr(SQLServerDialect, name, None)): + declared.add(name) + return declared + + +def _flipped_dialect(probe_name: str) -> SQLServerDialect: + """A stock dialect with exactly one capability probe flipped. + + The flip is the only difference from the stock dialect, so any behaviour + change between the two is attributable to that probe alone. + """ + stock_value = getattr(SQLServerDialect(SQL_SERVER_2022), probe_name)() + flipped = type( + f"SQLServerDialectFlipped_{probe_name}", + (SQLServerDialect,), + {probe_name: lambda self: not stock_value}, + ) + return flipped(SQL_SERVER_2022) + + +def _render_outcome(expression_cls, dialect, kwargs): + """Render one statement; a refusal is an outcome, not an error. + + Returns ``("rendered", (sql, params))`` or ``("refused", message)`` so the + guard can compare the two dialects' behaviour without ``pytest.raises``. + """ + try: + sql, params = expression_cls(dialect, _seq(dialect), **kwargs).to_sql() + except UnsupportedFeatureError as exc: + return "refused", str(exc) + return "rendered", (sql, params) + + +def _load_bearing_failure(case: _ProbeCase, stock, flipped): + """The reason ``case`` fails the load-bearing walk, or ``None``. + + The stock dialect's outcome must follow its own probe answer: rendered + when the probe accepts the option, refused (naming it) when it does not. + The flipped dialect must then answer the opposite. If both dialects behave + the same, the probe is decorative. + """ + stock_probe = getattr(stock, case.probe_name)() + stock_kind, stock_detail = _render_outcome(case.expression_cls, stock, case.kwargs) + flipped_kind, flipped_detail = _render_outcome( + case.expression_cls, flipped, case.kwargs + ) + expected_stock = "rendered" if stock_probe else "refused" + expected_flipped = "refused" if stock_probe else "rendered" + + if stock_kind != expected_stock: + return ( + f"{case.statement} SEQUENCE {case.option}: the stock dialect answers " + f"{case.probe_name}()={stock_probe}, so the option must be " + f"{expected_stock}; got {stock_kind}: {stock_detail!r}" + ) + if stock_kind == "refused" and case.feature not in stock_detail: + return ( + f"{case.statement} SEQUENCE {case.option}: the refusal does not name " + f"{case.feature!r}: {stock_detail!r}" + ) + if flipped_kind != expected_flipped: + return ( + f"{case.statement} SEQUENCE {case.option}: with " + f"{case.probe_name}() flipped to {not stock_probe}, the option must be " + f"{expected_flipped}; got {flipped_kind}: {flipped_detail!r}. The " + f"probe is decorative: the formatter ignores its answer." + ) + if flipped_kind == "refused" and case.feature not in flipped_detail: + return ( + f"{case.statement} SEQUENCE {case.option}: the flipped refusal does " + f"not name {case.feature!r}: {flipped_detail!r}" + ) + return None + + +class TestEverySequenceOptionProbeIsLoadBearing: + """Flipping any one sequence-option probe must change the formatter's answer. + + Each case subclasses the stock dialect with exactly one probe flipped and + renders the same expression through the same formatter. The stock dialect + answers ``False`` for ``order`` and ``owned_by``, so those cases must + refuse on the stock dialect and render on the flipped one; the other probes + answer ``True`` (or ``False`` for the IF NOT EXISTS spellings), so their + cases must flip the other way. + """ + + @pytest.mark.parametrize( + "case", + SEQUENCE_OPTION_PROBES, + ids=[case.label for case in SEQUENCE_OPTION_PROBES], + ) + def test_every_sequence_option_probe_is_load_bearing(self, dialect, case): + flipped_dialect = _flipped_dialect(case.probe_name) + failure = _load_bearing_failure(case, dialect, flipped_dialect) + assert failure is None, failure + + +class TestTheWalkCoversEveryProbe: + """The case list cannot silently shrink while the dialect grows.""" + + def test_every_declared_sequence_probe_is_walked_or_named(self): + covered = {case.probe_name for case in SEQUENCE_OPTION_PROBES} + declared = _declared_sequence_probes() + unaccounted = declared - covered - NON_OPTION_PROBES + assert not unaccounted, ( + f"sequence probes {sorted(unaccounted)} are declared but neither " + f"walked nor listed as non-option; extend the guard or say why they " + f"are outside it" + ) + # Sentinel: the walk must still see the probes that existed when it was + # written, or a shrinking MRO could hide a dropped probe. + assert covered >= _OPTION_PROBES_AT_WALK_BIRTH + + +class TestTheDefaultPolarityAlsoFollowsTheProbe: + """``cycle=False`` changes the SQL while both dialects keep rendering. + + Flipping ``supports_sequence_cycle`` must remove the ``NO CYCLE`` clause, + not just flip a refusal: this is the "different SQL" half of load-bearing, + which the rendered/refused walk alone cannot see. + """ + + @pytest.mark.parametrize( + "statement,expression_cls", + [ + ("CREATE", CreateSequenceExpression), + ("ALTER", AlterSequenceExpression), + ], + ids=["create", "alter"], + ) + def test_no_cycle_clause_follows_the_probe( + self, dialect, statement, expression_cls + ): + flipped = _flipped_dialect("supports_sequence_cycle") + stock_kind, stock_detail = _render_outcome( + expression_cls, dialect, {"cycle": False} + ) + flipped_kind, flipped_detail = _render_outcome( + expression_cls, flipped, {"cycle": False} + ) + assert stock_kind == "rendered", (statement, stock_detail) + assert flipped_kind == "rendered", (statement, flipped_detail) + stock_sql = stock_detail[0] + flipped_sql = flipped_detail[0] + assert stock_sql != flipped_sql, (statement, stock_sql) + assert "NO CYCLE" in stock_sql, (statement, stock_sql) + assert "NO CYCLE" not in flipped_sql, (statement, flipped_sql) + + +class TestTheGuardCanSeeADecorativeProbe: + """The guard is live: the pre-fix shape must fail it. + + The dialect below is the shape ``format_alter_sequence_statement`` had + before the gates existed -- ``supports_sequence_order()`` answers ``True`` + while the formatter refuses ``ORDER`` unconditionally. If the walk could + not report that as decorative, it could not certify the defect it was + written to catch. + """ + + def test_hardcoded_order_refusal_is_reported_decorative(self, dialect): + case = next(c for c in SEQUENCE_OPTION_PROBES if c.label == "alter-order") + + class HardcodedOrderDialect(SQLServerDialect): + def supports_sequence_order(self) -> bool: + return True + + def format_alter_sequence_statement(self, expr): + if expr.order is not None: + raise UnsupportedFeatureError( + self.name, + "ALTER SEQUENCE ORDER", + "hard-coded refusal, ignoring the probe", + ) + return super().format_alter_sequence_statement(expr) + + flipped = HardcodedOrderDialect(SQL_SERVER_2022) + failure = _load_bearing_failure(case, dialect, flipped) + assert failure is not None, ( + "the walk did not notice a probe the formatter ignores" + ) + assert "decorative" in failure, failure From 802bed0d8b7d45a14a8f49112150d9a5850a875c Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 7 Oct 2026 18:38:23 +0800 Subject: [PATCH 07/10] feat(sqlserver): consume the clause-parameter pairs and close the two dialect gaps Every two-spelling clause node now carries one parameter per spelling; "unspecified" is neither set and setting both raises ValueError at construction. The SQL Server formatters that read the changed fields consume the pairs, and the overrides that replace core's gated formatters carry the gates for the pairs their nodes grew. Pairs consumed, per formatter: - ALTER SEQUENCE override: cycle/no_cycle and cache/no_cache are spellable (measured accepted on 2019/2022/2025); order/no_order is refused by name because the engine has no ORDER option. - Identity formatter: cycle/no_cycle, cache/no_cache, order/no_order have no spelling in IDENTITY(seed, increment); each explicit spelling is refused by name, and an unset pair renders the bare property. - Set operation: all_ is rendered; distinct is refused by name (T-SQL has no DISTINCT keyword in set operations; measured refused). - TRUNCATE / DROP VIEW / DROP SCHEMA overrides: cascade/restrict and restart_identity/continue_identity are refused by name instead of silently dropped. - Constraint formatters (table, column, table-TYPE): deferrable/not_deferrable, initially_deferred/initially_immediate, enforced/not_enforced are gated on the existing probes and refused by name. - Transaction formatters: deferrable/not_deferrable refused by name (BEGIN TRANSACTION DEFERRABLE parses as a transaction *name*, measured). - The table-TYPE formatter's is-not-None field scan, which the bool defaults turned into an always-refuse, is replaced with per-spelling consumption. Dialect-specific gaps: - SQLServerAlterColumn: Optional[bool] not_null becomes not_null/nullable (NOT NULL / NULL); both set raises ValueError. Measured: unspecified ALTER COLUMN c INT is accepted and leaves the column NULLable; both explicit spellings are accepted and take effect. - SQLServerColumnstoreIndexExpression: Optional[bool] clustered becomes clustered/nonclustered (CLUSTERED / NONCLUSTERED); both set raises ValueError. Measured: the bare keyword requires a key list and the server records that form as NONCLUSTERED, so validate() now allows columns when neither keyword is set; the clustered form still forbids them. Probes: none of the new core probes (supports_schema_restrict, supports_restrict_view, supports_drop_function_restrict, supports_materialized_view_restrict, supports_truncate_restrict) is supported by SQL Server; every candidate spelling was measured refused on 2019/2022/2025 and the probes stay False. Behaviour change (intended): an explicit spelling whose probe is False now raises instead of being dropped; an unset pair renders nothing where the old encoding spelled a negative form. Guard: test_clause_pair_guard_sqlserver.py asserts the four states of every pair the dialect consumes are pairwise distinguishable (neither rendered / A rendered or refused by name / B rendered or refused by name / both refused). It was run red against the unmodified tree first (46 failing test ids) and is green after the change (86 passed); two mutations (a disabled no_cache branch, a disabled nullable branch) were each caught by the expected guard test. Tests: the pointed files (roundtrip matrix, sequence/DDL, identity, protocol conformance, sequence-probe guard, plus the formatter-touching files) pass: 1348 passed, 9 skipped; the one async test errors locally because aioodbc is not installed in the local venv (CI installs it). --- .../impl/sqlserver/alter_table_modifier.py | 5 +- .../backend/impl/sqlserver/dialect.py | 181 ++++- .../impl/sqlserver/expression/alter_column.py | 13 +- .../impl/sqlserver/expression/columnstore.py | 28 +- .../impl/sqlserver/mixins/columnstore.py | 4 +- .../backend/impl/sqlserver/mixins/ddl_type.py | 37 +- .../backend/impl/sqlserver/mixins/ddl_view.py | 16 + .../impl/sqlserver/mixins/identity_column.py | 32 +- .../backend/impl/sqlserver/mixins/schema.py | 17 +- .../impl/sqlserver/mixins/set_operation.py | 17 +- .../impl/sqlserver/mixins/transaction.py | 28 +- .../test_clause_pair_guard_sqlserver.py | 627 ++++++++++++++++++ .../backend/test_dialect_comprehensive.py | 4 +- .../backend/test_dialect_version_branches.py | 2 +- .../backend/test_identity_clause_sqlserver.py | 55 +- .../test_identity_execution_confirmation.py | 4 +- .../backend/test_p1_coverage_features.py | 20 +- .../backend/test_sequence_probes_sqlserver.py | 37 +- 18 files changed, 1041 insertions(+), 86 deletions(-) create mode 100644 tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_clause_pair_guard_sqlserver.py diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/alter_table_modifier.py b/src/rhosocial/activerecord/backend/impl/sqlserver/alter_table_modifier.py index 3fdd5fd..4831be5 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/alter_table_modifier.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/alter_table_modifier.py @@ -122,8 +122,9 @@ def format_alter_column_type_action(self, action: Any) -> Tuple[str, tuple]: if collate: parts.append(f"COLLATE {collate}") - not_null = getattr(action, "not_null", None) - if not_null is not None: + not_null = getattr(action, "not_null", False) + nullable = getattr(action, "nullable", False) + if not_null or nullable: parts.append("NOT NULL" if not_null else "NULL") if getattr(action, "sparse", False): diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index a6bd954..49068b1 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -796,6 +796,63 @@ def format_column_definition( constraint_parts.append(f"CHECK ({check_sql})") params.extend(check_params) + # The node's two-spelling constraint attributes are consumed here + # even though SQL Server cannot spell any of them: this override + # replaces core's column formatter, so an explicitly requested + # attribute is refused by name rather than silently dropped. + # Measured refused on 2019 / 2022 / 2025. + from rhosocial.activerecord.backend.dialect.mixins.ddl_table import ( + normalize_column_constraint_type, + ) + + if constraint.enforced or constraint.not_enforced: + normalized = normalize_column_constraint_type( + constraint.constraint_type + ) + if normalized not in { + ColumnConstraintType.CHECK, + ColumnConstraintType.FOREIGN_KEY, + }: + raise ValueError( + "ENFORCED/NOT ENFORCED is only valid for CHECK and " + "FOREIGN KEY constraints" + ) + spelling = "ENFORCED" if constraint.enforced else "NOT ENFORCED" + if not self.supports_constraint_enforced(): + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + raise UnsupportedFeatureError( + self.name, + f"constraint {spelling}", + f"{self.name} does not support the {spelling} constraint.", + ) + constraint_parts.append(spelling) + if constraint.deferrable or constraint.not_deferrable: + spelling = ( + "DEFERRABLE" if constraint.deferrable else "NOT DEFERRABLE" + ) + if not self.supports_deferrable_constraint(): + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + raise UnsupportedFeatureError( + self.name, + f"constraint {spelling}", + f"{self.name} does not support the {spelling} constraint.", + ) + constraint_parts.append(spelling) + if constraint.initially_deferred or constraint.initially_immediate: + spelling = ( + "INITIALLY DEFERRED" + if constraint.initially_deferred + else "INITIALLY IMMEDIATE" + ) + if not self.supports_deferrable_constraint(): + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + raise UnsupportedFeatureError( + self.name, + f"constraint {spelling}", + f"{self.name} does not support the {spelling} constraint.", + ) + constraint_parts.append(spelling) + if constraint.is_auto_increment: constraint_parts.append("IDENTITY(1,1)") @@ -891,6 +948,59 @@ def format_table_constraint( parts.append(f"CHECK ({check_sql})") params.extend(check_params) + # The node's two-spelling constraint attributes are consumed here even + # though SQL Server cannot spell any of them: this override replaces + # core's formatter, so an explicitly requested attribute is refused by + # name rather than silently dropped. Measured refused on 2019 / 2022 / + # 2025 (ENFORCED, NOT ENFORCED, DEFERRABLE, NOT DEFERRABLE, INITIALLY + # DEFERRED, INITIALLY IMMEDIATE). The probes gate the refusals, and the + # spelling is rendered when a probe answers True, so neither is + # decorative. + from rhosocial.activerecord.backend.dialect.mixins.ddl_table import ( + normalize_table_constraint_type, + ) + + normalized_type = normalize_table_constraint_type(t_const.constraint_type) + if t_const.enforced or t_const.not_enforced: + if normalized_type not in { + TableConstraintType.CHECK, + TableConstraintType.FOREIGN_KEY, + }: + raise ValueError( + "ENFORCED/NOT ENFORCED is only valid for CHECK and " + "FOREIGN KEY constraints" + ) + spelling = "ENFORCED" if t_const.enforced else "NOT ENFORCED" + if not self.supports_constraint_enforced(): + raise UnsupportedFeatureError( + self.name, + f"constraint {spelling}", + f"{self.name} does not support the {spelling} constraint.", + ) + parts.append(spelling) + if t_const.deferrable or t_const.not_deferrable: + spelling = "DEFERRABLE" if t_const.deferrable else "NOT DEFERRABLE" + if not self.supports_deferrable_constraint(): + raise UnsupportedFeatureError( + self.name, + f"constraint {spelling}", + f"{self.name} does not support the {spelling} constraint.", + ) + parts.append(spelling) + if t_const.initially_deferred or t_const.initially_immediate: + spelling = ( + "INITIALLY DEFERRED" + if t_const.initially_deferred + else "INITIALLY IMMEDIATE" + ) + if not self.supports_deferrable_constraint(): + raise UnsupportedFeatureError( + self.name, + f"constraint {spelling}", + f"{self.name} does not support the {spelling} constraint.", + ) + parts.append(spelling) + return ' '.join(parts), tuple(params) def format_inline_index(self, idx_def: "IndexDefinition") -> Tuple[str, tuple]: @@ -1037,6 +1147,13 @@ def format_alter_sequence_statement(self, expr: "AlterSequenceExpression") -> Tu than on the CREATE-side :meth:`supports_sequence_start`; the two clauses are different and only the former decides this statement. + Each two-spelling option carries one parameter per spelling -- + ``cycle`` / ``no_cycle``, ``cache`` / ``no_cache``, ``order`` / + ``no_order``. The parameter selects the spelling; the probe answers + whether the dialect can express the option at all. An unset pair + renders nothing, and an explicit spelling whose probe is ``False`` + raises naming the requested spelling. + Raises: TypeError: ``expr.sequence`` is not a Sequence. A table handed here would render as a well-formed ``ALTER SEQUENCE`` over that @@ -1091,17 +1208,18 @@ def format_alter_sequence_statement(self, expr: "AlterSequenceExpression") -> Tu f"{self.name} does not support the MAXVALUE sequence option." ) parts.append(f"MAXVALUE {expr.maxvalue}") - if expr.cycle is not None: - if expr.cycle: - if not self.supports_sequence_cycle(): - raise UnsupportedFeatureError( - self.name, "ALTER SEQUENCE CYCLE", - f"{self.name} does not support the CYCLE sequence option." - ) - parts.append("CYCLE") - elif self.supports_sequence_cycle(): - # NO CYCLE is the default; only spell it where it is legal. - parts.append("NO CYCLE") + if expr.cycle or expr.no_cycle: + # One parameter per spelling: ``cycle`` selects CYCLE, ``no_cycle`` + # selects NO CYCLE, and neither renders nothing. The probe answers + # whether the option can be expressed at all; an explicit spelling + # whose probe is False is refused by name, never dropped. + if not self.supports_sequence_cycle(): + raise UnsupportedFeatureError( + self.name, "ALTER SEQUENCE CYCLE", + f"{self.name} does not support the " + f"{'CYCLE' if expr.cycle else 'NO CYCLE'} sequence option." + ) + parts.append("CYCLE" if expr.cycle else "NO CYCLE") if expr.cache is not None: if not self.supports_sequence_cache(): raise UnsupportedFeatureError( @@ -1109,11 +1227,19 @@ def format_alter_sequence_statement(self, expr: "AlterSequenceExpression") -> Tu f"{self.name} does not support the CACHE sequence option." ) parts.append(f"CACHE {expr.cache}") - if expr.order is not None: + if expr.no_cache: + if not self.supports_sequence_cache(): + raise UnsupportedFeatureError( + self.name, "ALTER SEQUENCE CACHE", + f"{self.name} does not support the NO CACHE sequence option." + ) + parts.append("NO CACHE") + if expr.order or expr.no_order: if not self.supports_sequence_order(): raise UnsupportedFeatureError( self.name, "ALTER SEQUENCE ORDER", - f"{self.name} does not support the ORDER sequence option." + f"{self.name} does not support the " + f"{'ORDER' if expr.order else 'NO ORDER'} sequence option." ) parts.append("ORDER" if expr.order else "NO ORDER") if expr.owned_by is not None: @@ -1133,18 +1259,45 @@ def format_truncate_statement(self, expr: "TruncateExpression") -> Tuple[str, tu SQL Server's TRUNCATE TABLE is a DDL operation (minimal logging) with no modifiers, so the statement renders the table object and nothing - else. + else. The node's modifier pairs (``cascade`` / ``restrict``, + ``restart_identity`` / ``continue_identity``) are still consumed: this + override replaces core's gated formatter, so an explicitly requested + modifier is refused by name -- measured refused on 2019 / 2022 / 2025 -- + rather than silently dropped. Raises: TypeError: ``expr.table`` is not a Table. A view or a sequence handed here would render as a well-formed ``TRUNCATE TABLE`` over that object's name. + UnsupportedFeatureError: If a TRUNCATE modifier SQL Server cannot + spell was requested. """ if not isinstance(expr.table, Table): raise TypeError( f"TruncateExpression.table must be a Table, " f"got {type(expr.table).__name__}" ) + if ( + expr.restart_identity or expr.continue_identity + ) and not self.supports_truncate_restart_identity(): + feature = ( + "TRUNCATE RESTART IDENTITY" + if expr.restart_identity + else "TRUNCATE CONTINUE IDENTITY" + ) + raise UnsupportedFeatureError( + self.name, feature, f"{self.name} does not support {feature}." + ) + if expr.cascade and not self.supports_truncate_cascade(): + raise UnsupportedFeatureError( + self.name, "TRUNCATE CASCADE", + f"{self.name} does not support TRUNCATE with CASCADE." + ) + if expr.restrict and not self.supports_truncate_restrict(): + raise UnsupportedFeatureError( + self.name, "TRUNCATE RESTRICT", + f"{self.name} does not support TRUNCATE with RESTRICT." + ) return f"TRUNCATE TABLE {expr.table.to_sql()[0]}", () def format_merge_statement(self, expr: "MergeExpression") -> Tuple[str, tuple]: diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/alter_column.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/alter_column.py index 8c1b0a5..811bd7d 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/alter_column.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/alter_column.py @@ -7,7 +7,8 @@ * ``data_type`` — the new type for ``ALTER COLUMN col ``. * ``collate`` — the ``COLLATE `` clause. -* ``not_null`` — ``NULL`` / ``NOT NULL`` (``None`` = omit). +* ``not_null`` / ``nullable`` — ``NOT NULL`` / ``NULL`` (one parameter per + spelling; neither renders nothing, both raises ``ValueError``). * ``sparse`` — the ``SPARSE`` storage clause. * ``masked_function`` / ``drop_masked`` — dynamic data masking. @@ -35,8 +36,8 @@ class SQLServerAlterColumn(AlterColumn): """A SQL Server ``ALTER COLUMN`` action extending the generic one. - Adds the SQL Server-only ``data_type``, ``collate``, ``not_null``, - ``sparse``, ``masked_function`` and ``drop_masked`` fields. + Adds the SQL Server-only ``data_type``, ``collate``, ``not_null`` / + ``nullable``, ``sparse``, ``masked_function`` and ``drop_masked`` fields. """ def __init__( @@ -49,11 +50,14 @@ def __init__( cascade: bool = False, data_type: Optional[str] = None, collate: Optional[str] = None, - not_null: Optional[bool] = None, + not_null: bool = False, + nullable: bool = False, sparse: bool = False, masked_function: Optional[str] = None, drop_masked: bool = False, ): + if not_null and nullable: + raise ValueError("not_null and nullable are mutually exclusive options") super().__init__( dialect, column_name, @@ -64,6 +68,7 @@ def __init__( self.data_type = data_type self.collate = collate self.not_null = not_null + self.nullable = nullable self.sparse = sparse self.masked_function = masked_function self.drop_masked = drop_masked diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/columnstore.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/columnstore.py index 13a0cf0..49b07f5 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/columnstore.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/columnstore.py @@ -11,7 +11,7 @@ ``format_create_columnstore_index_statement`` formatter. """ -from typing import Optional, Sequence, TYPE_CHECKING +from typing import Sequence, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLQueryAndParams @@ -26,10 +26,14 @@ class SQLServerColumnstoreIndexExpression(BaseExpression): index_name: Name of the columnstore index. table_name: Table on which the index is created. columns: Key columns. Required for NONCLUSTERED columnstore - (``clustered=False``), forbidden for clustered columnstore. - clustered: If ``None`` the CLUSTERED/NONCLUSTERED keyword is omitted - (defaults to a clustered columnstore); ``True`` renders - CLUSTERED, ``False`` renders NONCLUSTERED. + (``nonclustered=True``), forbidden for clustered columnstore. + The bare keyword accepts a key list too; the server records that + form as NONCLUSTERED (measured on 2019 / 2022 / 2025), but it is + the *absence* of both keywords, not a third spelling. + clustered: ``True`` renders CLUSTERED; both parameters unset omits the + keyword entirely. + nonclustered: ``True`` renders NONCLUSTERED. Setting both raises + ``ValueError``. order_columns: Optional ORDER (...) columns for ordered clustered columnstore (SQL Server 2022+). @@ -47,24 +51,30 @@ def __init__( index_name: str, table_name: str, columns: Sequence[str] = (), - clustered: Optional[bool] = None, + clustered: bool = False, + nonclustered: bool = False, order_columns: Sequence[str] = (), ): super().__init__(dialect) + if clustered and nonclustered: + raise ValueError( + "clustered and nonclustered are mutually exclusive options" + ) self.index_name = index_name self.table_name = table_name self.columns = list(columns) self.clustered = clustered + self.nonclustered = nonclustered self.order_columns = list(order_columns) def validate(self, strict: bool = True) -> None: if not strict: return - if self.clustered is False and not self.columns: + if self.nonclustered and not self.columns: raise ValueError("NONCLUSTERED columnstore requires key columns") - if self.clustered is not False and self.columns: + if self.clustered and self.columns: raise ValueError("clustered columnstore does not allow key columns") - if self.order_columns and self.clustered is False: + if self.order_columns and self.nonclustered: raise ValueError("ORDER (...) is only valid for clustered columnstore") def to_sql(self) -> SQLQueryAndParams: diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/columnstore.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/columnstore.py index 6ba5590..f527587 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/columnstore.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/columnstore.py @@ -61,7 +61,7 @@ def format_create_columnstore_index_statement( """ parts = ["CREATE"] - if expr.clustered is False: + if expr.nonclustered: self.check_feature_support( # type: ignore[attr-defined] "supports_nonclustered_columnstore", "NONCLUSTERED COLUMNSTORE INDEX", @@ -75,7 +75,7 @@ def format_create_columnstore_index_statement( "CLUSTERED COLUMNSTORE INDEX", "requires SQL Server 2014+.", ) - if expr.clustered is True: + if expr.clustered: parts.append("CLUSTERED") parts.append("COLUMNSTORE") diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_type.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_type.py index e5dfcaf..692ff32 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_type.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_type.py @@ -357,12 +357,37 @@ def _format_table_type_constraint( or constraint.foreign_key_columns ): raise ValueError("CHECK constraints cannot carry key or foreign key fields") - for field_name in ("deferrable", "initially_deferred", "validation", "enforced"): - if getattr(constraint, field_name, None) is not None: - raise UnsupportedFeatureError( - self.name, - f"TYPE table constraint option {field_name}", - ) + # The enforcement / deferrability options are two-spelling pairs on the + # node now: their defaults are ``False``, not ``None``, so an + # ``is not None`` test would refuse every constraint. Only an + # explicitly requested spelling is refused, and it is named. + if constraint.deferrable or constraint.not_deferrable: + spelling = "DEFERRABLE" if constraint.deferrable else "NOT DEFERRABLE" + raise UnsupportedFeatureError( + self.name, + f"TYPE table constraint option {spelling}", + ) + if constraint.initially_deferred or constraint.initially_immediate: + spelling = ( + "INITIALLY DEFERRED" + if constraint.initially_deferred + else "INITIALLY IMMEDIATE" + ) + raise UnsupportedFeatureError( + self.name, + f"TYPE table constraint option {spelling}", + ) + if constraint.enforced or constraint.not_enforced: + spelling = "ENFORCED" if constraint.enforced else "NOT ENFORCED" + raise UnsupportedFeatureError( + self.name, + f"TYPE table constraint option {spelling}", + ) + if constraint.validation is not None: + raise UnsupportedFeatureError( + self.name, + "TYPE table constraint option validation", + ) if constraint.constraint_type in { TableConstraintType.PRIMARY_KEY, TableConstraintType.UNIQUE, diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_view.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_view.py index 7c9dc1f..a683ecb 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_view.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_view.py @@ -91,15 +91,31 @@ def format_drop_view_statement( an older version is a no-op rather than a syntax error, which is the spelling this dialect has always produced. + SQL Server has neither ``CASCADE`` nor ``RESTRICT`` on ``DROP VIEW`` + (measured refused on 2019 / 2022 / 2025). This override replaces core's + gated formatter, so an explicitly requested behavior is refused by name + rather than silently dropped. + Raises: TypeError: ``expr.view`` is not a View. A table handed here would render as a well-formed ``DROP VIEW`` over that table's name. + UnsupportedFeatureError: If CASCADE or RESTRICT was requested. """ if not isinstance(expr.view, View): raise TypeError( f"DropViewExpression.view must be a View, " f"got {type(expr.view).__name__}" ) + if expr.cascade and not self.supports_cascade_view(): + raise UnsupportedFeatureError( + self.name, "DROP VIEW CASCADE", + f"{self.name} does not support DROP VIEW CASCADE." + ) + if expr.restrict and not self.supports_restrict_view(): + raise UnsupportedFeatureError( + self.name, "DROP VIEW RESTRICT", + f"{self.name} does not support DROP VIEW RESTRICT." + ) parts = ["DROP VIEW"] if expr.if_exists and self.supports_if_exists_view(): diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/identity_column.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/identity_column.py index 48e8f04..e47811b 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/identity_column.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/identity_column.py @@ -132,6 +132,13 @@ def format_identity_clause(self, expr: "IdentityClause") -> Tuple[str, tuple]: ``_order`` / ``_cache`` gate the sequence attributes the property cannot spell. + Each two-spelling option carries one parameter per spelling -- + ``cycle`` / ``no_cycle``, ``cache`` / ``no_cache``, ``order`` / + ``no_order``. An unset pair renders nothing; an explicit spelling whose + probe is ``False`` is refused naming the requested spelling. The + refusal never falls through to the bare property, which would be a + silent downgrade. + Args: expr: The ``IdentityClause`` carrying the identity parameters. @@ -185,26 +192,35 @@ def format_identity_clause(self, expr: "IdentityClause") -> Tuple[str, tuple]: "IDENTITY MAXVALUE", f"{self.name} does not support the MAXVALUE identity option.", # type: ignore[attr-defined] ) - if expr.cycle is not None: + if expr.cycle or expr.no_cycle: if not self.supports_identity_cycle(): raise UnsupportedFeatureError( - self.name, # type: ignore[attr-defined] + self.name, "IDENTITY CYCLE", - f"{self.name} does not support the CYCLE identity option.", # type: ignore[attr-defined] + f"{self.name} does not support the " + f"{'CYCLE' if expr.cycle else 'NO CYCLE'} identity option.", ) if expr.cache is not None: if not self.supports_identity_cache(): raise UnsupportedFeatureError( - self.name, # type: ignore[attr-defined] + self.name, + "IDENTITY CACHE", + f"{self.name} does not support the CACHE identity option.", + ) + if expr.no_cache: + if not self.supports_identity_cache(): + raise UnsupportedFeatureError( + self.name, "IDENTITY CACHE", - f"{self.name} does not support the CACHE identity option.", # type: ignore[attr-defined] + f"{self.name} does not support the NO CACHE identity option.", ) - if expr.order is not None: + if expr.order or expr.no_order: if not self.supports_identity_order(): raise UnsupportedFeatureError( - self.name, # type: ignore[attr-defined] + self.name, "IDENTITY ORDER", - f"{self.name} does not support the ORDER identity option.", # type: ignore[attr-defined] + f"{self.name} does not support the " + f"{'ORDER' if expr.order else 'NO ORDER'} identity option.", ) seed = expr.start if expr.start is not None else 1 increment = expr.increment if expr.increment is not None else 1 diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/schema.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/schema.py index 3271988..b4d0862 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/schema.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/schema.py @@ -1,6 +1,7 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/mixins/schema.py from typing import Tuple, TYPE_CHECKING +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.objects import Schema if TYPE_CHECKING: @@ -55,16 +56,30 @@ def format_drop_schema_statement(self, expr: "DropSchemaExpression") -> Tuple[st """Format DROP SCHEMA for SQL Server. SQL Server requires schema to be empty before dropping. - Does not support IF EXISTS or CASCADE. + Does not support IF EXISTS or CASCADE. Neither ``CASCADE`` nor + ``RESTRICT`` is in the T-SQL grammar (measured refused on 2019 / 2022 / + 2025); this override replaces core's gated formatter, so an explicitly + requested behavior is refused by name rather than silently dropped. Raises: TypeError: ``expr.schema`` is not a Schema. A table handed here would render as a well-formed ``DROP SCHEMA`` over that table's name. + UnsupportedFeatureError: If CASCADE or RESTRICT was requested. """ if not isinstance(expr.schema, Schema): raise TypeError( f"DropSchemaExpression.schema must be a Schema, " f"got {type(expr.schema).__name__}" ) + if expr.cascade and not self.supports_schema_cascade(): + raise UnsupportedFeatureError( + self.name, "DROP SCHEMA CASCADE", + f"{self.name} does not support DROP SCHEMA CASCADE." + ) + if expr.restrict and not self.supports_schema_restrict(): + raise UnsupportedFeatureError( + self.name, "DROP SCHEMA RESTRICT", + f"{self.name} does not support DROP SCHEMA RESTRICT." + ) return f"DROP SCHEMA {expr.schema.to_sql()[0]}", () diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/set_operation.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/set_operation.py index fb5005a..f09d817 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/set_operation.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/set_operation.py @@ -43,6 +43,7 @@ def format_set_operation_expression(self, expr: "bases.BaseExpression") -> Tuple left, right = expr.left, expr.right operation = expr.operation all_ = expr.all_ + distinct = expr.distinct alias = expr.alias order_by_clause = expr.order_by_clause limit_offset_clause = expr.limit_offset_clause @@ -57,7 +58,21 @@ def format_set_operation_expression(self, expr: "bases.BaseExpression") -> Tuple left_sql, left_params = left.to_sql() right_sql, right_params = right.to_sql() - all_str = " ALL" if all_ else "" + if all_: + all_str = " ALL" + elif distinct: + # T-SQL has no DISTINCT keyword in set operations; plain UNION is + # already the distinct form. An explicit DISTINCT request is + # refused rather than dropped -- measured refused on 2019 / 2022 / + # 2025 (``Incorrect syntax near the keyword 'DISTINCT'``). + raise UnsupportedFeatureError( + self.name, + f"{operation} DISTINCT", + f"{self.name} has no DISTINCT keyword in set operations; " + f"the DISTINCT spelling is not part of T-SQL.", + ) + else: + all_str = "" base_sql = f"{left_sql} {operation}{all_str} {right_sql}" diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/transaction.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/transaction.py index 55dc84d..ecc2edd 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/transaction.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/transaction.py @@ -1,5 +1,5 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/mixins/transaction.py -from typing import Tuple, TYPE_CHECKING +from typing import Any, Tuple, TYPE_CHECKING if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.transaction import ( @@ -31,6 +31,30 @@ def supports_deferrable_transaction(self) -> bool: """SQL Server doesn't support DEFERRABLE mode.""" return False + def _refuse_deferrable_transaction(self, expr: Any) -> None: + """Refuse an explicitly requested [NOT] DEFERRABLE transaction mode. + + SQL Server has no such mode (measured on 2019 / 2022 / 2025: the only + ``BEGIN TRANSACTION`` spelling that parses is the transaction *name*, + so ``BEGIN TRANSACTION DEFERRABLE`` names the transaction, and the + ``NOT DEFERRABLE`` form is a syntax error). A requested spelling is + refused by name rather than silently dropped. + """ + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + + params = expr.get_params() + if params.get("deferrable") or params.get("not_deferrable"): + spelling = ( + "DEFERRABLE" if params.get("deferrable") else "NOT DEFERRABLE" + ) + raise UnsupportedFeatureError( + self.name, + "DEFERRABLE transaction", + f"{self.name} does not support the {spelling} transaction mode.", + ) + def format_begin_transaction( self, expr: "BeginTransactionExpression" ) -> Tuple[str, tuple]: @@ -38,6 +62,7 @@ def format_begin_transaction( from rhosocial.activerecord.backend.errors import UnsupportedTransactionModeError from rhosocial.activerecord.backend.transaction import TransactionMode + self._refuse_deferrable_transaction(expr) params = expr.get_params() mode = params.get("mode") @@ -58,6 +83,7 @@ def format_set_transaction(self, expr: "SetTransactionExpression") -> Tuple[str, from rhosocial.activerecord.backend.errors import UnsupportedTransactionModeError from rhosocial.activerecord.backend.transaction import TransactionMode + self._refuse_deferrable_transaction(expr) params = expr.get_params() mode = params.get("mode") diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_clause_pair_guard_sqlserver.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_clause_pair_guard_sqlserver.py new file mode 100644 index 0000000..2af33e1 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_clause_pair_guard_sqlserver.py @@ -0,0 +1,627 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_clause_pair_guard_sqlserver.py +"""Guard: every two-spelling clause pair the SQL Server dialect consumes. + +The rule this file enforces (the round's rules 1/2/3/4): + +* each spellable alternative has its own parameter; +* "unspecified" is the state where none of the group's parameters is set; +* setting more than one of them is API misuse and raises ``ValueError`` at + construction time; +* no ``Optional[bool]`` tri-state and no sentinel value. + +For every clause pair the four states must be pairwise distinguishable: + +==================== ============================================= +neither parameter neither spelling rendered +parameter A A's spelling rendered (and not B's) +parameter B B's spelling rendered (and not A's) +both parameters ``ValueError`` +==================== ============================================= + +Two kinds of pair appear here, and both are checked the same way: + +* **spellable** (``AlterSequenceExpression.cycle`` and friends): the dialect + has a spelling for both sides, so the render asserts the spelling. +* **unspellable** (``IdentityClause.cycle`` and friends): the server refuses + the spelling, so an explicitly set parameter must be refused by name -- + never dropped, never rendered as invalid SQL. The refusal must name the + *requested* spelling, so state A and state B stay distinguishable. + +Which pairs the dialect consumes was read from the source, not guessed: +``format_alter_sequence_statement`` and ``format_identity_clause`` read the +sequence/identity pairs; ``format_set_operation_expression`` reads ``all_``; +the SQL Server overrides of the core TRUNCATE / DROP VIEW / DROP SCHEMA / +constraint / transaction formatters *replace* core's gated versions, so they +carry the gates for the pairs those nodes grew. + +Every "unspellable" verdict is backed by a live-server measurement (SQL Server +2019 / 2022 / 2025, ``{SQL Server}`` driver), recorded in the round's plan +directory; e.g. ``TRUNCATE TABLE t CASCADE``, ``DROP VIEW v RESTRICT``, +``UNION DISTINCT`` and ``CHECK (...) ENFORCED`` are all server-refused. + +The file is deliberately able to fail: ``TestTheGuardCanSeeADroppingFormatter`` +feeds the comparison a dialect that ignores one of the new parameters (the +shape this backend had before the round) and asserts the guard reports it. +""" + +import re + +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.core import Column +from rhosocial.activerecord.backend.expression.objects import Schema, Sequence, Table, View +from rhosocial.activerecord.backend.expression.query_sources import SetOperationExpression +from rhosocial.activerecord.backend.expression.statements.ddl_schema import ( + DropSchemaExpression, +) +from rhosocial.activerecord.backend.expression.statements.ddl_sequence import ( + AlterSequenceExpression, +) +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + ColumnConstraint, + ColumnConstraintType, + ColumnDefinition, + ForeignKeyConstraint, + IdentityClause, + TableConstraint, + TableConstraintType, +) +from rhosocial.activerecord.backend.expression.statements.ddl_truncate import ( + TruncateExpression, +) +from rhosocial.activerecord.backend.expression.statements.ddl_view import ( + DropViewExpression, +) +from rhosocial.activerecord.backend.expression.statements.dql import QueryExpression +from rhosocial.activerecord.backend.expression.transaction import ( + BeginTransactionExpression, + SetTransactionExpression, +) +from rhosocial.activerecord.backend.expression.types import IntegerType +from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect +from rhosocial.activerecord.backend.impl.sqlserver.expression.alter_column import ( + SQLServerAlterColumn, +) +from rhosocial.activerecord.backend.impl.sqlserver.expression.columnstore import ( + SQLServerColumnstoreIndexExpression, +) + + +def _dialect(): + return SQLServerDialect((16, 0, 0)) + + +def _seq(d): + return Sequence(d, "s") + + +def _table(d, name="t"): + return Table(d, name) + + +def _query(d, table="t1"): + return QueryExpression(d, select=[Column(d, "a")], from_=_table(d, table)) + + +def _union(d, **kw): + return SetOperationExpression( + d, left=_query(d, "t1"), right=_query(d, "t2"), operation="UNION", **kw + ) + + +def _predicate(d): + from rhosocial.activerecord.backend.expression.predicates import ( + ComparisonPredicate, + ) + + return ComparisonPredicate(d, "=", Column(d, "a"), Column(d, "b")) + + +def _fk(d, **kw): + return ForeignKeyConstraint( + d, + columns=["a"], + foreign_key_table=_table(d, "t2"), + foreign_key_columns=["b"], + name="fk", + **kw, + ) + + +def _check(d, **kw): + return TableConstraint( + d, + TableConstraintType.CHECK, + name="ck", + check_condition=_predicate(d), + **kw, + ) + + +def _column_check(d, **kw): + """A CHECK constraint rendered through the column-definition path. + + SQL Server's ``format_column_definition`` override walks the constraints + itself, so this is the path a user's column takes -- not the standalone + ``ColumnConstraint.to_sql()`` dispatch, which would bypass the override. + """ + return ColumnDefinition( + d, + "a", + IntegerType(d), + constraints=[ + ColumnConstraint( + d, + ColumnConstraintType.CHECK, + name="ck", + check_condition=_predicate(d), + **kw, + ) + ], + ) + + +def _alter_column(d, **kw): + return SQLServerAlterColumn(d, "c", data_type="INT", **kw) + + +def _columnstore(d, **kw): + """A columnstore index; NONCLUSTERED takes its key list, the rest do not.""" + if kw.get("nonclustered"): + kw.setdefault("columns", ["c"]) + return SQLServerColumnstoreIndexExpression(d, "ix", "t", **kw) + + +class PairCase: + """One two-spelling clause and how to render it in each state. + + ``a_error`` / ``b_error`` are ``None`` for a spellable pair, or a + ``(feature, message_fragment)`` tuple for a pair the server cannot spell: + the refusal must carry both fragments, and the fragments must differ + between A and B so the two refusals stay distinguishable. + """ + + def __init__( + self, + case_id, + builder, + a, + b, + a_pattern, + b_pattern, + a_value=True, + b_value=True, + a_error=None, + b_error=None, + neither_error=None, + ): + self.case_id = case_id + self.builder = builder + self.a = a + self.b = b + self.a_pattern = re.compile(a_pattern) + self.b_pattern = re.compile(b_pattern) + self.a_value = a_value + self.b_value = b_value + self.a_error = a_error + self.b_error = b_error + self.neither_error = neither_error + + def render(self, dialect, **kwargs): + sql, _params = self.builder(dialect, **kwargs).to_sql() + return sql + + +PAIR_CASES = ( + # --- ALTER SEQUENCE: CYCLE / CACHE are spellable, ORDER is not --- + PairCase( + "AlterSequenceExpression.cycle", + lambda d, **kw: AlterSequenceExpression(d, _seq(d), **kw), + "cycle", + "no_cycle", + r"(? dict: def _probe_values(field_name: str, param: inspect.Parameter) -> tuple: """Non-default probe values for one option field. - Both sides of the domain are probed for the types the options use today: - ``True`` and ``False`` for a flag (``NO ORDER`` is as much a request as - ``ORDER``), ``100`` and ``0`` for a number (``CACHE 0`` is as much a - request as ``CACHE 10``). A field with an unknown type fails the walk - with the reason instead of being probed with a meaningless value. + A bool option is one *spelling* of a two-spelling pair: ``True`` requests + that spelling, ``False`` is the "not requested" state, so only ``True`` is + a request worth probing. An int option takes a positive count; ``0`` is + refused at construction (``cache must be a positive integer``) and is + pinned by its own sentinel test, not probed here. A field with an unknown + type fails the walk with the reason instead of being probed with a + meaningless value. """ annotation = param.annotation if annotation is inspect.Parameter.empty: @@ -215,9 +228,9 @@ def _probe_values(field_name: str, param: inspect.Parameter) -> tuple: ) annotation = args[0] if args else annotation if annotation is bool: - return (True, False) + return (True,) if annotation is int: - return (100, 0) + return (100,) raise AssertionError( f"no probe value is known for IdentityClause option {field_name!r} " f"(type {annotation!r}); extend this walk so the new option is still " @@ -242,12 +255,15 @@ def _walk_options(dialect) -> dict: bare_sql, _ = IdentityClause(dialect).to_sql() verdicts = {} for name, param in options.items(): + # ``no_cycle`` is the NO CYCLE spelling of the ``cycle`` pair; the + # refusal names the clause ("IDENTITY CYCLE"), not the parameter. + option_name = name[3:] if name.startswith("no_") else name for value in _probe_values(name, param): clause = IdentityClause(dialect, **{name: value}) try: sql, _ = clause.to_sql() except UnsupportedFeatureError as exc: - if f"IDENTITY {name.upper()}" not in str(exc): + if f"IDENTITY {option_name.upper()}" not in str(exc): raise AssertionError( f"option {name!r}={value!r} was refused without " f"naming it: {exc}" @@ -278,10 +294,21 @@ def test_every_option_is_rendered_or_refused_by_name(self, dialect): # that only ever saw one of them could look healthy while blind. assert "refused" in verdicts.values(), verdicts assert "rendered" in verdicts.values(), verdicts - # The two fields this round added: both sides of each request refused. - for key in (("order", True), ("order", False), ("cache", 100), ("cache", 0)): + # The fields this round split into pairs: every explicit spelling is + # refused by name. + for key in ( + ("order", True), + ("no_order", True), + ("cache", 100), + ("no_cache", True), + ): assert verdicts[key] == "refused", verdicts + def test_cache_zero_is_refused_at_construction(self, dialect): + """``cache=0`` is not a spelling of NO CACHE (round rule 4).""" + with pytest.raises(ValueError, match="cache must be a positive integer"): + IdentityClause(dialect, cache=0) + def test_the_walk_rejects_a_formatter_that_drops_options(self): """The walk is live: a formatter that drops an option must fail it. diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_identity_execution_confirmation.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_identity_execution_confirmation.py index 9ed8093..a5c0435 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_identity_execution_confirmation.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_identity_execution_confirmation.py @@ -197,9 +197,9 @@ def test_auto_increment_marker_is_not_rendered(self, sqlserver_backend): "identity_kwargs,feature", [ ({"order": True}, "IDENTITY ORDER"), - ({"order": False}, "IDENTITY ORDER"), + ({"no_order": True}, "IDENTITY ORDER"), ({"cache": 10}, "IDENTITY CACHE"), - ({"cache": 0}, "IDENTITY CACHE"), + ({"no_cache": True}, "IDENTITY CACHE"), ], ids=["order", "no-order", "cache", "no-cache"], ) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p1_coverage_features.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p1_coverage_features.py index 24e2800..1c5b39c 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p1_coverage_features.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p1_coverage_features.py @@ -263,7 +263,7 @@ def test_default_columnstore_omits_keyword(self, dialect): def test_nonclustered_columnstore(self, dialect): expr = SQLServerColumnstoreIndexExpression( - dialect, "ncci", "t", columns=["c"], clustered=False + dialect, "ncci", "t", columns=["c"], nonclustered=True ) sql, params = expr.to_sql() assert sql == "CREATE NONCLUSTERED COLUMNSTORE INDEX [ncci] ON [t] ([c])" @@ -281,9 +281,15 @@ def test_ordered_clustered_columnstore(self, dialect): def test_ncci_validation(self, dialect): with pytest.raises(ValueError): SQLServerColumnstoreIndexExpression( - dialect, "ncci", "t", clustered=False + dialect, "ncci", "t", nonclustered=True ).validate() + def test_clustered_and_nonclustered_are_mutually_exclusive(self, dialect): + with pytest.raises(ValueError, match="clustered and nonclustered"): + SQLServerColumnstoreIndexExpression( + dialect, "cci", "t", clustered=True, nonclustered=True + ) + def test_cci_forbids_columns(self, dialect): with pytest.raises(ValueError): SQLServerColumnstoreIndexExpression( @@ -292,7 +298,7 @@ def test_cci_forbids_columns(self, dialect): def test_ncci_gate_below_2012(self): d = SQLServerDialect(SQL_SERVER_2008) - expr = SQLServerColumnstoreIndexExpression(d, "ncci", "t", columns=["c"], clustered=False) + expr = SQLServerColumnstoreIndexExpression(d, "ncci", "t", columns=["c"], nonclustered=True) with pytest.raises(UnsupportedFeatureError) as exc_info: expr.to_sql() assert exc_info.value.feature_name == "NONCLUSTERED COLUMNSTORE INDEX" @@ -300,7 +306,7 @@ def test_ncci_gate_below_2012(self): def test_ncci_supported_on_2012(self): d = SQLServerDialect(SQL_SERVER_2012) - expr = SQLServerColumnstoreIndexExpression(d, "ncci", "t", columns=["c"], clustered=False) + expr = SQLServerColumnstoreIndexExpression(d, "ncci", "t", columns=["c"], nonclustered=True) assert expr.to_sql()[0] == ( "CREATE NONCLUSTERED COLUMNSTORE INDEX [ncci] ON [t] ([c])" ) @@ -367,9 +373,13 @@ def test_alter_column_type(self, dialect): ) def test_alter_column_type_nullable(self, dialect): - action = self._alter_column(dialect, new_value="INT", not_null=False) + action = self._alter_column(dialect, new_value="INT", nullable=True) assert self._alter(dialect, action) == "ALTER TABLE [t] ALTER COLUMN [c] INT NULL" + def test_alter_column_both_nullability_spellings_are_refused(self, dialect): + with pytest.raises(ValueError, match="not_null and nullable"): + self._alter_column(dialect, new_value="INT", not_null=True, nullable=True) + def test_alter_column_type_collate(self, dialect): action = self._alter_column(dialect, new_value="VARCHAR(100)", collate="Latin1_General_CS_AS") assert self._alter(dialect, action) == ( diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sequence_probes_sqlserver.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sequence_probes_sqlserver.py index 78f1937..f0dffa6 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sequence_probes_sqlserver.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sequence_probes_sqlserver.py @@ -23,6 +23,13 @@ answer about the statement or object rather than one of its options are named in :data:`NON_OPTION_PROBES` and are outside the walk. +Round update: each two-spelling option now has one parameter per spelling +(``cycle`` / ``no_cycle``, ``order`` / ``no_order``, ``cache`` / +``no_cache``). The walk requests the explicit negative spellings through their +own parameters, and an explicit spelling whose probe is ``False`` is refused +by name rather than dropped -- which is what the ``NO CYCLE`` polarity check +asserts. + The guard is deliberately able to fail: ``test_hardcoded_order_refusal_...`` feeds the comparison a dialect whose formatter ignores a probe (the shape this method had before the gates existed) and asserts the guard reports it @@ -160,7 +167,7 @@ class _ProbeCase(NamedTuple): _ProbeCase( "alter-no-order", "ALTER", AlterSequenceExpression, "NO ORDER", "ALTER SEQUENCE ORDER", - "supports_sequence_order", {"order": False}, + "supports_sequence_order", {"no_order": True}, ), _ProbeCase( "alter-owned-by", "ALTER", AlterSequenceExpression, @@ -336,11 +343,13 @@ def test_every_declared_sequence_probe_is_walked_or_named(self): class TestTheDefaultPolarityAlsoFollowsTheProbe: - """``cycle=False`` changes the SQL while both dialects keep rendering. + """An explicit ``NO CYCLE`` follows the probe; it is never silently dropped. - Flipping ``supports_sequence_cycle`` must remove the ``NO CYCLE`` clause, - not just flip a refusal: this is the "different SQL" half of load-bearing, - which the rendered/refused walk alone cannot see. + The round's rule: the parameter selects the spelling and the probe decides + whether the dialect can express the option at all. Flipping + ``supports_sequence_cycle`` must therefore turn the rendered ``NO CYCLE`` + into a refusal that names it -- not remove the clause while still + rendering, which would silently drop the request. """ @pytest.mark.parametrize( @@ -356,18 +365,18 @@ def test_no_cycle_clause_follows_the_probe( ): flipped = _flipped_dialect("supports_sequence_cycle") stock_kind, stock_detail = _render_outcome( - expression_cls, dialect, {"cycle": False} + expression_cls, dialect, {"no_cycle": True} ) flipped_kind, flipped_detail = _render_outcome( - expression_cls, flipped, {"cycle": False} + expression_cls, flipped, {"no_cycle": True} ) assert stock_kind == "rendered", (statement, stock_detail) - assert flipped_kind == "rendered", (statement, flipped_detail) - stock_sql = stock_detail[0] - flipped_sql = flipped_detail[0] - assert stock_sql != flipped_sql, (statement, stock_sql) - assert "NO CYCLE" in stock_sql, (statement, stock_sql) - assert "NO CYCLE" not in flipped_sql, (statement, flipped_sql) + assert "NO CYCLE" in stock_detail[0], (statement, stock_detail) + assert flipped_kind == "refused", (statement, flipped_detail) + # CREATE renders through core's formatter, whose refusal names the + # option ("SEQUENCE CYCLE"); ALTER names the requested spelling too. + # Either way the request is refused, never silently dropped. + assert "CYCLE" in flipped_detail, (statement, flipped_detail) class TestTheGuardCanSeeADecorativeProbe: @@ -388,7 +397,7 @@ def supports_sequence_order(self) -> bool: return True def format_alter_sequence_statement(self, expr): - if expr.order is not None: + if expr.order or expr.no_order: raise UnsupportedFeatureError( self.name, "ALTER SEQUENCE ORDER", From 16af634654c9b68c906f32d4ca7af720c4aff63b Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 8 Oct 2026 01:14:58 +0800 Subject: [PATCH 08/10] feat(sqlserver): declare the four master probes and consume the wait pair Core at c4d6adc now consults supports_materialized_cte (the CTE MATERIALIZED hint), supports_truncate (TRUNCATE itself) and the new supports_with_data_clause (WITH [NO] DATA on CTAS / materialized view create / refresh), and added supports_transaction_wait for the wait/no_wait pair on the two transaction expressions. A dialect that leaves these probes undeclared inherits a core default; this declares each one in this backend with its measured answer. Measured (SQL Server 2019 / 2022 / 2025, ports 11433/11434/11435, {SQL Server} driver; raw output in the round's plan directory): - supports_truncate -> True: TRUNCATE TABLE accepted and empties the table; CASCADE rejected. - supports_with_data_clause -> False: SELECT ... INTO accepted, but WITH DATA / WITH NO DATA are syntax errors near 'with'; CREATE TABLE ... AS is not SQL Server syntax either. - supports_materialized_cte -> False: plain CTE accepted; AS MATERIALIZED and AS NOT MATERIALIZED are syntax errors. - supports_transaction_wait -> False: BEGIN TRANSACTION WAIT parses the word as the transaction name (sys.dm_tran_active_transactions.name reads 'WAIT'); NO WAIT, SET TRANSACTION WAIT and SET TRANSACTION NO WAIT are syntax errors. Formatter changes so each declaration is load-bearing: - format_truncate_statement consults supports_truncate() and refuses by name; the stock output is unchanged (the probe is True). - format_begin_transaction / format_set_transaction consume the wait/no_wait pair through _transaction_wait_clause: they refuse by name when the probe declines (the previous shape silently dropped the pair, e.g. no_wait rendered as a bare BEGIN TRANSACTION), and render the spelling when a subclass's probe accepts it. Behaviour change (intended): an explicitly requested wait/no_wait on SQL Server now raises instead of being dropped; every other captured state renders byte-identically (before/after diff in the plan directory). Guard: test_master_probes_guard_sqlserver.py walks all four probes for load-bearing and declaration completeness, checks the four states of the wait pair, and is able to fail (two dropping-formatter sentinels). It was run red against the unmodified tree first (20 failing ids) and is green after the change (41 passed); two mutations (truncate gate removed, wait pair dropped) were each caught by the expected guard tests. --- .../backend/impl/sqlserver/dialect.py | 5 + .../backend/impl/sqlserver/mixins/cte.py | 11 + .../impl/sqlserver/mixins/ddl_table.py | 9 + .../backend/impl/sqlserver/mixins/ddl_view.py | 12 + .../impl/sqlserver/mixins/transaction.py | 51 +- .../test_master_probes_guard_sqlserver.py | 523 ++++++++++++++++++ 6 files changed, 609 insertions(+), 2 deletions(-) create mode 100644 tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_master_probes_guard_sqlserver.py diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index 49068b1..19cfbed 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -1277,6 +1277,11 @@ def format_truncate_statement(self, expr: "TruncateExpression") -> Tuple[str, tu f"TruncateExpression.table must be a Table, " f"got {type(expr.table).__name__}" ) + if not self.supports_truncate(): + raise UnsupportedFeatureError( + self.name, "TRUNCATE", + f"{self.name} does not support TRUNCATE.", + ) if ( expr.restart_identity or expr.continue_identity ) and not self.supports_truncate_restart_identity(): diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/cte.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/cte.py index af00cba..1ab36f4 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/cte.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/cte.py @@ -12,6 +12,17 @@ def supports_recursive_cte(self) -> bool: """Recursive CTEs are supported since SQL Server 2005.""" return True + def supports_materialized_cte(self) -> bool: + """SQL Server has no ``AS [NOT] MATERIALIZED`` CTE hint. + + Measured on 2019 / 2022 / 2025 (``{SQL Server}`` driver): + ``WITH c AS (...)`` is accepted, while ``AS MATERIALIZED`` and + ``AS NOT MATERIALIZED`` are syntax errors near ``MATERIALIZED`` / + ``NOT``. An explicitly requested hint is refused by name rather than + rendered, so the declaration is never silently bypassed. + """ + return False + def supports_unconditional_cte_order_by(self) -> bool: """SQL Server prohibits ORDER BY in CTEs unless TOP/OFFSET is present.""" return False diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_table.py index 3fab1f2..ce4d507 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_table.py @@ -17,6 +17,15 @@ class SQLServerTableMixin: """SQL Server table DDL capability declarations.""" + def supports_truncate(self) -> bool: + """SQL Server supports ``TRUNCATE TABLE``. + + Measured accepted on 2019 / 2022 / 2025 (``{SQL Server}`` driver) and + the table is emptied; the modifier pair (``CASCADE`` / ``RESTRICT``) is + refused by the formatter, not by this master probe. + """ + return True + def supports_if_exists_table(self) -> bool: """SQL Server supports DROP TABLE IF EXISTS (2016+).""" return self.version >= SQL_SERVER_2016 diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_view.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_view.py index a683ecb..e12b54a 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_view.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_view.py @@ -34,6 +34,18 @@ def supports_if_exists_view(self) -> bool: """SQL Server supports DROP VIEW IF EXISTS (2016+).""" return self.version >= SQL_SERVER_2016 + def supports_with_data_clause(self) -> bool: + """SQL Server has no ``WITH [NO] DATA`` population clause. + + Measured on 2019 / 2022 / 2025 (``{SQL Server}`` driver): + ``SELECT ... INTO
`` -- SQL Server's CTAS spelling -- is + accepted, while the same statement with ``WITH DATA`` or + ``WITH NO DATA`` is a syntax error near ``with``, and + ``CREATE TABLE ... AS`` is not SQL Server syntax at all. A requested + clause is refused by name rather than emitted. + """ + return False + def supports_view_check_option(self) -> bool: """SQL Server supports WITH CHECK OPTION.""" return True diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/transaction.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/transaction.py index ecc2edd..6a21b0c 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/transaction.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/transaction.py @@ -31,6 +31,45 @@ def supports_deferrable_transaction(self) -> bool: """SQL Server doesn't support DEFERRABLE mode.""" return False + def supports_transaction_wait(self) -> bool: + """SQL Server has no ``WAIT`` / ``NO WAIT`` transaction clause. + + Measured on 2019 / 2022 / 2025 (``{SQL Server}`` driver): + ``BEGIN TRANSACTION WAIT`` is accepted, but the word is taken as the + transaction *name* (``sys.dm_tran_active_transactions.name`` reads + ``WAIT``; any control word behaves the same), ``BEGIN TRANSACTION + NO WAIT`` is a syntax error, and ``SET TRANSACTION WAIT`` / + ``SET TRANSACTION NO WAIT`` are syntax errors. An explicitly requested + spelling is refused by name rather than silently dropped. + """ + return False + + def _transaction_wait_clause(self, expr: Any) -> str: + """Consume the ``wait`` / ``no_wait`` pair for one transaction expression. + + Returns the spelling when the probe accepts it (so a subclass with a + real lock-wait clause can render it), or refuses the requested spelling + by name when the probe declines. Never returns a spelling the probe + did not approve. + """ + params = expr.get_params() + wait = params.get("wait") + no_wait = params.get("no_wait") + if not (wait or no_wait): + return "" + spelling = "WAIT" if wait else "NO WAIT" + if not self.supports_transaction_wait(): + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + + raise UnsupportedFeatureError( + self.name, + f"transaction {spelling}", + f"{self.name} does not support the {spelling} transaction clause.", + ) + return spelling + def _refuse_deferrable_transaction(self, expr: Any) -> None: """Refuse an explicitly requested [NOT] DEFERRABLE transaction mode. @@ -63,6 +102,7 @@ def format_begin_transaction( from rhosocial.activerecord.backend.transaction import TransactionMode self._refuse_deferrable_transaction(expr) + wait_clause = self._transaction_wait_clause(expr) params = expr.get_params() mode = params.get("mode") @@ -73,6 +113,8 @@ def format_begin_transaction( message="SQL Server does not support READ ONLY transactions." ) + if wait_clause: + return f"BEGIN TRANSACTION {wait_clause}", () return "BEGIN TRANSACTION", () def format_set_transaction(self, expr: "SetTransactionExpression") -> Tuple[str, tuple]: @@ -84,6 +126,7 @@ def format_set_transaction(self, expr: "SetTransactionExpression") -> Tuple[str, from rhosocial.activerecord.backend.transaction import TransactionMode self._refuse_deferrable_transaction(expr) + wait_clause = self._transaction_wait_clause(expr) params = expr.get_params() mode = params.get("mode") @@ -94,9 +137,13 @@ def format_set_transaction(self, expr: "SetTransactionExpression") -> Tuple[str, message="SQL Server does not support READ ONLY transactions.", ) + options = [] isolation_level = params.get("isolation_level") if isolation_level: level_name = self.get_isolation_level_name(isolation_level) - return f"SET TRANSACTION ISOLATION LEVEL {level_name}", () - + options.append(f"ISOLATION LEVEL {level_name}") + if wait_clause: + options.append(wait_clause) + if options: + return f"SET TRANSACTION {' '.join(options)}", () return "", () diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_master_probes_guard_sqlserver.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_master_probes_guard_sqlserver.py new file mode 100644 index 0000000..641c9bc --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_master_probes_guard_sqlserver.py @@ -0,0 +1,523 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_master_probes_guard_sqlserver.py +"""Guard: the four master probes that gate clauses SQL Server cannot spell. + +The round gated three previously-decorative master probes behind real call +sites in core (``format_cte_expression`` consults ``supports_materialized_cte``, +``format_truncate_statement`` consults ``supports_truncate``, and the new +``supports_with_data_clause`` decides ``WITH [NO] DATA`` on CTAS / materialized +view create / materialized view refresh), and added ``supports_transaction_wait`` +for the ``wait`` / ``no_wait`` pair on the two transaction expressions. A +dialect that leaves these probes undeclared inherits a core default rather than +stating its own contract. + +A probe is **load-bearing** when the formatter's decision follows its answer: +flip the probe and the rendered statement (or the refusal) flips with it. A +master probe is **declared** when a class in this backend's package answers it, +so a change to a core default cannot silently move SQL Server's contract. + +Measured answers (SQL Server 2019 / 2022 / 2025, port 11433 / 11434 / 11435, +``{SQL Server}`` driver; raw output in +``.claude/plan/2026-10-08/measure_master_probes.txt``): + +* ``supports_truncate()`` -> **True**: ``TRUNCATE TABLE dbo.t`` accepted and + empties the table; ``TRUNCATE TABLE dbo.t CASCADE`` rejected. +* ``supports_with_data_clause()`` -> **False**: ``SELECT ... INTO dbo.t`` + accepted, but the same statement with ``WITH DATA`` or ``WITH NO DATA`` is a + syntax error near ``with``; ``CREATE TABLE ... AS`` is a syntax error too. +* ``supports_materialized_cte()`` -> **False**: ``WITH c AS (...)`` accepted; + ``AS MATERIALIZED`` and ``AS NOT MATERIALIZED`` are syntax errors. +* ``supports_transaction_wait()`` -> **False**: ``BEGIN TRANSACTION WAIT`` is + accepted, but the word is taken as the transaction *name* + (``sys.dm_tran_active_transactions.name`` = ``'WAIT'``; a control name + behaves the same), ``BEGIN TRANSACTION NO WAIT``, ``SET TRANSACTION WAIT`` + and ``SET TRANSACTION NO WAIT`` are syntax errors. No lock-wait clause exists. + +Scope notes: + +* ``supports_materialized_cte`` and ``supports_with_data_clause`` are consulted + by core formatters this dialect inherits (``format_cte_expression``, + ``format_create_table_as_statement``), so the load-bearing walk flips them + through a subclass and checks the core render/refuse paths. +* Materialized view create / refresh are refused *as statements* by this + dialect before any ``WITH [NO] DATA`` gate could run; the walk therefore + covers CTAS and the focused test below documents the subsumption instead of + pretending a clause-level refusal exists for a statement that does not exist. +* ``supports_truncate`` and ``supports_transaction_wait`` are consulted by this + backend's own formatters (``format_truncate_statement``, + ``format_begin_transaction`` / ``format_set_transaction``), which is where the + pre-fix defects lived: the probes' answers could not change the behaviour. + +The guard is deliberately able to fail: ``TestTheGuardCanSeeADroppingFormatter`` +feeds the walk dialects whose formatter ignores a probe (the shape this backend +had before the round) and asserts it reports the probe decorative. +""" + +import re +from typing import Callable, NamedTuple + +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.core import Column +from rhosocial.activerecord.backend.expression.objects import MaterializedView, Table +from rhosocial.activerecord.backend.expression.query_sources import CTEExpression +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + CreateTableAsExpression, +) +from rhosocial.activerecord.backend.expression.statements.ddl_truncate import ( + TruncateExpression, +) +from rhosocial.activerecord.backend.expression.statements.ddl_view import ( + CreateMaterializedViewExpression, + RefreshMaterializedViewExpression, +) +from rhosocial.activerecord.backend.expression.statements.dql import QueryExpression +from rhosocial.activerecord.backend.expression.transaction import ( + BeginTransactionExpression, + SetTransactionExpression, +) +from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect + +#: Every declaration this guard certifies must come from this package. +BACKEND_PACKAGE = "rhosocial.activerecord.backend.impl.sqlserver." + +SQL_SERVER_2022 = (16, 0, 0) + + +def _dialect(): + return SQLServerDialect(SQL_SERVER_2022) + + +def _table(d, name="mp_guard_t"): + return Table(d, name) + + +def _query(d): + return QueryExpression(d, select=[Column(d, "a")], from_=_table(d, "s")) + + +def _declaring_class(probe_name): + """The first class in the dialect's MRO that *declares* ``probe_name``.""" + for cls in SQLServerDialect.__mro__: + if probe_name in cls.__dict__: + return cls + raise AssertionError(f"no class in the MRO declares {probe_name}()") + + +def _declaring_module(probe_name): + return _declaring_class(probe_name).__module__ + + +class MasterProbeCase(NamedTuple): + """One master probe and the clause request it decides. + + ``stock_answer`` is the measured answer, hard-coded here on purpose: it is + the source of truth and must not be read back from the dialect, or the + guard could never see a flipped default. ``feature`` is the fragment the + refusal must name; ``spelled`` is the regex the render must contain when + the probe accepts the request. + """ + + label: str + probe_name: str + stock_answer: bool + feature: str + spelled: str + build: Callable + + +MASTER_PROBE_CASES = ( + MasterProbeCase( + "truncate", + "supports_truncate", + True, + "TRUNCATE", + r"TRUNCATE TABLE", + lambda d: TruncateExpression(d, _table(d)), + ), + MasterProbeCase( + "materialized-cte", + "supports_materialized_cte", + False, + "MATERIALIZED CTE", + r"AS MATERIALIZED", + lambda d: CTEExpression(d, "c", _query(d), materialized=True), + ), + MasterProbeCase( + "not-materialized-cte", + "supports_materialized_cte", + False, + "NOT MATERIALIZED CTE", + r"AS NOT MATERIALIZED", + lambda d: CTEExpression(d, "c", _query(d), not_materialized=True), + ), + MasterProbeCase( + "ctas-with-data", + "supports_with_data_clause", + False, + "WITH DATA", + r"WITH DATA", + lambda d: CreateTableAsExpression(d, _table(d), _query(d), with_data=True), + ), + MasterProbeCase( + "ctas-no-data", + "supports_with_data_clause", + False, + "WITH NO DATA", + r"WITH NO DATA", + lambda d: CreateTableAsExpression(d, _table(d), _query(d), no_data=True), + ), + MasterProbeCase( + "begin-wait", + "supports_transaction_wait", + False, + "transaction WAIT", + r"(? bool: + return False + + with pytest.raises(UnsupportedFeatureError) as excinfo: + TruncateExpression(NoTruncateDialect(SQL_SERVER_2022), _table(_dialect())).to_sql() + assert "TRUNCATE" in str(excinfo.value), str(excinfo.value) + + +class TestTheOtherThreeFormattersConsumeTheSpelling: + """Focused checks that the request is never silently dropped.""" + + def test_plain_cte_is_not_gated_by_the_materialized_probe(self): + sql, _params = CTEExpression(_dialect(), "c", _query(_dialect())).to_sql() + assert "MATERIALIZED" not in sql, sql + + def test_ctas_without_the_clause_is_not_gated(self): + sql, _params = CreateTableAsExpression( + _dialect(), _table(_dialect()), _query(_dialect()) + ).to_sql() + assert "WITH DATA" not in sql, sql + + def test_materialized_view_statements_subsume_the_with_data_gate(self): + """CREATE / REFRESH are refused as statements; the clause gate never runs. + + A clause-level refusal for a statement the engine does not have would + be a fiction: the request is refused by the statement name instead, and + that refusal is what the caller must see (never a silent render). + """ + for builder, feature in ( + ( + lambda d: CreateMaterializedViewExpression( + d, MaterializedView(d, "mv"), _query(d), with_data=True + ), + "CREATE MATERIALIZED VIEW", + ), + ( + lambda d: RefreshMaterializedViewExpression( + d, MaterializedView(d, "mv"), with_data=True + ), + "REFRESH MATERIALIZED VIEW", + ), + ): + with pytest.raises(UnsupportedFeatureError) as excinfo: + builder(_dialect()).to_sql() + assert feature in str(excinfo.value), str(excinfo.value) + + +class TestTheGuardIsNotVacuous: + """Guards so the checks above cannot pass by accident.""" + + def test_case_list_covers_every_probe(self): + assert set(PROBE_NAMES) == { + "supports_truncate", + "supports_materialized_cte", + "supports_with_data_clause", + "supports_transaction_wait", + } + + def test_both_probe_polarities_are_walked(self): + answers = {case.stock_answer for case in MASTER_PROBE_CASES} + assert answers == {True, False}, ( + "the walk only exercises one polarity; a formatter that hard-codes " + "one outcome would pass" + ) + + def test_every_case_names_a_feature_and_a_spelling(self): + for case in MASTER_PROBE_CASES: + assert case.feature, case.label + assert case.spelled, case.label + assert callable(case.build), case.label + + +class TestTheGuardCanSeeADroppingFormatter: + """The guard is live: the pre-fix shapes must fail it. + + Each dialect below is the shape this backend had before the round for that + probe: the declaration is present (or flipped) but the formatter ignores + the probe and renders the bare statement. If the walk could not report + that as decorative, it could not certify the defect it was written to catch. + """ + + def test_truncate_ignoring_the_probe_is_reported(self): + case = next(c for c in MASTER_PROBE_CASES if c.label == "truncate") + + class DroppingTruncateDialect(SQLServerDialect): + def supports_truncate(self) -> bool: + return False + + def format_truncate_statement(self, expr): + # The pre-fix shape: no probe branch at all. + return f"TRUNCATE TABLE {expr.table.to_sql()[0]}", () + + failure = _load_bearing_failure( + case, _dialect(), DroppingTruncateDialect(SQL_SERVER_2022) + ) + assert failure is not None, ( + "the walk did not notice a formatter that ignores supports_truncate()" + ) + assert "decorative" in failure, failure + + def test_transaction_formatter_ignoring_the_wait_pair_is_reported(self): + case = next(c for c in MASTER_PROBE_CASES if c.label == "begin-wait") + + class DroppingWaitDialect(SQLServerDialect): + def supports_transaction_wait(self) -> bool: + # The declaration says yes; the formatter below ignores it. + return True + + def format_begin_transaction(self, expr): + # The pre-fix shape: the wait pair was not read at all, so no + # spelling followed the declaration. + return "BEGIN TRANSACTION", () + + failure = _load_bearing_failure( + case, _dialect(), DroppingWaitDialect(SQL_SERVER_2022) + ) + assert failure is not None, ( + "the walk did not notice a formatter that drops the wait pair" + ) + assert "does not spell" in failure or "decorative" in failure, failure From 2bf5a4249d77d1341b1170191718a319757df538 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 8 Oct 2026 03:55:57 +0800 Subject: [PATCH 09/10] ci: pin the core dependency to the merged release line The schema-object refactor these branches depend on landed on the core's release/v1.0.0.dev30 line as PR #125, so the workflow no longer needs to install the core from the feature branch. CORE_PKG now points at the release branch; the testsuite dependency stays on its own feature branch, which is not merged yet. --- .github/workflows/test.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 26dd3c6..08bbaae 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -59,7 +59,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/expression-schema-object-refactor" - name: Create test database @@ -149,7 +149,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/expression-schema-object-refactor" - name: Create test database @@ -239,7 +239,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/expression-schema-object-refactor" - name: Create test database @@ -329,7 +329,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/expression-schema-object-refactor" - name: Create test database @@ -419,7 +419,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/expression-schema-object-refactor" - name: Create test database @@ -509,7 +509,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/expression-schema-object-refactor" - name: Create test database @@ -600,7 +600,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/expression-schema-object-refactor" - name: Create test database @@ -690,7 +690,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/expression-schema-object-refactor" - name: Create test database @@ -789,7 +789,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/expression-schema-object-refactor" - name: Create test database From b789a4af3aa205d6482a13eb5f066de3d4e0369d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 8 Oct 2026 05:07:50 +0800 Subject: [PATCH 10/10] chore: add changelog fragment for PR #7 --- changelog.d/7.added.md | 1 + changelog.d/7.changed.md | 1 + changelog.d/7.fixed.md | 1 + changelog.d/7.internal.md | 1 + 4 files changed, 4 insertions(+) create mode 100644 changelog.d/7.added.md create mode 100644 changelog.d/7.changed.md create mode 100644 changelog.d/7.fixed.md create mode 100644 changelog.d/7.internal.md diff --git a/changelog.d/7.added.md b/changelog.d/7.added.md new file mode 100644 index 0000000..28a97bb --- /dev/null +++ b/changelog.d/7.added.md @@ -0,0 +1 @@ +Add the SQL Server catalogue objects and namespace support: `SQLServerTable` — a table whose `#` temporary form is a construction-time field folded into its name and identity — plus `PartitionFunction` and `PartitionScheme` (rendered through the new `format_partition_function_object`/`format_partition_scheme_object` formatters) are exported from `rhosocial.activerecord.backend.impl.sqlserver.expression`, and the dialect implements core's `NamespaceSupport` with one object protocol per catalogue kind, so a schema object carries catalog, schema and name, every level is rendered and validated against SQL Server's 128-character identifier cap, and `format_object_id_name` provides the one deliberate bracket-free spelling `OBJECT_ID` needs. diff --git a/changelog.d/7.changed.md b/changelog.d/7.changed.md new file mode 100644 index 0000000..408fd69 --- /dev/null +++ b/changelog.d/7.changed.md @@ -0,0 +1 @@ +Change how the SQL Server expressions encode two tri-state options and make every unsupported spelling fail closed: `SQLServerAlterColumn` now takes `not_null`/`nullable` (setting both raises `ValueError`), so `not_null=False` — which used to spell `NULL` — now means unspecified and callers wanting `NULL` must pass `nullable=True`; `SQLServerColumnstoreIndexExpression` now takes `clustered`/`nonclustered` (setting both raises `ValueError`), so `clustered=False` — which used to spell `NONCLUSTERED` — now means no keyword and callers wanting `NONCLUSTERED` must pass `nonclustered=True`, while `validate()` accepts key columns for the bare form the server records as NONCLUSTERED. The dialect consumes the two-spelling clause pairs (sequence `cycle`/`no_cycle` and `cache`/`no_cache`, identity `cycle`/`no_cycle`, `order`/`no_order` and `cache`/`no_cache`, set-operation `all_`/`distinct`, `TRUNCATE` and `DROP VIEW`/`DROP SCHEMA` `cascade`/`restrict` and `restart_identity`/`continue_identity`, constraint `deferrable`/`not_deferrable`, `initially_deferred`/`initially_immediate` and `enforced`/`not_enforced`, transaction `deferrable`/`not_deferrable` and `wait`/`no_wait`): an explicit spelling the dialect cannot express now raises `UnsupportedFeatureError` naming the requested spelling instead of rendering SQL that silently dropped it, and an unset pair renders nothing where the old encoding spelled a negative form. Formatters that name a catalogue object now check the object's kind, raising `TypeError` instead of rendering a well-formed statement over the wrong kind's name. diff --git a/changelog.d/7.fixed.md b/changelog.d/7.fixed.md new file mode 100644 index 0000000..88b364d --- /dev/null +++ b/changelog.d/7.fixed.md @@ -0,0 +1 @@ +Declare the SQL Server capability probes from measurement rather than inheriting core's defaults, and make them load-bearing. The sequence probes answer 2012+ for `supports_sequence`, `supports_alter_sequence` and every option the engine spells, with `supports_sequence_if_exists` at 2016+ (the deleted local `DROP SEQUENCE` renderer had gated `IF EXISTS` on 2012) and `supports_alter_sequence_start` answering `False` from its own ALTER-side question — `ALTER SEQUENCE ... START WITH` is rejected with error 11710 and `RESTART WITH` is the engine's spelling, while `ORDER` and `OWNED BY` have no spelling at all. The identity probes declare `IDENTITY(seed, increment)` (`supports_identity_column` and start/increment `True`) and answer `False` for `ALWAYS`, `MINVALUE`/`MAXVALUE`/`CYCLE`/`ORDER`/`CACHE` — `ALWAYS` had been silently downgraded to the bare property and the options dropped — with `supports_auto_increment_column` `False`. The four master probes are measured too: `supports_truncate` `True`, `supports_with_data_clause` `False`, `supports_materialized_cte` `False` and `supports_transaction_wait` `False`, whose probing trap is that `BEGIN TRANSACTION WAIT` is accepted only because the server reads `WAIT` as the transaction name. diff --git a/changelog.d/7.internal.md b/changelog.d/7.internal.md new file mode 100644 index 0000000..0136fc5 --- /dev/null +++ b/changelog.d/7.internal.md @@ -0,0 +1 @@ +Follow core's protocol package split: the dialect now declares `NamespaceSupport` plus one `*ObjectSupport` protocol per catalogue kind and one support protocol per DDL statement (`CreateTableSupport`/`DropTableSupport`/`AlterTableSupport`, `CreateSchemaSupport`/`DropSchemaSupport`, `CreateIndexSupport`/`DropIndexSupport`/`FulltextIndexSupport`, `CreateTypeSupport`/`DropTypeSupport`), with `SQLServerTableSupport` deriving from `TableObjectSupport` and `SQLServerUserDefinedTypeSupport` from `CreateTypeSupport`/`DropTypeSupport`; this backend's protocols and its CLI introspection have moved off the old `TableSupport`/`SchemaSupport`/`IndexSupport`/`FunctionSupport`/`TriggerSupport`/`SequenceSupport`/`ViewSupport` names, so custom subclasses that referenced them must move to the new ones. The local `CREATE SEQUENCE`/`DROP SEQUENCE` renderer overrides are deleted in favour of core's shared probe-gated renderers and `format_next_value_for` now takes `SQLServerNextValueForExpression` instead of `Any`; `.claude/backend_development.md` records the protocol-to-mixin mapping, and CI keeps core pinned to `release/v1.0.0.dev30` while the testsuite installs from its round branch.