diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 08bbaae..acc7785 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -60,7 +60,7 @@ 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@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@test/typed-column-contracts" - name: Create test database run: | @@ -150,7 +150,7 @@ 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@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@test/typed-column-contracts" - name: Create test database run: | @@ -240,7 +240,7 @@ 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@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@test/typed-column-contracts" - name: Create test database run: | @@ -330,7 +330,7 @@ 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@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@test/typed-column-contracts" - name: Create test database run: | @@ -420,7 +420,7 @@ 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@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@test/typed-column-contracts" - name: Create test database run: | @@ -510,7 +510,7 @@ 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@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@test/typed-column-contracts" - name: Create test database run: | @@ -601,7 +601,7 @@ 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@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@test/typed-column-contracts" - name: Create test database run: | @@ -691,7 +691,7 @@ 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@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@test/typed-column-contracts" - name: Create test database run: | @@ -790,7 +790,7 @@ 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@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@test/typed-column-contracts" - name: Create test database run: | diff --git a/README.md b/README.md index e485fa5..a7e78d9 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,11 @@ _b69f713_ - **CI-tested server versions**: 2017, 2019, 2022, 2025 - **Published**: PyPI `1.0.0.dev2` +`pyodbc` 5.3.0 ships no cp315 wheel — the source tarball only, with no +`Python :: 3.15` classifier declaring support — so a 3.15 leg would fail at +install while pip compiles the driver. Adding one awaits a `pyodbc` release, +not anything in this repository. + ## Installation ```bash diff --git a/changelog.d/8.added.md b/changelog.d/8.added.md new file mode 100644 index 0000000..bb26fae --- /dev/null +++ b/changelog.d/8.added.md @@ -0,0 +1 @@ +Implement core's ColumnTypeSupport protocol for SQL Server through SQLServerColumnTypeMixin and the SQLSERVER_COLUMN_TYPES table, an eighteen-entry answer for every common Python type — str is a StringColumn, list/dict/tuple/set/frozenset are JSONColumns (this server has no array type on any version, and a document is text in NVARCHAR(MAX) that the 2016 function family reads), uuid.UUID a UUIDColumn, enum.Enum a StringColumn and datetime.timedelta a NumericColumn (a span here is a DATEDIFF count) — with each deviation recorded against its measured 2019 / 2022 / 2025 evidence and each deliberate omission stated in the module docstring rather than left to be inferred. Also declare the SQLServerTypeSupport protocol, the stated shape of the sqlserver_-prefixed type family (whether xml is a real type here, whether BIT is a boolean, whether tinyint is signed); render the new trim node through SQLServerTrimMixin, which keeps the operand order this server had first (BOTH becomes TRIM(chars FROM x) / TRIM(x), LEADING becomes LTRIM, TRAILING becomes RTRIM) and records its compatibility-level limits instead of gating them; compose SQLServerUUIDMixin so this backend's NEWID and UNIQUEIDENTIFIER spellings win over core's single fixed spelling; declare STRING_CONCATENATION = "CONCAT", because T-SQL reads || as logical OR; and add the column-type table, node-class enumeration and table-type definition round-trip test coverage. diff --git a/changelog.d/8.changed.md b/changelog.d/8.changed.md new file mode 100644 index 0000000..67581f1 --- /dev/null +++ b/changelog.d/8.changed.md @@ -0,0 +1 @@ +Rework suggested_data_types() into a documented answer to "concepts SQL Server cannot spell, and what it stores instead": the keys are exactly the core types this dialect does not render (binary and varbinary as BlobType, interval as BigIntType, array and jsonb as JsonType, enum as VarCharType, timetz as TimeType, and uuid under this backend's own SQLServerUniqueIdentifierType, because T-SQL writes UNIQUEIDENTIFIER and the dispatch key has to be a word this backend can produce), while xml and timestamptz stay out of the table because the server has native types for them. Follow the core's renames of DateTimeColumn to TimestampColumn and DateTimeSupport to TimestampSupport, and migrate the tests to the rebuilt DDL-source accessors (column_type() / columns_type() become column_data_type() / columns_data_type()) and to building predicates, DDL check conditions and index conditions as explicit nodes now that the bare Column offers no operators. diff --git a/changelog.d/8.docs.md b/changelog.d/8.docs.md new file mode 100644 index 0000000..601cb29 --- /dev/null +++ b/changelog.d/8.docs.md @@ -0,0 +1 @@ +Record in the README why there is no Python 3.15 CI leg: pyodbc 5.3.0 ships no cp315 wheel (source tarball only, with no Python :: 3.15 classifier), so a 3.15 leg would fail at install while pip compiles the driver, and adding one awaits a pyodbc release rather than anything in this repository. diff --git a/changelog.d/8.fixed.md b/changelog.d/8.fixed.md new file mode 100644 index 0000000..db5e3be --- /dev/null +++ b/changelog.d/8.fixed.md @@ -0,0 +1 @@ +Fix sqlserver_-prefixed DataType names silently dropping out of supports_data_types(): the resolver walks the live DataType subclass tree, so a name whose own class nothing had imported yet resolved to None and vanished from the mapping — unnoticed, because every test that looked at the mapping had happened to import a SQLServer* class in its own import block first, which is exactly the load that was missing. The type-formatter registration path now imports this backend's own DataType classes before resolution runs. diff --git a/changelog.d/8.internal.md b/changelog.d/8.internal.md new file mode 100644 index 0000000..b3ac984 --- /dev/null +++ b/changelog.d/8.internal.md @@ -0,0 +1 @@ +Enumerate every core node class in the expression round-trip matrix by walking the package deterministically instead of the process-global ExpressionRegistry, whose contents depended on which sibling test modules pytest happened to import first, and pin UNMODELLED_FORMATTERS and UNCONSTRUCTIBLE in both directions so a dispatch refusal must be a row with a stated reason (SQL Server has no LPAD / RPAD / REPEAT functions, no SQL/XML feature set and no DOMAIN object) instead of passing as "unsupported". Point the CI core install at release/v1.0.0.dev30 now that typed-columns has merged there, and pin pydantic 2.12.2 / pydantic-core 2.41.4 on Python 3.13 and 3.13t, mirroring the core's pins. diff --git a/requirements-dev.txt b/requirements-dev.txt index 9d5975c..0245f28 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -5,4 +5,18 @@ pytest-asyncio pytest-cov pytest-benchmark pytest-xdist -coverage>=7.0.0 \ No newline at end of file +coverage>=7.0.0 + +# Python 3.13, including the free-threaded 3.13t build. The python_version +# marker distinguishes release lines, not GIL configuration, so these two lines +# pin both 3.13 builds. pydantic-core has no cp313t wheel after 2.41.4, and the +# newer sdists cannot be built there (PyO3 0.29 refuses the free-threaded build +# of CPython < 3.14); 2.12.2 is the last pydantic paired with 2.41.4. +# +# These mirror the same pins in rhosocial-activerecord's own dependencies, and +# they have to be restated here because this file is installed before the core +# package: resolving an unpinned `pydantic` here picks a pydantic-core whose only +# cp313t distribution is an sdist, and the build then fails on the free-threaded +# jobs before the core's pin is ever in scope. +pydantic[email]==2.12.2; python_version == '3.13' +pydantic-core==2.41.4; python_version == '3.13' diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index 19cfbed..e6fa3ca 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -73,6 +73,7 @@ SQLServerIdentitySupport, SQLServerIndexedViewSupport, SQLServerUserDefinedTypeSupport, + SQLServerTypeSupport, ) from rhosocial.activerecord.backend.dialect.mixins import ( CollationMixin, @@ -101,6 +102,7 @@ NamespaceMixin, WindowFunctionMixin, JSONMixin, + UUIDMixin, ArrayMixin, ExplainMixin, @@ -138,6 +140,9 @@ PartitionMixin, ILIKEMixin, FunctionMixin, + # Column types: the generic half, which SQLServerColumnTypeMixin below + # overrides. + ColumnTypeMixin, # 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, @@ -153,6 +158,7 @@ from .mixins.columnstore import SQLServerColumnstoreIndexMixin from .mixins.memory_optimized import SQLServerMemoryOptimizedMixin from .mixins.ddl_type import SQLServerTypeDDLMixin +from .mixins.trim import SQLServerTrimMixin from .mixins.routine import SQLServerRoutineMixin from .mixins.trigger import SQLServerTriggerDdlMixin from .mixins.protocol_support import SQLServerProtocolSupportMixin @@ -163,6 +169,8 @@ from .mixins.constraint import SQLServerConstraintMixin from .mixins.window import SQLServerWindowMixin from .mixins.json import SQLServerJSONMixin +from .mixins.column_type import SQLServerColumnTypeMixin +from .mixins.uuid import SQLServerUUIDMixin from .mixins.grouping import SQLServerGroupingMixin from .mixins.locking import SQLServerLockingMixin from .mixins.merge import SQLServerMergeMixin @@ -299,6 +307,15 @@ class SQLServerDialect( SQLServerRoutineMixin, # PROCEDURE / FUNCTION DDL (2005+) SQLServerTriggerDdlMixin, # TRIGGER DDL (2005+) SQLServerTypeDDLMixin, + # Before DDLTypeMixin: states the shape of the sqlserver_-prefixed type + # family. The implementations arrive as own-class attributes copied by + # _register_type_formatters(), so they win in the MRO regardless; what this + # position buys is that isinstance(dialect, SQLServerTypeSupport) is + # meaningful next to the mixin that carries the behaviour. + SQLServerTypeSupport, + # Before DDLColumnMixin: the backend block below comes later, so + # supports_fk_match answered for the core. Same value by coincidence. + SQLServerConstraintMixin, DDLColumnMixin, DDLTypeMixin, UserDefinedTypeMixin, @@ -308,7 +325,6 @@ class SQLServerDialect( SQLServerWindowMixin, SQLServerJSONMixin, SQLServerReturningMixin, - SQLServerConstraintMixin, SQLServerGroupingMixin, SQLServerLockingMixin, SQLServerMergeMixin, @@ -323,11 +339,21 @@ class SQLServerDialect( SQLServerDatabaseMixin, SQLServerIndexMixin, SQLServerGeneratedColumnMixin, + # Column types. The SQL Server half first: it overrides the eighteen-entry + # table, and C3 gives the earlier name priority. Nothing else in this list + # can answer it, so it may move as a unit without disturbing the order + # around it. + SQLServerColumnTypeMixin, + ColumnTypeMixin, SQLServerSetOperationMixin, SQLServerTableMixin, SQLServerTransactionMixin, SQLServerFunctionMixin, SQLServerDateTimeMixin, + # TRIM is the one of the four string nodes SQL Server spells differently. + # It must precede any core TRIM mixin so its ``format_trim_expression`` + # wins; see SQLServerTrimMixin for the mapping and its version limits. + SQLServerTrimMixin, # Global feature mixins (after SQL Server mixins so SQL Server methods win) DateTimeMixin, DQLMixin, @@ -335,6 +361,10 @@ class SQLServerDialect( CTEMixin, WindowFunctionMixin, JSONMixin, + # SQL Server's UUID spellings (NEWID, UNIQUEIDENTIFIER) must win over the + # core UUIDMixin table, which has one fixed spelling per operation. + SQLServerUUIDMixin, + UUIDMixin, ArrayMixin, ExplainMixin, @@ -1499,29 +1529,81 @@ def format_query_option_clause(self, clause: "bases.BaseExpression") -> Tuple[st # ------------------------------------------------------------------ def suggested_data_types(self) -> Dict[str, type]: - """Types SQL Server does not natively support, with best-effort replacements. - - Returns a mapping ``{: DataType class}`` for every - core type that has **no** ``format_data_type_`` on this - dialect. Keys are disjoint from ``supports_data_types()``. + """Concepts SQL Server cannot spell, and what it stores instead (D9). + + Returns a mapping ``{: DataType class}`` for every core + type this dialect does **not** render. Keys are disjoint from + ``supports_data_types()``, and every value is a class this dialect + really renders — a suggestion the backend cannot produce would trade a + clear "unsupported" for a worse one, one whose advice does not work. + + ``binary`` / ``varbinary`` + SQL Server's byte storage is ``VARBINARY``: ``VARBINARY(n)`` up to + 8000 bytes, ``VARBINARY(MAX)`` up to 2 GB, and the deprecated + ``IMAGE``. The framework models three byte concepts and only one of + them has a T-SQL word of its own, so both the fixed- and + variable-length concepts name the unbounded one — which is what + ``BlobType`` renders. A length on such a column is a fact about the + column, not about a type. + + ``interval`` + SQL Server has no interval type. What it has is ``DATEDIFF``, which + returns a **count of a datepart** and ``DATEADD``, which accepts one — + so a duration here is an integer count of a chosen unit, and the + unit has to be decided by the caller because the type does not + record it. 64 bits because ``DATEDIFF_BIG`` does. + + ``array`` + SQL Server has no array type, and the alternative it does support is + JSON: an element type is reached through ``OPENJSON`` rather than a + subscript, which is what the rest of this dialect already suggests. + + ``enum`` + SQL Server has no enum. The value is stored as text and constrained + by a ``CHECK``, so the nearest type this dialect renders is a string + column. + + ``jsonb`` + ``jsonb`` is PostgreSQL's *binary* JSON type, and the distinction is + the whole point of having it. SQL Server has no such split: a JSON + value is text in an ``NVARCHAR(MAX)`` column that ``OPENJSON`` parses + on demand, so ``jsonb`` here is exactly ``json``. + + ``timetz`` + SQL Server has no time-with-offset type — ``TIME`` carries no offset + at all, and ``DATETIMEOFFSET`` is a *datetime*. The time-of-day part + is therefore what is stored, and an offset needs a column of its own. + + ``uuid`` + SQL Server **does** have the type, under another name: + ``UNIQUEIDENTIFIER``, which is this backend's own class. It is named + here rather than rendered under the core ``uuid`` name because T-SQL + writes ``UNIQUEIDENTIFIER`` and the dispatch key has to be a word + this backend can produce. + + Note what is *not* here: ``xml`` and ``timestamptz``. Both are + rendered, because SQL Server has native types for them (``XML`` and + ``DATETIMEOFFSET``) — substituting text or a zoneless datetime for a + concept the engine implements would be the wrong answer. """ from rhosocial.activerecord.backend.expression.types import ( - UUIDType, - IntervalType, - ArrayType, - EnumType, - JsonBType, - TimestampTzType, - TimeTzType, + BigIntType, + BlobType, + JsonType, + TimeType, + VarCharType, ) + from .expression.types import SQLServerUniqueIdentifierType + return { - "uuid": UUIDType, - "interval": IntervalType, - "array": ArrayType, - "enum": EnumType, - "jsonb": JsonBType, - "timestamptz": TimestampTzType, - "timetz": TimeTzType, + "binary": BlobType, + "varbinary": BlobType, + "interval": BigIntType, + "array": JsonType, + "enum": VarCharType, + "jsonb": JsonType, + "timetz": TimeType, + "uuid": SQLServerUniqueIdentifierType, } @@ -1533,14 +1615,43 @@ def _register_type_formatters(): ``DDLTypeMixin.supports_data_types()`` find them. Also copies the ``_SQLSERVER_*`` regex class attributes needed by - ``parse_type`` at runtime. + ``parse_type`` at runtime, and imports this backend's own ``DataType`` + classes so ``supports_data_types()`` can resolve the ``sqlserver_``-prefixed + names to a class. That last part is load order, not bookkeeping: + ``_type_class_for(name)`` resolves a name by walking the live subclass tree + of ``DataType``, so a name whose class nothing has imported yet resolves to + ``None`` and drops out of the mapping silently. Nothing noticed, because + every test that looked at the mapping imported a ``SQLServer*`` class in + its own import block first — which is exactly the load that was missing. + + The private helpers — ``_refuse_unsigned_*`` and ``_check_*`` — are copied + for the same reason the formatters are: inside a copied formatter ``self`` + is the *dialect*, so a helper that stayed on the mixin would be an + ``AttributeError`` at render time rather than a refusal. The + ``_refuse_unsigned_`` prefix is the name the signedness gate has in every + backend's type mixin (PostgreSQL, Oracle, Snowflake, SQLite, ClickHouse), + and ``_check_`` is the prefix this backend's own range helpers use + (``_check_length``, ``_check_decimal_scale_sign``), so copying both keeps + this backend in step rather than inventing a second convention. + + ``type_parameter_defaults`` is copied for that same reason and is named + outright because it is a public hook with no prefix to match: it is this + backend's statement of the widths T-SQL supplies for a declaration that + named none, and both the copied formatter and the copied parser read it off + ``self``. Left on the mixin it would resolve to core's empty answer through + the inherited ``DataTypeMixin`` version, so the dialect would silently claim + this server supplies no width for anything while its own formatters said + otherwise — which is the defect this hook exists to remove. """ from .mixins.types import SQLServerTypeSupportMixin + from .expression import types as _backend_data_types # noqa: F401 for member_name in dir(SQLServerTypeSupportMixin): if (member_name.startswith("format_data_type_") or member_name.startswith("supports_data_type_") - or member_name == "parse_type"): + or member_name.startswith("_refuse_unsigned_") + or member_name.startswith("_check_") + or member_name in ("parse_type", "type_parameter_defaults")): member = getattr(SQLServerTypeSupportMixin, member_name, None) if callable(member): setattr(SQLServerDialect, member_name, member) 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 12e3d32..a2e22e3 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 @@ -20,6 +20,7 @@ # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 Column, + ComparisonPredicate, Literal, QueryExpression, ) @@ -32,7 +33,7 @@ def get_order(dialect, order_id: int): dialect, select=[Column(dialect, "id"), Column(dialect, "status"), Column(dialect, "user_id")], from_=Table(dialect, "orders"), - where=Column(dialect, "id") == Literal(dialect, order_id), + where=ComparisonPredicate(dialect, "=", Column(dialect, "id"), Literal(dialect, order_id)), ) @@ -42,7 +43,7 @@ def check_inventory(dialect, order_id: int): dialect, select=[Column(dialect, "available")], from_=Table(dialect, "inventory"), - where=Column(dialect, "order_id") == Literal(dialect, order_id), + where=ComparisonPredicate(dialect, "=", Column(dialect, "order_id"), Literal(dialect, order_id)), ) @@ -52,7 +53,7 @@ def reserve_inventory(dialect, order_id: int): dialect, select=[Column(dialect, "id"), Column(dialect, "available")], from_=Table(dialect, "inventory"), - where=Column(dialect, "order_id") == Literal(dialect, order_id), + where=ComparisonPredicate(dialect, "=", Column(dialect, "order_id"), Literal(dialect, order_id)), ) @@ -62,7 +63,7 @@ def send_notification(dialect, user_id: int, type: str): dialect, select=[Column(dialect, "id")], from_=Table(dialect, "notifications"), - where=Column(dialect, "user_id") == Literal(dialect, user_id), + where=ComparisonPredicate(dialect, "=", Column(dialect, "user_id"), Literal(dialect, user_id)), ) @@ -72,7 +73,7 @@ def process_payment(dialect, order_id: int, amount: float): dialect, select=[Column(dialect, "status"), Column(dialect, "transaction_id")], from_=Table(dialect, "payments"), - where=Column(dialect, "order_id") == Literal(dialect, order_id), + where=ComparisonPredicate(dialect, "=", Column(dialect, "order_id"), Literal(dialect, order_id)), ) @@ -82,7 +83,7 @@ def release_inventory(dialect, order_id: int): dialect, select=[Column(dialect, "id")], from_=Table(dialect, "inventory"), - where=Column(dialect, "order_id") == Literal(dialect, order_id), + where=ComparisonPredicate(dialect, "=", Column(dialect, "order_id"), Literal(dialect, order_id)), ) @@ -92,7 +93,7 @@ def create_order_record(dialect, order_id: int, user_id: int, amount: float): dialect, select=[Column(dialect, "id"), Column(dialect, "created_at")], from_=Table(dialect, "order_records"), - where=Column(dialect, "order_id") == Literal(dialect, order_id), + where=ComparisonPredicate(dialect, "=", Column(dialect, "order_id"), Literal(dialect, order_id)), ) @@ -102,7 +103,7 @@ def confirm_inventory(dialect, order_id: int): dialect, select=[Column(dialect, "id")], from_=Table(dialect, "inventory"), - where=Column(dialect, "order_id") == Literal(dialect, order_id), + where=ComparisonPredicate(dialect, "=", Column(dialect, "order_id"), Literal(dialect, order_id)), ) 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 f3b5903..40013ab 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/type.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/type.py @@ -4,7 +4,7 @@ from __future__ import annotations from enum import Enum -from typing import Any, Dict, Optional, Sequence, Union, cast +from typing import Any, List, Optional, Sequence, Union from rhosocial.activerecord.backend.expression.bases import BaseExpression from rhosocial.activerecord.backend.expression.objects import Type @@ -84,7 +84,18 @@ def __init__( class SQLServerTableTypeDefinition(TypeDefinition): - """SQL Server user-defined table TYPE definition.""" + """SQL Server user-defined table TYPE definition. + + ``constraints`` and ``table_constraints`` are two spellings of the same + list (the latter exists to match the model-level naming) and are mutually + exclusive. Both spellings reach the *same* rendering state, so the generic + introspection-based ``get_params()`` is the whole serialization story: + ``__init__`` stores the merged list under the slot belonging to the + parameter the caller actually used and leaves the other slot ``None``, so + ``get_params()`` emits ``constraints=[...], table_constraints=None`` (or + the reverse) and the reconstruction takes the same branch. Renderers read + :attr:`constraint_definitions`, never one particular spelling's slot. + """ definition_kind = "table" @@ -118,16 +129,30 @@ def __init__( if not isinstance(memory_optimized, bool): raise TypeError("memory_optimized must be a bool") self.columns = column_list - self.constraints = constraint_list - self.table_constraints = constraint_list self.indexes = index_list self.memory_optimized = memory_optimized + # Fold the merged list into the slot named by the parameter the caller + # used and leave the unused spelling at None ("not supplied"): both + # spellings are mutually exclusive, so this is what makes the generic + # get_params() round trip without an override. + if table_constraints is None: + self.constraints = constraint_list if constraints is not None else None + self.table_constraints = None + else: + self.constraints = None + self.table_constraints = constraint_list - def get_params(self) -> Dict[str, Any]: - params = cast(Dict[str, Any], super().get_params()) - params.pop("table_constraints", None) - params["constraints"] = self.constraints - return params + @property + def constraint_definitions(self) -> List[TableConstraint]: + """The declared table constraints, whichever spelling the caller used. + + ``constraints`` and ``table_constraints`` are two names for one list; + only the spelling that was passed owns the corresponding attribute. + Use this to read them back without caring which one that was. + """ + if self.constraints is not None: + return self.constraints + return self.table_constraints or [] class SQLServerClrTypeDefinition(TypeDefinition): diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/types.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/types.py index 9403575..2e29200 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/types.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/types.py @@ -5,60 +5,99 @@ attribute. Core types (inherited from ``rhosocial.activerecord.backend.expression.types``) retain their pure names and are rendered to SQL Server syntax by the dialect's ``format_data_type_`` methods. + +Every class here is anchored to the core concept it *is*: T-SQL spells some of +those concepts differently and adds a few the standards do not have, but none of +them is a different concept. The classes that exist rather than being rendered +by name alone are the ones where the written word cannot be reached from the +core type — ``NVARCHAR``/``NCHAR`` (SQL Server keeps national character data in +its own types), ``VARBINARY(n)``/``IMAGE``, ``UNIQUEIDENTIFIER``, ``TINYINT``, +``BIT`` and ``xml``. Two of the base classes below were wrong until this +round and are fixed here with the reasoning recorded: :class:`SQLServerXmlType` +and :class:`SQLServerTinyIntType`. """ -from typing import Optional, Set +from typing import Optional from rhosocial.activerecord.backend.expression.types import ( - VarCharType, - CharType, - IntegerType, BlobType, + VarBinaryType, BooleanType, + CharType, + TinyIntType, + UUIDType, + VarCharType, + XmlType, ) class SQLServerNVarCharType(VarCharType): - """SQL Server NVARCHAR type.""" + """SQL Server ``NVARCHAR(n)`` — variable-length Unicode string. + + ``NVARCHAR`` is not a spelling of ``VARCHAR``: the framework's core string + types deliberately have no character-set dimension, so the national-character + types are SQL Server's own and get their own classes here. Deriving from + :class:`VarCharType` says what they share — variable length — without + claiming they are the same storage, which they are not: ``NVARCHAR`` stores + UCS-2/UTF-16 and cannot hold characters outside it, ``VARCHAR`` follows the + database collation's code page. + """ name = "sqlserver_nvarchar" def __init__(self, dialect=None, length: Optional[int] = None): super().__init__(dialect, length=length) - @classmethod - def synonyms(cls) -> Set[str]: - return {"VarCharType"} - class SQLServerNCharType(CharType): - """SQL Server NCHAR type.""" + """SQL Server ``NCHAR(n)`` — fixed-length Unicode string, blank-padded. + + The fixed-length twin of :class:`SQLServerNVarCharType`, and the reason this + repository keeps ``CHAR`` and ``NCHAR`` apart is that both are *fixed* + length: a shorter value is space-padded to ``n`` rather than stored as + written. + """ name = "sqlserver_nchar" def __init__(self, dialect=None, length: Optional[int] = None): super().__init__(dialect, length=length) - @classmethod - def synonyms(cls) -> Set[str]: - return {"CharType"} - class SQLServerNVarCharMaxType(VarCharType): - """SQL Server NVARCHAR(MAX) type.""" + """SQL Server ``NVARCHAR(MAX)`` — variable-length Unicode string, unbounded. + + ``MAX`` is not a length: it lifts the 8000-byte cap that makes a declared + ``NVARCHAR(n)`` fixed-size. The type is therefore variable-length and + unbounded, which is exactly :class:`VarCharType` — the *unboundedness* is + what the ``MAX`` spelling records, and it is carried by this class rather + than by a length of ``None`` on the base, because ``NVARCHAR`` and + ``NVARCHAR(MAX)`` are different declarations in the catalog. + """ name = "sqlserver_nvarchar_max" def __init__(self, dialect=None): super().__init__(dialect, length=None) - @classmethod - def synonyms(cls) -> Set[str]: - return {"VarCharType", "TextType"} +class SQLServerVarBinaryType(VarBinaryType): + """SQL Server ``VARBINARY(n)`` — variable-length byte string. + + ``n`` is capped at 8000 bytes; the unbounded spelling is ``VARBINARY(MAX)`` + (:class:`SQLServerVarBinaryMaxType`). T-SQL's own default for a declared + ``VARBINARY`` is one byte, but this backend renders 255 when no length was + given, matching what it does for ``VARCHAR`` and ``NVARCHAR`` — the default + is the dialect's, not the server's, and it is visible in the DDL either way. -class SQLServerVarBinaryType(BlobType): - """SQL Server VARBINARY type.""" + Derived from :class:`VarBinaryType`, the concept whose dispatch key this + class's own name (``sqlserver_varbinary``) claims. It was previously + derived from :class:`BlobType` on the reasoning that T-SQL's byte string is + "the blob one" — but a ``VARBINARY(n)`` is *bounded* and a ``BLOB`` is not, + and ``VARBINARY(MAX)`` is a different declaration again + (:class:`SQLServerVarBinaryMaxType`). Sibling ``SQLServerVarBinaryMaxType`` + stays on ``BlobType``, which is the concept that actually has no bound. + """ name = "sqlserver_varbinary" @@ -68,80 +107,128 @@ def __init__(self, dialect=None, length: Optional[int] = None): super().__init__(dialect) self.length = length - def _type_params(self) -> tuple: - return (self.length,) - - @classmethod - def synonyms(cls) -> Set[str]: - return {"BlobType"} - + PARAMETERS = ("length",) class SQLServerVarBinaryMaxType(BlobType): - """SQL Server VARBINARY(MAX) type.""" + """SQL Server ``VARBINARY(MAX)`` — byte string up to 2 GB. + + The same storage as :class:`SQLServerVarBinaryType` with the 8000-byte cap + lifted, which is what ``BlobType`` is. Kept as its own class because the + declaration differs, and a schema diff compares declarations. + """ name = "sqlserver_varbinary_max" def __init__(self, dialect=None): super().__init__(dialect) - @classmethod - def synonyms(cls) -> Set[str]: - return {"BlobType", "TextType"} +class SQLServerImageType(BlobType): + """SQL Server ``IMAGE`` — deprecated unbounded byte string. -class SQLServerXmlType(VarCharType): - """SQL Server XML type.""" + Superseded by ``VARBINARY(MAX)`` in SQL Server 2005 and deprecated for + removal; it is still creatable on every version this backend supports, so + the type exists for reading existing schemas rather than for writing new + ones. Same storage as the ``(MAX)`` spelling, hence :class:`BlobType`. + """ - name = "sqlserver_xml" + name = "sqlserver_image" def __init__(self, dialect=None): - super().__init__(dialect, length=None) + super().__init__(dialect) - @classmethod - def synonyms(cls) -> Set[str]: - return {"VarCharType", "TextType"} +class SQLServerXmlType(XmlType): + """SQL Server ``xml`` — an XML document with optional schema collection. -class SQLServerTinyIntType(IntegerType): - """SQL Server TINYINT type.""" + **Not text.** This class used to derive from ``VarCharType`` and that was + wrong: SQL Server stores ``xml`` as an internal *binary* representation + (optionally validated against a registered XML Schema Collection) and offers + ``.nodes()`` / ``.value()`` / ``.exist()`` / ``.modify()`` over it. + ``CAST(col AS varchar)`` is the documented way to *lose* that structure, so + "it is a varchar" was not a simplification but the opposite of the truth. + Deriving from :class:`XmlType` is what makes ``isinstance(col.data_type, + XmlType)`` true for the same reason it is true on PostgreSQL. - name = "sqlserver_tinyint" + The associated XML Schema Collection is a column attribute, not part of the + type's identity, so it does not appear here. ``SQLServerXmlType`` and the + core ``XmlType`` render the same SQL under their own namespaced names. + """ + + name = "sqlserver_xml" def __init__(self, dialect=None): super().__init__(dialect) - @classmethod - def synonyms(cls) -> Set[str]: - return {"TinyIntType"} + +class SQLServerTinyIntType(TinyIntType): + """SQL Server ``TINYINT`` — **unsigned** 8-bit integer (0–255). + + This class used to derive from ``IntegerType`` and that was wrong by three + orders of magnitude: SQL Server's ``TINYINT`` holds 0…255 while ``INTEGER`` + holds -2147483648…2147483647. ``TINYINT`` is the 1-byte integer, so the + width is the class and :class:`TinyIntType` is the width; the signedness is + the ``unsigned`` field, which is fixed to ``True`` here because no T-SQL + spelling of this type is ever signed. + + ``==`` — which compares the class plus the values of its inherited + ``PARAMETERS`` — therefore separates this from :class:`IntegerType` and from + a signed :class:`TinyIntType`, which is the whole point of recording the + field rather than assuming it. + """ + + name = "sqlserver_tinyint" + + def __init__(self, dialect=None, *, spelling: str = "tinyint"): + super().__init__(dialect, unsigned=True, spelling=spelling) class SQLServerBitType(BooleanType): - """SQL Server BIT type.""" + """SQL Server ``BIT`` — a boolean: ``0``, ``1`` or ``NULL``. + + Deliberately **not** a bit string. MySQL's and PostgreSQL's ``BIT(n)`` hold + ``n`` bits and are a different concept, which is why those backends keep + their own root-level class; T-SQL has exactly one ``BIT`` type and it is a + boolean. Two consequences follow, and both are why this class is not a + bit-string type: a ``BIT`` column aggregates as ``0``/``1``/``NULL``, and + SQL Server refuses any value outside that set outright rather than + truncating it to ``n`` bits. + + Its base is :class:`BooleanType` — the concept — while + ``format_data_type_sqlserver_bit`` and ``format_data_type_boolean`` are two + names for one rendered word, because the framework models the concept and + this backend's own spelling separately. The ``spelling`` argument is the + concept's own (``boolean`` / ``bool``), not T-SQL's ``BIT``: ``BIT`` is the + backend's word for the type, and it belongs to this class's ``name``. + """ name = "sqlserver_bit" - def __init__(self, dialect=None): - super().__init__(dialect) - - @classmethod - def synonyms(cls) -> Set[str]: - return {"BooleanType"} + def __init__(self, dialect=None, *, spelling: str = "boolean"): + super().__init__(dialect, spelling=spelling) -class SQLServerImageType(BlobType): - """SQL Server IMAGE type (deprecated).""" +class SQLServerUniqueIdentifierType(UUIDType): + """SQL Server ``UNIQUEIDENTIFIER`` — a native 16-byte GUID. - name = "sqlserver_image" + The native storage for the UUID concept, so it derives from + :class:`UUIDType` rather than sitting on ``DataType``: "is this a UUID + column?" must be one question across backends, and + ``isinstance(col.data_type, UUIDType)`` has to answer it. - def __init__(self, dialect=None): - super().__init__(dialect) + It stays a class of its own because T-SQL spells the type differently, and + the ``sqlserver_uniqueidentifier`` name keeps the dispatch keys of this + backend's ``format_data_type_*`` family isolated from the others'. + ``suggested_data_types()["uuid"]`` points at it — a substitute that names a + type this dialect really renders, which is the only kind of substitute the + contract allows. + """ - @classmethod - def synonyms(cls) -> Set[str]: - return {"BlobType"} + name = "sqlserver_uniqueidentifier" __all__ = [ + "SQLServerUniqueIdentifierType", "SQLServerNVarCharType", "SQLServerNCharType", "SQLServerNVarCharMaxType", diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/functions/json.py b/src/rhosocial/activerecord/backend/impl/sqlserver/functions/json.py index 0c31b42..f48e4bd 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/functions/json.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/functions/json.py @@ -5,7 +5,7 @@ Functions: json_value, json_query, isjson, openjson, json_object, json_array """ -from typing import Union, Optional, Any, TYPE_CHECKING +from typing import Union, Optional, Any, NoReturn, TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core @@ -110,13 +110,43 @@ def json_extract( def json_unquote( dialect: "SQLServerDialect", json_val: Union[str, "bases.BaseExpression"], -) -> "core.FunctionCall": - """Creates a JSON_UNQUOTE function call (approximation). + *, + path: Optional[str] = None, + alias: Optional[str] = None, +) -> "bases.BaseExpression": + """Extract an unquoted scalar, the way SQL Server spells this. + + T-SQL has no ``JSON_UNQUOTE``. ``JSON_VALUE`` already returns a scalar + without surrounding quotes, so a path is required to have anything to + extract — hence the mandatory keyword. + + This used to emit ``JSON_UNQUOTE(...)``, which SQL Server does not have, + by bypassing the formatter that correctly refuses it. + + Args: + dialect: The SQL Server dialect. + json_val: The JSON document or column. + path: JSON path to extract, e.g. ``"$.name"``. + alias: Optional output alias. - SQL Server has no JSON_UNQUOTE; JSON_VALUE already returns scalar - values without surrounding quotes. + Returns: + A ``SQLServerJSONExtractExpression`` rendering ``JSON_VALUE``. + + Raises: + UnsupportedFeatureError: If no path is given. """ - return core.FunctionCall(dialect, "JSON_UNQUOTE", [_convert_to_expression(dialect, json_val)]) + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + from ..expression.json import SQLServerJSONExtractExpression + + if path is None: + raise UnsupportedFeatureError( + dialect.name, + "json_unquote without a path", + "SQL Server has no JSON_UNQUOTE; JSON_VALUE returns scalars " + "already unquoted, so a path is required. Use " + "json_value(dialect, doc, path).", + ) + return SQLServerJSONExtractExpression(dialect, json_val, path, alias=alias) def json_contains( @@ -124,12 +154,34 @@ def json_contains( target: Union[str, "bases.BaseExpression"], candidate: Union[str, "bases.BaseExpression"], path: Optional[str] = None, -) -> "core.FunctionCall": - """Creates a JSON_CONTAINS function call (approximation).""" - args = [_convert_to_expression(dialect, target), _convert_to_expression(dialect, candidate)] - if path is not None: - args.append(core.Literal(dialect, path)) - return core.FunctionCall(dialect, "JSON_CONTAINS", args) + *, + alias: Optional[str] = None, +) -> "bases.BaseExpression": + """Check whether a JSON array or object contains a value (2016+). + + T-SQL has no ``JSON_CONTAINS``. Membership is expressed as an + ``OPENJSON`` predicate, which the formatter already implemented + correctly; this factory used to emit ``JSON_CONTAINS(...)`` and bypass it. + + Args: + dialect: The SQL Server dialect. + target: The JSON document or column to search. + candidate: The value to look for. + path: Optional path within the document; defaults to ``$``. + alias: Optional output alias. + + Returns: + A ``SQLServerJSONContainsExpression``. + """ + from ..expression.json import SQLServerJSONContainsExpression + + return SQLServerJSONContainsExpression( + dialect, + str(target) if not isinstance(target, bases.BaseExpression) else target, + str(candidate) if not isinstance(candidate, bases.BaseExpression) else candidate, + path, + alias=alias, + ) def json_set( @@ -172,9 +224,23 @@ def json_remove( def json_type( dialect: "SQLServerDialect", json_val: Union[str, "bases.BaseExpression"], -) -> "core.FunctionCall": - """Creates a JSON_TYPE function call (approximation; SQL Server lacks one).""" - return core.FunctionCall(dialect, "JSON_TYPE", [_convert_to_expression(dialect, json_val)]) +) -> "NoReturn": + """Always refuse: T-SQL has no ``JSON_TYPE``. + + Args: + dialect: The SQL Server dialect. + json_val: The JSON document or column. + + Raises: + UnsupportedFeatureError: Always. + """ + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + raise UnsupportedFeatureError( + dialect.name, + "JSON_TYPE", + "SQL Server has no JSON_TYPE; classify values via OPENJSON.", + ) def json_valid( @@ -191,9 +257,23 @@ def json_search( search_str: str, path: Optional[str] = None, all_: bool = False, -) -> "core.FunctionCall": - """Creates a JSON_SEARCH function call (approximation; SQL Server lacks one).""" - args = [_convert_to_expression(dialect, json_doc), core.Literal(dialect, search_str)] - if path is not None: - args.append(core.Literal(dialect, path)) - return core.FunctionCall(dialect, "JSON_SEARCH", args) \ No newline at end of file +) -> "NoReturn": + """Always refuse: T-SQL has no ``JSON_SEARCH``. + + Args: + dialect: The SQL Server dialect. + json_doc: The JSON document or column. + search_str: The value to search for. + path: Optional path within the document. + all_: Whether to require every match (MySQL semantics). + + Raises: + UnsupportedFeatureError: Always. + """ + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + raise UnsupportedFeatureError( + dialect.name, + "JSON_SEARCH", + "SQL Server has no JSON_SEARCH; use OPENJSON with a LIKE predicate.", + ) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/introspector.py b/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/introspector.py index 8588b97..4a567ac 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/introspector.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/introspector.py @@ -34,6 +34,51 @@ ) from rhosocial.activerecord.backend.expression.types._base import DataType +#: ``INFORMATION_SCHEMA.COLUMNS.CHARACTER_MAXIMUM_LENGTH`` reports ``-1`` +#: for a MAX-capable column. Only these three spell that marker as a length +#: argument (``(MAX)``). ``xml`` reports ``-1`` as well but takes no length +#: argument, and the deprecated LOBs (``text``/``ntext``/``image``) report +#: their own maximum (2^31-1 / 2^30-1 / 2^31-1) rather than ``-1``, so they +#: keep the bare-word path. +_MAX_LENGTH_TYPES = frozenset({"varchar", "nvarchar", "varbinary"}) + +#: The three temporal types that take a fractional-seconds precision. The +#: catalog reports it in ``DATETIME_PRECISION``, and it is the *only* place the +#: precision lives — ``NUMERIC_PRECISION`` is NULL for all three — so before it +#: was selected every ``time(3)``/``datetime2(3)``/``datetimeoffset(3)`` column +#: rebuilt to its bare word and re-read as the *default* precision: a phantom +#: diff against the declaration that produced it, and a missed change between +#: ``time(0)`` and ``time(7)``. See the normalization note below. +#: +#: ``datetime`` (reports 3) and ``smalldatetime`` (reports 0) also carry a +#: value in ``DATETIME_PRECISION``, but those two are fixed legacy aliases +#: folded into the ``DATETIME2`` concept, so they keep the existing bare-word +#: path and are deliberately absent here. +_DATETIME_PRECISION_TYPES = frozenset({"time", "datetime2", "datetimeoffset"}) + +#: The fractional-seconds precision the server reports for a *bare* +#: ``time``/``datetime2``/``datetimeoffset`` declaration, and equally for an +#: explicit ``(7)`` one: the server cannot tell those two declarations apart, +#: so neither can this rebuild. ``(n)`` is rebuilt only when ``n`` differs +#: from this default and the bare word is rebuilt at it. +#: +#: Every number above was read off a live server, identically on SQL Server +#: 2019 (15.0.4465.1), 2022 (16.0.4250.1) and 2025 (17.0.4035.5):: +#: +#: CREATE TABLE t (t TIME, t0 TIME(0), t3 TIME(3), t7 TIME(7), +#: d DATETIME2, d3 DATETIME2(3), o DATETIMEOFFSET(3)) +#: -- INFORMATION_SCHEMA.COLUMNS.DATETIME_PRECISION +#: -- t -> 7 t0 -> 0 t3 -> 3 t7 -> 7 +#: -- d -> 7 d3 -> 3 o3 -> 3 +#: +#: The one residual this does not close: a declared explicit ``(7)`` parses to +#: a type carrying precision 7 while the rebuilt column is bare, so those two +#: still compare unequal. Closing *that* is the server-defaults question (a +#: bare word and an explicit default name one server column), which belongs +#: with the dialect's ``type_parameter_defaults`` machinery rather than this +#: catalog rebuild; it is recorded here rather than silently widened. +_DATETIME_DEFAULT_PRECISION = 7 + class SQLServerIntrospectorMixin(IntrospectorMixin): """Shared non-I/O logic for SQL Server introspectors.""" @@ -88,6 +133,7 @@ def _build_column_info_sql( c.COLUMN_NAME, c.DATA_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT, c.CHARACTER_MAXIMUM_LENGTH, c.NUMERIC_PRECISION, c.NUMERIC_SCALE, + c.DATETIME_PRECISION, c.ORDINAL_POSITION, c.COLLATION_NAME, COLUMNPROPERTY( OBJECT_ID(c.TABLE_SCHEMA + '.' + c.TABLE_NAME), @@ -259,8 +305,37 @@ def _parse_columns( max_len = row.get("CHARACTER_MAXIMUM_LENGTH") precision = row.get("NUMERIC_PRECISION") scale = row.get("NUMERIC_SCALE") - - if max_len and max_len > 0: + dt_precision = row.get("DATETIME_PRECISION") + + if max_len == -1 and data_type in _MAX_LENGTH_TYPES: + # Rebuild the ``(MAX)`` spelling the catalog marker stands + # for; ``parse_type`` reads it as the documented widening + # (``TextType`` for the character pair, + # ``SQLServerVarBinaryMaxType`` for ``varbinary``). Dropping + # the marker rebuilt a bare word, which re-read as the + # declared default width (``nvarchar`` -> 255) — a MAX + # column then compared equal to a 255 one, and against a + # declared ``NVARCHAR(MAX)`` it produced a phantom diff. + full_type = f"{data_type}(MAX)" + elif data_type in _DATETIME_PRECISION_TYPES: + # Rebuild the fractional-seconds precision the column was + # declared with, except at the server default: a bare word + # and ``(7)`` are the same column to the server, so the bare + # word is what the default rebuilds to and ``(n)`` appears + # only where the declaration was genuinely narrower. Before + # ``DATETIME_PRECISION`` was read at all, a ``time(3)`` + # column came back bare and re-read as the default + # (``TimeType(None)`` vs the declared ``TimeType(3)``), while + # ``time(0)`` and ``time(7)`` both folded to the same bare + # type and could not be told apart. The measured rows and + # the explicit-``(7)`` residual are written down on + # ``_DATETIME_DEFAULT_PRECISION``. + if (dt_precision is not None + and dt_precision != _DATETIME_DEFAULT_PRECISION): + full_type = f"{data_type}({dt_precision})" + else: + full_type = data_type + elif max_len and max_len > 0: full_type = f"{data_type}({max_len})" elif precision is not None: if scale: diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py index 5656cbd..20a908e 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py @@ -19,6 +19,8 @@ from .constraint import SQLServerConstraintMixin from .window import SQLServerWindowMixin from .json import SQLServerJSONMixin +from .column_type import SQLServerColumnTypeMixin +from .uuid import SQLServerUUIDMixin from .grouping import SQLServerGroupingMixin from .locking import SQLServerLockingMixin from .merge import SQLServerMergeMixin @@ -40,6 +42,7 @@ from .transaction import SQLServerTransactionMixin from .function import SQLServerFunctionMixin from .ddl_type import SQLServerTypeDDLMixin +from .trim import SQLServerTrimMixin __all__ = [ "SQLServerTypeSupportMixin", @@ -62,6 +65,8 @@ "SQLServerConstraintMixin", "SQLServerWindowMixin", "SQLServerJSONMixin", + "SQLServerColumnTypeMixin", + "SQLServerUUIDMixin", "SQLServerGroupingMixin", "SQLServerLockingMixin", "SQLServerMergeMixin", @@ -83,4 +88,5 @@ "SQLServerTransactionMixin", "SQLServerFunctionMixin", "SQLServerTypeDDLMixin", + "SQLServerTrimMixin", ] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/column_type.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/column_type.py new file mode 100644 index 0000000..269bf36 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/column_type.py @@ -0,0 +1,249 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/column_type.py +"""SQL Server's answer to "which column class does this Python type mean here". + +This is the **column** half of the ColumnTypeSupport protocol (the storage half +is :meth:`SQLServerDialect.suggested_data_types`), and it answers a different +question from that one. A column class says what a value can *do*; how it is +stored is the DDL layer's separate decision, and neither reads the other. Two +of SQL Server's most famous facts therefore stay **out** of the table below and +are stated here instead, so that their absence reads as a decision rather than +as an oversight: + +* **``JSON``** is not a storage type on 2019 or 2022. A document is kept in + ``NVARCHAR(MAX)`` and the *functions* are what make it a document -- + ``JSON_VALUE`` / ``JSON_QUERY`` / ``OPENJSON`` / ``ISJSON`` / ``JSON_MODIFY``, + all of which arrived with 2016 and are measured working on 2019, 2022 and 2025 + (``suggested-pairing-json.md`` §"发现" items 3 and 7). SQL Server **2025** does + have a native ``json`` word, and this backend does not model it yet: the + catalog answers ``json`` with ``CHARACTER_MAXIMUM_LENGTH = -1`` and the parser + returns ``CustomType('json')`` while ``format_data_type_json`` still renders + ``NVARCHAR(MAX)`` (fix item #22, evidence in this repository's + ``.claude/plan/2026-10-08/secondary-gaps-investigation.md`` 附录 F.4-6). None of + that changes the column answer: a document carries the same operations + wherever it is kept, and the storage spelling is + :meth:`SQLServerDialect.suggested_data_types`'s business. +* **``UNIQUEIDENTIFIER``** is native, so nothing about the ``UUID`` cell is a + substitute. What *is* unusual about it is its **order**: SQL Server sorts + ``uniqueidentifier`` by its internal byte layout, not by the canonical text + order -- measured on all three servers, ``ORDER BY u`` returns ``[2, 1, 3]`` + where the text order is ``[1, 2, 3]``, and the range predicate ``u > U2`` + matches a different set of rows for the same reason + (``suggested-pairing-uuid.md`` §"发现" item 4 and the ordering item). + ``CONVERT(varchar(36), u)`` restores the text order. That is a **rendering** + obligation on whoever writes the formatter (Phase 2b), so it is recorded + here and *not* narrowed: ordering works, it just needs a ``CONVERT`` to mean + what it means elsewhere. + +What does reach the table is SQL Server's lack of a **native array type** -- +there is none on any version, so a column cannot be declared ``INT[]`` -- and +its two storage substitutions above. ``float`` / ``Decimal`` also stop being +one generic number at the **storage** layer, because ``FLOAT`` and +``DECIMAL(p,s)`` are two families with different rounding behaviour (measured: +``ROUND(2.675, 2)`` is ``2.68`` for ``decimal`` and ``2.67`` for ``float``, +``suggested-pairing-numeric.md`` §"发现" item 1). Core's column classes make the +same split impossible to express now: :class:`NumericColumn` is one class for +every numeric width and precision -- the operations are the same whatever the +value was declared as, and the difference between a ``NUMERIC(18,4)`` and a +``DOUBLE PRECISION`` belongs to the DDL layer's ``DataType``. So both entries +answer :class:`NumericColumn` here, and the measured two-roundings fact is +carried by the DataType declaration rather than dropped. + +The table answers all eighteen of the framework's common Python types and needs +**no version gate**: the JSON route works on 2019, 2022 and 2025 alike, and the +three agree item for item. An entry left out would be indistinguishable from +one nobody thought about, so every key is present; the only permitted ``None`` +is a genuine last resort saying "this backend has no column class for that value +family", and this table reaches none of them -- SQL Server's substitutions +carry every entry. + +Evidence is the measured live-server sweep recorded in +``.claude/plan/2026-10-08/`` of the core repository +(``suggested-pairing-json.md``, ``suggested-pairing-array.md``, +``suggested-pairing-datetime.md``, ``suggested-pairing-string-enum.md``, +``suggested-pairing-uuid.md``, ``suggested-pairing-numeric.md`` §8.1 / §8.4), +against SQL Server 2019 CU32 (15.0.4465.1), 2022 CU24 (16.0.4250.1) and 2025 CU4 +(17.0.4035.5). + +One thing the sweep did **not** measure is the boolean family: the four-server +boolean probe (§7 of ``suggested-mappings.md``) ran against sqlite, MySQL, +MariaDB and PostgreSQL, and SQL Server was not in it. Since a narrowing needs a +measurement, nothing about ``BooleanColumn`` is declared -- neither +``is_true`` / ``is_false`` (T-SQL has no boolean literal to compare against) +nor the aggregate family. That is the honest state of the evidence, and it is +recorded here rather than left to be inferred from a cell that looks complete. +""" + +import datetime +import decimal +import enum +import uuid +from typing import Any, Dict, Type + +from rhosocial.activerecord.backend.dialect.mixins.column_type import ColumnTypeMixin +from rhosocial.activerecord.backend.expression.column_types import ( + BinaryColumn, + BooleanColumn, + ColumnBase, + IntegerColumn, + JSONColumn, + NumericColumn, + StringColumn, + TimestampColumn, + UUIDColumn, +) + +#: SQL Server's complete answer for every one of the framework's common Python +#: types: ``{common Python type: ColumnBase subclass}``. +#: +#: The deviations from the portable baseline are three groups, and each is a +#: fact about this server rather than a preference. +SQLSERVER_COLUMN_TYPES: Dict[Any, Type[ColumnBase]] = { + # --- numbers ---------------------------------------------------------- + # §1 makes `float` the `FLOAT(53)` family and `Decimal` the `DECIMAL(p,s)` + # family. SQL Server stores the two as distinct types and their arithmetic + # differs: a `DECIMAL` is exact and a `FLOAT` is not, and the rounding + # follows -- measured, `ROUND(2.675, 2)` gives `2.68` on a `decimal` column + # and `2.67` on a `float` one (`suggested-pairing-numeric.md` §"发现" item + # 1), where the other binary-float servers (sqlite REAL, MySQL) agree with + # SQL Server's `float`. The column *class* cannot tell the two apart + # any more: core has one NumericColumn for every numeric width and + # precision, because the operations are the same either way and the + # `NUMERIC(18,4)` / `DOUBLE PRECISION` difference is a DataType fact. So + # both entries answer NumericColumn, and the measured two-roundings fact + # rides with the DDL declaration rather than being lost. + float: NumericColumn, + decimal.Decimal: NumericColumn, + # --- booleans ---------------------------------------------------------- + # `BIT`, which takes no width (`bit(1)` is refused with error 2716 -- + # this backend's own fix for it is in `parse_type`). Storage is `BIT` + # and the column class is unaffected: `is_true` / `is_false` / `&` / `|` + # / `~` and comparison are BooleanColumn's. + bool: BooleanColumn, + # --- integers ---------------------------------------------------------- + # `INT` in the width axis (`tinyint` .. `bigint`), with `unsigned` as no + # concept at all: overflow behaviour rides with the DataType + # declaration, not with a different operation surface. The whole-number + # answer is the baseline every backend gives. + int: IntegerColumn, + # --- text and bytes ---------------------------------------------------- + # `NVARCHAR(n)`; this backend's default rendering is the Unicode dialect + # (`VARCHAR` spells as `NVARCHAR`), which is a storage decision and + # stays in `suggested_data_types`. Measured on all three versions: + # `=` / `IN`, `LIKE` / `NOT LIKE`, concatenation, 1-based substring, + # `UPPER` / `LOWER`, `TRIM` and `ORDER BY` all pass, and an over-long + # value is **refused** (a truncation error, not a silent cut). + str: StringColumn, + # `VARBINARY(n)` / `VARBINARY(MAX)`; equality, byte length, byte + # substring, concatenation and hex (`0x…`) were each measured present. + # The read-back shape and the fact that `CONCAT` on bytes returns a + # mis-decoded `str` (so `+` is the spelling) are value-layer facts. + # Note that the fixed-length `BINARY(16)` word is **not modelled as a + # type** (fix item #21, 附录 F.4-5 -- it parses to `CustomType`), which + # is a storage gap: the column answer below is unaffected by it and + # stays `BinaryColumn`. + bytes: BinaryColumn, + bytearray: BinaryColumn, + # --- date / time ------------------------------------------------------- + # `DATE`, `TIME(p)`, `DATETIME2(p)` and `DATETIMEOFFSET(p)` are four + # real storage types and TimestampColumn is the one class core has for + # all of them; core has no DateColumn / TimeColumn yet, so this is the + # shared baseline rather than a claim that the four are one type. + # Measured: comparisons, `y/m/d/h/mi/s` extraction, `DATEADD` increments + # and `datetime - datetime` (whole seconds, `DATEDIFF`) all work, and + # `DATETIME2(7)` keeps microseconds. A tz-aware `datetime` normalises + # to the same entry and both `AT TIME ZONE 'UTC'` directions were + # measured working, so nothing about tz is narrowed. + datetime.date: TimestampColumn, + datetime.time: TimestampColumn, + datetime.datetime: TimestampColumn, + # A timedelta is a number of seconds here. SQL Server has no interval + # *type* at all -- a span exists only as `DATEDIFF`, which is an + # expression -- so a stored duration has to be a count, and the value + # layer serialises to seconds because the driver refuses a bound + # `timedelta` outright (`suggested-mappings.md` §8.2 item 4). There is + # no `IntervalColumn` class yet, so this is the baseline every backend + # without an interval type gives. + datetime.timedelta: NumericColumn, + # Native `UNIQUEIDENTIFIER` (there is also a documented `char(36)` + # convention, which is a storage choice and not a column one). Measured: + # `=` / `IN` work, prefix `LIKE` works, `NEWID()` is available in a + # SELECT while `NEWSEQUENTIALID()` is a column default only, and the + # value comes back as an **upper-case str** (a value-layer converter + # item). Ordering is the documented exception -- see the module + # docstring; it is a rendering obligation, not a missing operation. + uuid.UUID: UUIDColumn, + # --- documents --------------------------------------------------------- + # `NVARCHAR(MAX)` holding JSON text, with the 2016 function family + # making it a document. The whole measured battery passes on 2019, + # 2022 and 2025 alike (`suggested-pairing-json.md` §"端级三态"): binding + # a JSON text parameter, scalar and nested path extraction + # (`JSON_VALUE` / `JSON_QUERY`), key existence (`OPENJSON` + `EXISTS`), + # array length (counting `OPENJSON` rows), validity (`ISJSON`) and + # document building. This is a **χ route** and says so: the column is + # text, so whole-column equality is a comparison of serialized text, and + # the semantically correct recipe is the count-plus-`JSON_VALUE` concat + # rather than `=` on the document (`suggested-pairing-array.md` §"发现" + # item 4a/4b). Both renderings are a Phase 2b obligation; the + # operation itself exists on every measured version, so it is not + # narrowed here. + dict: JSONColumn, + # --- arrays ------------------------------------------------------------ + # SQL Server has **no array type on any version** -- nothing can be + # declared as an array, and `format_array_expression` raises with the + # "use JSON or comma-separated values" suggestion. The operations a + # sequence carries were measured present through the JSON route on all + # three servers (`suggested-pairing-array.md` §A, the 2019 / 2022 / + # 2025 row): array length by counting `OPENJSON`, element access via + # `JSON_VALUE(doc, '$[n]')`, containment via `OPENJSON EXISTS`, unnest + # via `CROSS APPLY OPENJSON`, and both text and concatenative + # whole-column equality. `ArrayColumn`'s whole surface is + # `array_length` / `unnest` over a *native* array -- a column this + # server cannot declare -- so the suggestion follows the operations + # that actually exist. The one gap in that battery is the quantifier + # (`v = ANY(x)`), measured a **syntax error** here; it is an emulation + # (`IN (SELECT CAST([value] AS INT) FROM OPENJSON)`) rather than an + # operator, and belongs to Phase 2b. + list: JSONColumn, + # A tuple is heterogeneous and a set is de-duplicated by Python before + # it ever reaches the server; neither changes the storage family, which + # is a JSON document. + tuple: JSONColumn, + set: JSONColumn, + frozenset: JSONColumn, + # No native enum: the measured DDL is `NVARCHAR(20) CHECK (...)` with + # an illegal member refused by the CHECK (a column without the CHECK + # accepts it), `ORDER BY` in **lexicographic** order rather than + # declaration order (which is the opposite of PostgreSQL's and + # ClickHouse's native enum), and the value comes back as a `str`. + # Those are storage and value-layer facts; the operation surface -- + # equality, `IN`, `ORDER BY` -- is StringColumn's. + enum.Enum: StringColumn, +} + + +class SQLServerColumnTypeMixin(ColumnTypeMixin): + """SQL Server's full 18-entry column-type table. + + Composed into :class:`~...dialect.SQLServerDialect` **ahead of** core's + :class:`ColumnTypeMixin`, so its :meth:`suggested_column_types` is the one + a model layer reaches. Core supplies no table of its own: an entry a + dialect does not answer fails at resolution time, by name, rather than + being filled in with a guess about a server core has never seen. + + The table is a class attribute because it does not vary by version: the + measured 2019 / 2022 / 2025 sweep gives the same answer on all three. + """ + + def suggested_column_types(self) -> Dict[Any, Type[ColumnBase]]: + """The column class each common Python type means on SQL Server. + + A fresh copy per call, so a caller mutating the answer cannot corrupt + what the next lookup reads. All eighteen entries are answered with a + class: SQL Server's storage substitutions (JSON documents in + ``NVARCHAR(MAX)``, spans as counts, no native array) still carry the + operations, so this table has no last-resort ``None``. + """ + return dict(SQLSERVER_COLUMN_TYPES) + + +__all__ = ["SQLSERVER_COLUMN_TYPES", "SQLServerColumnTypeMixin"] 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 692ff32..1dd7ebf 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_type.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_type.py @@ -535,6 +535,10 @@ def _format_table_type_definition( ) parts: List[str] = [] params: List[Any] = [] + # `constraints` and `table_constraints` are two spellings of one list; + # only the one the caller passed owns its attribute, so read the merged + # accessor rather than either slot. + constraint_definitions = expr.constraint_definitions for column in expr.columns: column_sql, column_params = self._format_table_type_column( column, @@ -542,7 +546,7 @@ def _format_table_type_definition( ) parts.append(column_sql) params.extend(column_params) - for constraint in expr.constraints: + for constraint in constraint_definitions: constraint_sql, constraint_params = self._format_table_type_constraint( constraint, expr.memory_optimized, @@ -571,7 +575,7 @@ def _format_table_type_definition( TableConstraintType.PRIMARY_KEY, TableConstraintType.UNIQUE, } - for constraint in expr.constraints + for constraint in constraint_definitions ) if len(expr.indexes) + key_constraint_count > 8: raise UnsupportedFeatureError( @@ -594,7 +598,7 @@ def _format_table_type_definition( TableConstraintType.PRIMARY_KEY, TableConstraintType.UNIQUE, } - for constraint in expr.constraints + for constraint in constraint_definitions ) if not has_key_constraint and not expr.indexes: raise ValueError( diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/json.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/json.py index e346919..c8781f4 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/json.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/json.py @@ -26,7 +26,9 @@ def format_json_function_expression(self, expr: "BaseExpression") -> Tuple[str, which already returns a scalar value without surrounding quotes. Args: - expr: The JSONExpression to format. + expr: The JSON path node to format -- a + JSONDocumentExpression for ``->``, a + JSONTextExpression for ``->>``. Returns: (SQL string, params tuple). @@ -43,5 +45,15 @@ def format_json_function_expression(self, expr: "BaseExpression") -> Tuple[str, return sql, params def supports_json_table(self) -> bool: - """OPENJSON provides JSON table functionality since SQL Server 2016.""" - return self.version >= SQL_SERVER_2016 + """Always False: this backend has no JSON_TABLE path. + + The probe used to answer True on 2016+, which contradicted + ``format_json_table_expression`` — that formatter raises + unconditionally and points at OPENJSON. A caller that trusted the + probe would be told a table expression was available and then get an + exception at render time. + + OPENJSON is the supported route and it is fully implemented, through + ``SQLServerOpenJsonExpression``; it just is not JSON_TABLE. + """ + return False 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 9b5b19f..2e0d863 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/protocol_support.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/protocol_support.py @@ -21,6 +21,11 @@ class SQLServerProtocolSupportMixin: """SQL Server protocol contract implementations delegating to dialect formatters.""" + #: SQL Server reads ``||`` as logical OR, so ``CONCAT`` is the only + #: unambiguous spelling. + STRING_CONCATENATION = "CONCAT" + + def supports_output_clause(self) -> bool: """SQL Server supports the OUTPUT clause.""" return True diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/returning.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/returning.py index 899a0c5..5687b89 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/returning.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/returning.py @@ -58,7 +58,8 @@ def format_returning_clause(self, clause: "ReturningClause", default_table: str UnsupportedFeatureError: If the clause requests an alias, a wildcard, or contains a non-column expression. """ - from rhosocial.activerecord.backend.expression.core import Column, WildcardExpression + from rhosocial.activerecord.backend.expression.column_types import ColumnBase + from rhosocial.activerecord.backend.expression.core import WildcardExpression from rhosocial.activerecord.backend.expression.operators import RawSQLExpression if clause.alias: @@ -79,7 +80,7 @@ def format_returning_clause(self, clause: "ReturningClause", default_table: str "SQL Server OUTPUT requires explicit column references; '*' " "is not supported.", ) - if isinstance(expr, Column): + if isinstance(expr, ColumnBase): prefix = expr.table.upper() if expr.table else default_table col_sql = self.format_identifier(expr.name, expr.name_need_quote) expr_parts.append(f"{prefix}.{col_sql}") diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/trim.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/trim.py new file mode 100644 index 0000000..6f598b1 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/trim.py @@ -0,0 +1,77 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/trim.py +"""SQL Server's TRIM spelling: the direction is the function name. + +SQL Server's ``TRIM`` has no ANSI ``TRIM(BOTH ... FROM ...)`` form at every +compatibility level. Up to 2019 (15.x) -- and in Azure Synapse Analytics -- the +only spelling is ``TRIM([characters FROM] string)``, which always trims both +ends and puts the character set *before* the operand. SQL Server 2022 (16.x) +added the ``LEADING`` / ``TRAILING`` / ``BOTH`` keywords, but only at database +compatibility level 160, so the ANSI default form the core renderer emits is +not portable even on the newest server. The mapping therefore keeps the +operand order SQL Server had first and encodes the direction in the name: + + BOTH -> ``TRIM(chars FROM x)`` / ``TRIM(x)`` + LEADING -> ``LTRIM(x)`` / ``LTRIM(x, chars)`` + TRAILING -> ``RTRIM(x)`` / ``RTRIM(x, chars)`` + +``LTRIM`` / ``RTRIM`` accept a second, characters argument only from 2022 +(16.x) at compatibility level 160 as well; on an earlier server that form is +refused by the engine. This mixin does not attempt to version-gate: the +dialect has no mechanism for a per-expression capability switch, and the +rendering is recorded here instead. + +Everything else about the node (the operands' parameters in order, the +optional alias) is the default's. +""" +from typing import Tuple, TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover + from ...expression.advanced_functions import TrimExpression + + +class SQLServerTrimMixin: + """Mixin rendering the TRIM family as SQL Server's TRIM/LTRIM/RTRIM.""" + + _TRIM_FUNCTIONS = {"BOTH": "TRIM", "LEADING": "LTRIM", "TRAILING": "RTRIM"} + + def format_trim_expression(self, expr: "TrimExpression") -> Tuple[str, Tuple]: + """Format a TRIM node in SQL Server's form. + + Args: + expr: Trim expression exposing ``expr``, ``chars``, ``direction`` + and an optional ``alias``. + + Returns: + Tuple of (SQL string, parameters tuple), carrying the operands' + parameters in order. + + Raises: + ValueError: ``expr.direction`` is not one of the three the node + allows. + """ + target_sql, target_params = expr.expr.to_sql() + function = self._TRIM_FUNCTIONS.get(expr.direction) + if function is None: + raise ValueError( + f"Unsupported trim direction {expr.direction!r}; expected one of " + f"{sorted(self._TRIM_FUNCTIONS)}" + ) + if expr.chars is not None: + chars_sql, chars_params = expr.chars.to_sql() + if function == "TRIM": + # The one spelling that is not the shared operand order: SQL + # Server's non-ANSI form puts the character set first. + sql = f"TRIM({chars_sql} FROM {target_sql})" + params = tuple(chars_params) + tuple(target_params) + else: + sql = f"{function}({target_sql}, {chars_sql})" + params = tuple(target_params) + tuple(chars_params) + else: + sql = f"{function}({target_sql})" + params = tuple(target_params) + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, params + + +__all__ = ['SQLServerTrimMixin'] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/types.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/types.py index c2d60f5..424f341 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/types.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/types.py @@ -4,8 +4,18 @@ from __future__ import annotations import re -from typing import Tuple +from typing import TYPE_CHECKING, Tuple + +if TYPE_CHECKING: # pragma: no cover + from ..expression.types import ( + SQLServerNCharType, + SQLServerNVarCharType, + SQLServerUniqueIdentifierType, + SQLServerVarBinaryType, + SQLServerXmlType, + ) +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.dialect.mixins.ddl_type import DDLTypeMixin from rhosocial.activerecord.backend.dialect.protocols import DDLTypeSupport from rhosocial.activerecord.backend.expression.types import ( @@ -27,7 +37,10 @@ TextType, TimeType, TimestampType, + TimestampTzType, + TinyIntType, VarCharType, + XmlType, ) @@ -42,57 +55,495 @@ class SQLServerTypeSupportMixin(DDLTypeMixin, DDLTypeSupport): naming-convention ``format_data_type_`` methods (see ``DDLTypeMixin``). SQL Server-specific types carry ``sqlserver_``-prefixed names; core types render their real SQL Server SQL. + + Spellings + --------- + A concept with several spellings (``INTEGER``/``INT``, ``DECIMAL``/ + ``NUMERIC``/``DEC``) declares them as a closed ``SPELLINGS`` list and the + formatter states which of them this backend renders through + ``_check_spelling``. T-SQL writes none of the alternatives below, so every + spelling of every concept is accepted and normalised to the T-SQL word — + refusing one would refuse the concept's own default spelling, which is the + spelling every plainly-constructed type carries. The gate is still called + on every such formatter, because the *only* way an unknown spelling can be + rejected rather than interpolated into SQL is for it to be checked against + the closed list. + + Signedness + ---------- + ``unsigned`` is in the integer concepts' ``PARAMETERS``, so it is part of + their identity: ``IntegerType(unsigned=True)`` is a different column from + ``IntegerType()`` and the differ compares them as such. A formatter may + therefore honour it or refuse it, and there is no third option — T-SQL has + no unsigned integer wider than one byte and no ``UNSIGNED`` attribute, so + every integer formatter here refuses it. See + :meth:`_refuse_unsigned_integer`, and :meth:`_refuse_unsigned_tinyint` for + the one width whose refusal is about a different reason. + + The same rule governs every other identity field, and the rule is not + satisfied by writing the type word and moving on. Two of this backend's + fields are refused rather than honoured, for two different reasons: + + * ``unsigned`` — there is no T-SQL column it could name. + * ``DecimalType.scale`` with no ``precision`` — there *is* a T-SQL column it + could name, but T-SQL's grammar does not let the scale be named on its + own: ``decimal[(p[,s])]``, and the reference says a scale "can only be + specified if precision is specified". So the request has no spelling at + all on this backend, which is the same failure as the first and needs the + same answer. See :meth:`format_data_type_decimal`. + + A third thing is neither honoured nor unexpressible: a *number* outside the + range T-SQL documents. ``DECIMAL(p, s)`` with ``s < 0`` and ``VARCHAR(n)`` + with ``n`` above 8000 are declarations this grammar has no trouble writing + and the server no trouble rejecting, so they are answered with + ``ValueError`` — which names the offending value and the documented bound — + rather than with ``UnsupportedFeatureError``, which is reserved for a + declaration the grammar cannot express at all. See + :meth:`_check_decimal_scale_sign` and :meth:`_check_length`. + + Everything else this dialect models is honoured: ``DECIMAL(p, s)``, + ``FLOAT(p)``, ``DATETIME2(p)``, ``TIME(p)`` and ``DATETIMEOFFSET(p)`` all + take the parameter they are given and render it, which is what makes two + declarations that differ in one a different column in the DDL as well as in + ``identity()``. """ + # ------------------------------------------------------------------ + # What this dialect supplies for a width left undeclared + # ------------------------------------------------------------------ + + #: The widths T-SQL gives a character or byte column whose ``n`` was not + #: written, keyed by concept name — the ``format_data_type_`` dispatch + #: key, so a backend type declares under its own name. + #: + #: **Every value here was read off a live server, and one of them is not + #: T-SQL's own default.** A bare declaration was created and the catalog + #: read back, identically on SQL Server 2022 (16.0.4250.1) and 2025 + #: (17.0.4035.5), which agree on all of it:: + #: + #: CREATE TABLE #probe (v VARCHAR, c CHAR, nv NVARCHAR, nc NCHAR) + #: -- sys.columns.max_length -> 1, 1, 2, 2 + #: + #: ``max_length`` is in bytes and ``nvarchar`` counts byte-pairs, so those + #: four are lengths of **1** each. For what the vendor says as well as + #: what the server did: the reference documents ``n`` for ``varchar(n)`` as + #: "a value from 1 through 8,000", and its sentence about an unspecified + #: ``n`` giving a length of 1 is scoped to variable and parameter + #: declarations. **A column definition's own default is not spelled out on + #: that page**, which is why the measurement above is quoted rather than a + #: paraphrase: it is the only thing here that answers the question actually + #: being asked. + #: https://learn.microsoft.com/en-us/sql/t-sql/data-types/char-and-varchar-transact-sql + #: + #: So: + #: + #: * ``char``, ``sqlserver_nchar`` — **1, and the measured server default.** + #: The declaration and the server agree, so nothing is being asserted + #: against the vendor here. + #: * ``varchar``, ``sqlserver_nvarchar`` — **255, which is *not* T-SQL's + #: default.** This dialect has always written 255 for an undeclared + #: width, and 1 would silently truncate every name, address and slug + #: written through a bare ``VarCharType`` — the server would accept the + #: DDL and store one character. 255 is therefore a decision of *this* + #: backend, stated here rather than implied, and the reason it is a + #: decision is the vendor sentence above: the server's own 1 is the one + #: number that must not be adopted by accident. + #: + #: What this buys is that the two numbers the schema differ compares are one + #: value instead of two. A bare ``VarCharType`` used to render + #: ``VARCHAR(255)`` while carrying no length, so the declared type and the + #: catalog's report of the very column it produced compared **unequal** and + #: the differ invented a change on every undeclared string column. + #: + #: ``sqlserver_nvarchar_max`` is **absent on purpose**: ``NVARCHAR(MAX)`` is + #: unbounded because ``MAX`` lifted a cap, which is a different statement + #: from "no width was declared", and it is carried by its own class for + #: exactly that reason. Nothing exempts it — an absent entry *is* the + #: declaration that this concept's bare form has no width. + def type_parameter_defaults(self) -> dict: + return { + "char": {"length": 1}, + "varchar": {"length": 255}, + "sqlserver_nchar": {"length": 1}, + "sqlserver_nvarchar": {"length": 255}, + } + # --- SQL Server-specific type formatters (dispatch key = type name) --- def format_data_type_sqlserver_nvarchar(self, data_type: "SQLServerNVarCharType") -> Tuple[str, tuple]: + """``NVARCHAR(n)``, with ``n`` bounded as T-SQL documents it. + + ``n`` counts byte-pairs here, not bytes, which is why the documented + ceiling is 4000 rather than the 8000 of ``VARCHAR(n)``; a declared length + outside the documented range is refused rather than truncated, and + :meth:`_check_length` writes the range out and says why this backend + checks the intersection of the page's two statements about the lower + bound. Declaring no length is this dialect's ``NVARCHAR(255)`` — which + T-SQL does **not** do; see :meth:`type_parameter_defaults`, where the + measured value and the reason this backend differs from it are written + down. A type that carries no length still renders the declared width, + because the fallback here and the declaration there are the same number + and neither may drift from the other. + """ + self._check_length("NVARCHAR", data_type.length) return (f"NVARCHAR({data_type.length})" if data_type.length is not None else "NVARCHAR(255)"), () def format_data_type_sqlserver_nchar(self, data_type: "SQLServerNCharType") -> Tuple[str, tuple]: + """``NCHAR(n)``, with ``n`` bounded as T-SQL documents it. + + Same 4000 ceiling as :meth:`format_data_type_sqlserver_nvarchar` and the + same intersection rule; see :meth:`_check_length`. A shorter value is + blank-padded to ``n``, which is what makes this concept fixed-length + rather than variable-length. Declaring no length is this dialect's + ``NCHAR(1)``, and here that is also the measured server default — a bare + ``NCHAR`` reports ``max_length = 2`` bytes on SQL Server 2022 and 2025, + which is one byte-pair; see :meth:`type_parameter_defaults`. + """ + self._check_length("NCHAR", data_type.length) return (f"NCHAR({data_type.length})" if data_type.length is not None else "NCHAR(1)"), () - def format_data_type_sqlserver_nvarchar_max(self, data_type: "SQLServerNVarCharMaxType") -> Tuple[str, tuple]: + def format_data_type_sqlserver_nvarchar_max(self, data_type) -> Tuple[str, tuple]: return "NVARCHAR(MAX)", () def format_data_type_sqlserver_varbinary(self, data_type: "SQLServerVarBinaryType") -> Tuple[str, tuple]: + """``VARBINARY(n)``, with ``n`` bounded as T-SQL documents it. + + T-SQL documents ``varbinary(n)`` as "n can be a value from 1 through + 8,000", and its Remarks separately that "The data that is entered can be + 0 bytes in length" — a statement about the *value*, which is how an empty + byte string is written, not a way to declare ``n = 0``. So the whole + documented range is enforced here, unlike the two character families + where one page states two different lower bounds; see + :meth:`_check_length`. ``VARBINARY(MAX)`` is the unbounded spelling and + is a class of its own. + """ + self._check_length("VARBINARY", data_type.length) if data_type.length is not None: return f"VARBINARY({data_type.length})", () return "VARBINARY(255)", () - def format_data_type_sqlserver_varbinary_max(self, data_type: "SQLServerVarBinaryMaxType") -> Tuple[str, tuple]: + def format_data_type_sqlserver_varbinary_max(self, data_type) -> Tuple[str, tuple]: return "VARBINARY(MAX)", () def format_data_type_sqlserver_xml(self, data_type: "SQLServerXmlType") -> Tuple[str, tuple]: + """SQL Server's own ``xml`` spelling. + + SQL Server has a native ``xml`` type, so the XML concept is *rendered* + here rather than substituted with text. It is not text either: the + storage is a binary representation (see ``SQLServerXmlType``). + """ return "XML", () - def format_data_type_sqlserver_tinyint(self, data_type: "SQLServerTinyIntType") -> Tuple[str, tuple]: + def format_data_type_sqlserver_tinyint(self, data_type) -> Tuple[str, tuple]: + """SQL Server ``TINYINT``, unsigned, for either spelling. + + No ``UNSIGNED`` modifier is emitted: T-SQL's ``TINYINT`` *is* unsigned + and has no signed spelling, so the ``unsigned`` field on the type class + records the fact rather than the DDL spelling it. ``INT1`` is not a + T-SQL word but names the same 8-bit concept, so it renders as + ``TINYINT`` rather than being refused. + + There is nothing to refuse here, and that is worth stating: this class + pins ``unsigned=True`` in its own ``__init__`` and takes no flag, so + this formatter cannot be reached with ``unsigned=False``. The signedness + gate lives on the *generic* concept instead — see + :meth:`_refuse_unsigned_tinyint`, which is where a caller who writes + ``TinyIntType(unsigned=True)`` is sent here by name. + """ + self._check_spelling(data_type, TinyIntType) return "TINYINT", () - def format_data_type_sqlserver_bit(self, data_type: "SQLServerBitType") -> Tuple[str, tuple]: + def format_data_type_sqlserver_bit(self, data_type) -> Tuple[str, tuple]: + """SQL Server ``BIT`` — the boolean, not a bit string. + + Unlike MySQL's and PostgreSQL's ``BIT(n)``, T-SQL's ``BIT`` takes no + length and holds ``0`` / ``1`` / ``NULL``; see ``SQLServerBitType``. + """ return "BIT", () - def format_data_type_sqlserver_image(self, data_type: "SQLServerImageType") -> Tuple[str, tuple]: + def format_data_type_sqlserver_image(self, data_type) -> Tuple[str, tuple]: return "IMAGE", () + def format_data_type_sqlserver_uniqueidentifier( + self, data_type: "SQLServerUniqueIdentifierType" + ) -> Tuple[str, tuple]: + return "UNIQUEIDENTIFIER", () + # --- Core types (pure names) rendered to real SQL Server SQL --- - def format_data_type_integer(self, data_type: IntegerType) -> Tuple[str, tuple]: - return "INT", () + #: The documented range of each signed T-SQL integer width, written as the + #: reference writes it. Quoted in the refusal below so the caller reads the + #: column it would otherwise have got. The ``_SQLSERVER_`` prefix is what + #: ``_register_type_formatters()`` copies onto the dialect class alongside + #: the formatters that read it: inside a copied formatter ``self`` is the + #: dialect, not this mixin. + #: https://learn.microsoft.com/en-us/sql/t-sql/data-types/int-bigint-smallint-and-tinyint-transact-sql + _SQLSERVER_INTEGER_RANGES = { + "INT": "-2,147,483,648 to 2,147,483,647", + "SMALLINT": "-32,768 to 32,767", + "BIGINT": ( + "-9,223,372,036,854,775,808 to 9,223,372,036,854,775,807" + ), + } + + def _refuse_unsigned_integer(self, data_type, word: str) -> None: + """Refuse ``unsigned=True``, because T-SQL has no unsigned integer of + any width above one byte and no ``UNSIGNED`` attribute to write. + + The core integer concepts carry signedness as a field rather than as a + class, so ``IntegerType(unsigned=True)`` is constructible and the flag + reaches the formatter. It is also in ``PARAMETERS``, hence in + ``__eq__``/``__hash__``, hence two declarations differing only in it are + different columns — so the flag has to change the rendered SQL or stop + the render. Writing the signed column and reporting success is the + second thing: the column then accepts the negatives the caller declared + it would not, and nothing says so. + + What the documentation says, which is why the answer is a refusal: + + * The integer types are enumerated as ``bigint``, ``int``, ``smallint`` + and ``tinyint`` under "Exact numerics", and the four of them are + documented with one symmetric range each — ``bigint`` -2^63 to + 2^63-1, ``int`` -2^31 to 2^31-1, ``smallint`` -2^15 to 2^15-1 — and + no unsigned counterpart for any of them. The exact-numeric category + is closed: ``tinyint``, ``smallint``, ``int``, ``bigint``, ``bit``, + ``decimal``, ``numeric``, ``money``, ``smallmoney``. There is no + unsigned row to select. + https://learn.microsoft.com/en-us/sql/t-sql/data-types/data-types-transact-sql + https://learn.microsoft.com/en-us/sql/t-sql/data-types/int-bigint-smallint-and-tinyint-transact-sql + * There is not even a spelling that would parse. ```` + is ``column_name `` followed by ``FILESTREAM``, + ``COLLATE``, ``SPARSE``, ``MASKED``, ``DEFAULT``, ``IDENTITY``, + ``GENERATED ALWAYS AS``, nullability, ``ROWGUIDCOL`` and + ``ENCRYPTED WITH`` — no type modifier, and no ``UNSIGNED`` or + ``SIGNED`` token anywhere in the statement reference. + https://learn.microsoft.com/en-us/sql/t-sql/statements/create-table-transact-sql + * Two nearby things are *not* signedness, and are named here so the + next reader does not mistake them for it. ``IDENTITY[(seed, + increment)]`` is the auto-numbering property, assignable to + ``tinyint``, ``smallint``, ``int``, ``bigint``, ``decimal(p,0)`` and + ``numeric(p,0)`` alike — a signed and an unsigned request alike get + the same counter. And ``sql_variant`` is a column that stores a + *base type* per value ("a data type that stores values of various + SQL Server-supported data types", with ``SQL_VARIANT_PROPERTY`` + reporting ``BaseType``); the base types it can hold are the signed + integers above, so it widens a column's type vocabulary without + adding a signedness to any of them. + https://learn.microsoft.com/en-us/sql/t-sql/data-types/sql-variant-transact-sql + + The signed range does cover each width's unsigned range, so nothing is + lost by dropping the *concept* here; what would be lost is the caller's + statement that this particular column holds no negative value, and that + belongs in a ``CHECK`` constraint. + """ + if not data_type.unsigned: + return + raise UnsupportedFeatureError( + self.name, + f"an unsigned {word} column " + f"(SQL Server has no unsigned integer type; {word} is a signed " + f"integer documented as {self._SQLSERVER_INTEGER_RANGES[word]}, " + f"and T-SQL has no UNSIGNED attribute to write after the type name)", + suggestion=( + "Declare the column signed and enforce the range with a CHECK " + "constraint if negatives must be rejected." + ), + ) + + def _refuse_unsigned_numeric(self, data_type, word: str) -> None: + """Refuse ``unsigned=True`` on ``DECIMAL``, ``FLOAT``, ``REAL`` or + ``DOUBLE``. + + The same field reaches these four concepts that it reaches the integer + widths -- ``DecimalType``, ``FloatType``, ``RealType`` and + ``DoubleType`` each carry ``unsigned`` in ``PARAMETERS``, so two + declarations differing only in it are different columns as far as the + schema differ is concerned -- and T-SQL's answer is a refusal, for its + own reasons. + + Note first what this is **not**. ``_refuse_unsigned_tinyint`` refuses + the flag on one width because T-SQL *has* that unsigned column and the + class that owns it already exists. There is no such case here: no T-SQL + numeric column is unsigned, so there is no second declaration to move the + request to, and no ``SQLServer*Type`` to name instead. + + What the documentation says: + + * **The categories are closed and carry no unsigned row.** + ``decimal`` and ``numeric`` are in "Exact Numerics", documented with + that category as ``tinyint``, ``smallint``, ``int``, ``bigint``, + ``bit``, ``decimal``, ``numeric``, ``money``, ``smallmoney``; + ``float`` and ``real`` are the whole of "Approximate Numerics". + https://learn.microsoft.com/en-us/sql/t-sql/data-types/data-types-transact-sql + * **Their documented ranges are signed on both sides.** The reference + gives ``float`` as "- 1.79E+308 to -2.23E-308, 0 and 2.23E-308 to + 1.79E+308" and ``real`` as "- 3.40E + 38 to -1.18E - 38, 0 and 1.18E - + 38 to 3.40E + 38", and says of both: "The behavior of float and real + follows the IEEE 754 specification on approximate numeric data types" + -- and IEEE 754 binary formats carry a signed exponent in their field + layout, so there is no unsigned binary format for a column to be. + https://learn.microsoft.com/en-us/sql/t-sql/data-types/float-and-real-transact-sql + ``decimal``/``numeric`` are exact numeric with a maximum precision of + 38, which is a bound on the digits a column holds rather than a + signedness. + https://learn.microsoft.com/en-us/sql/t-sql/data-types/decimal-and-numeric-transact-sql + * **There is not even a spelling that would parse.** + ```` is ``column_name `` followed by + ``FILESTREAM``, ``COLLATE``, ``SPARSE``, ``MASKED``, ``DEFAULT``, + ``IDENTITY``, ``GENERATED ALWAYS AS``, nullability, ``ROWGUIDCOL`` + and ``ENCRYPTED WITH`` -- no type modifier, and no ``UNSIGNED`` or + ``SIGNED`` token anywhere in the statement reference. + https://learn.microsoft.com/en-us/sql/t-sql/statements/create-table-transact-sql + + Writing a bare ``DECIMAL`` or ``FLOAT(53)`` for an unsigned request would + create a column that accepts the negatives the caller declared it would + not, and report success: the same silent loss as accepting the flag and + discarding it, which is what this replaces. + + ``word`` is the word T-SQL writes -- ``DOUBLE PRECISION`` is rendered + ``FLOAT(53)`` and ``DECIMAL`` keeps its own spelling -- so the message + names the request and the rendering together. + + ``UnsupportedFeatureError``, not ``ValueError``: this is a declaration + the grammar cannot express at all rather than a wrong value, and the two + exceptions do not share a base class. + """ + if not data_type.unsigned: + return + raise UnsupportedFeatureError( + self.name, + f"an unsigned {word} column " + f"(SQL Server has no unsigned floating-point or fixed-point type: " + f"decimal and numeric are Exact Numerics and float and real are the " + f"whole of Approximate Numerics, neither category has an unsigned " + f"row, float's documented range is -1.79E+308 to -2.23E-308, 0 and " + f"2.23E-308 to 1.79E+308 and real's is -3.40E+38 to -1.18E-38, 0 and " + f"1.18E-38 to 3.40E+38, float and real follow the IEEE 754 " + f"specification whose binary formats have a signed exponent, and " + f"T-SQL's column grammar has no type modifier position after the " + f"type name, so there is not even a spelling that would parse)", + suggestion=( + "Declare the column signed and enforce the range with a CHECK " + "constraint if negatives must be rejected." + ), + ) + + def _refuse_unsigned_tinyint(self, data_type) -> None: + """Refuse ``unsigned=True`` on the *generic* 8-bit concept, for a + reason that is not the one above: T-SQL **does** have an unsigned + integer, and it is exactly this width. + + ``TINYINT`` is documented as "0 to 255", range expression + ``2^0-1 to 2^8-1``, one byte — unsigned, with no signed 8-bit integer + beside it in the exact-numeric list. + https://learn.microsoft.com/en-us/sql/t-sql/data-types/int-bigint-smallint-and-tinyint-transact-sql + + So there is no second T-SQL word for the flag to switch to: the + rendering for ``unsigned=True`` and for ``unsigned=False`` would be the + same ``TINYINT`` column, which is precisely the paradigm violation — + and the unsigned column it would produce is already declared by + :class:`SQLServerTinyIntType`, which pins ``unsigned=True`` in its own + ``__init__`` and renders that same ``TINYINT``. Honouring the flag + here would give the caller two declarations for one column that a + differ could not tell apart by their SQL, so it is refused and the class + that owns the column is named instead. + + What the flag cannot express on this width is the other direction + either, and that is stated rather than papered over: T-SQL has no + signed 8-bit integer, so a *signed* ``TinyIntType`` also renders + ``TINYINT``, an unsigned column. That is the signedness collapse this + backend performs, in the same place SQLite collapses every integer + width to ``INTEGER`` — the vendor fixes the range of the only 8-bit + type, and the width a caller can have is whatever T-SQL offers. A + value that must be allowed to go negative needs ``SMALLINT``, the + narrowest signed width T-SQL has. + """ + if not data_type.unsigned: + return + raise UnsupportedFeatureError( + self.name, + "an unsigned TINYINT column declared on the generic TinyIntType " + "(T-SQL's only 8-bit integer, TINYINT, is itself unsigned - " + "documented as 0 to 255 with no signed spelling - so there is no " + "second T-SQL word for this flag to change the rendering to, and " + "the unsigned 8-bit column is already declared by " + "SQLServerTinyIntType, which pins unsigned=True)", + suggestion=( + "Declare SQLServerTinyIntType, which is this backend's unsigned " + "8-bit integer and renders TINYINT; a column that must also " + "hold negatives needs SMALLINT, the narrowest signed width T-SQL " + "has." + ), + ) - def format_data_type_int(self, data_type: IntegerType) -> Tuple[str, tuple]: + def format_data_type_integer(self, data_type: IntegerType) -> Tuple[str, tuple]: + """``INT`` for both spellings. + + T-SQL accepts ``INT`` and ``INTEGER`` interchangeably for the same + signed 32-bit integer and reports ``int`` from the catalog, so the + spelling is normalised to ``INT`` — T-SQL's own word — rather than + refused. ``INT4`` is not accepted: it is not in the concept's closed + spelling list, and accepting words T-SQL never writes would make the + list meaningless. + + ``unsigned`` is refused rather than ignored; see + :meth:`_refuse_unsigned_integer`, which is also where the reason for + the refusal is written down. + """ + self._check_spelling(data_type, IntegerType) + self._refuse_unsigned_integer(data_type, "INT") return "INT", () def format_data_type_bigint(self, data_type: BigIntType) -> Tuple[str, tuple]: + """``BIGINT`` for both spellings; T-SQL has no ``INT8`` of its own but + both name the same signed 64-bit integer, so neither is refused. + + ``unsigned`` is refused rather than ignored; see + :meth:`_refuse_unsigned_integer`.""" + self._check_spelling(data_type, BigIntType) + self._refuse_unsigned_integer(data_type, "BIGINT") return "BIGINT", () def format_data_type_smallint(self, data_type: SmallIntType) -> Tuple[str, tuple]: + """``SMALLINT`` for both spellings, for the same reason as + :meth:`format_data_type_bigint`. + + ``unsigned`` is refused rather than ignored; see + :meth:`_refuse_unsigned_integer`.""" + self._check_spelling(data_type, SmallIntType) + self._refuse_unsigned_integer(data_type, "SMALLINT") return "SMALLINT", () - def format_data_type_tinyint(self, data_type: DataType) -> Tuple[str, tuple]: + def format_data_type_tinyint(self, data_type: TinyIntType) -> Tuple[str, tuple]: + """``TINYINT`` for both spellings. + + This is the concept class; the unsigned 8-bit backend class is + :class:`SQLServerTinyIntType`. Both render ``TINYINT`` because T-SQL + has no signed 8-bit integer to distinguish: the range of the only + 8-bit type is fixed by the vendor, so the ``unsigned`` field records a + property of the type rather than a word to write. + + ``unsigned`` is refused rather than ignored — but not because T-SQL has + no unsigned integer, which is the argument on + :meth:`_refuse_unsigned_integer`. It is refused because ``TINYINT`` + *is* that unsigned integer and :class:`SQLServerTinyIntType` already + declares it; see :meth:`_refuse_unsigned_tinyint`, which is where that + reasoning is written down and where the class to use instead is named. + """ + self._check_spelling(data_type, TinyIntType) + self._refuse_unsigned_tinyint(data_type) return "TINYINT", () def format_data_type_float(self, data_type: FloatType) -> Tuple[str, tuple]: + """``FLOAT(p)`` for a declared precision, bare ``FLOAT`` for none. + + ``unsigned`` is refused rather than ignored; see + :meth:`_refuse_unsigned_numeric`, which is also where the reason is + written down. It is checked first so an unsigned request is answered the + same way whether or not it also named a precision, and the precision's own + ``ValueError`` is untouched for a signed declaration. + """ + self._refuse_unsigned_numeric(data_type, "FLOAT") if data_type.precision is not None: if not (1 <= data_type.precision <= 53): raise ValueError( @@ -103,12 +554,258 @@ def format_data_type_float(self, data_type: FloatType) -> Tuple[str, tuple]: return "FLOAT", () def format_data_type_real(self, data_type: RealType) -> Tuple[str, tuple]: + """``REAL``, and ``unsigned`` refused rather than ignored; see + :meth:`_refuse_unsigned_numeric`. + + Worth being precise about what this refusal is *not*, because the + briefing for this field described it wrongly and the distinction is the + whole reason the message says what it says. T-SQL ``real`` is **not** + ``FLOAT(53)``: ``REAL`` is its own row in "Approximate Numerics", a + 4-byte single-precision type with its own documented range -- "- 3.40E + + 38 to -1.18E - 38, 0 and 1.18E - 38 to 3.40E + 38" -- while 8-byte double + precision is separately rendered ``FLOAT(53)`` by + :meth:`format_data_type_double`. So this formatter drops the field on a + concept whose storage really is that concept, not on one standing in for + another. ``REAL`` is one of the two words T-SQL writes verbatim, so + nothing about a *signed* declaration moves. + https://learn.microsoft.com/en-us/sql/t-sql/data-types/float-and-real-transact-sql + + The signedness is still refused, for the reasons in + :meth:`_refuse_unsigned_numeric`: that range is signed on both sides, + the reference says "The behavior of float and real follows the IEEE 754 + specification", IEEE 754 binary formats carry a signed exponent in their + field layout, and T-SQL's ```` has no type-modifier + position after the type name in which ``UNSIGNED`` could go. + """ + self._refuse_unsigned_numeric(data_type, "REAL") return "REAL", () def format_data_type_double(self, data_type: DoubleType) -> Tuple[str, tuple]: + """``FLOAT(53)`` for both spellings. + + T-SQL has no ``DOUBLE``: 8-byte floating point *is* ``FLOAT(53)``, the + form the server itself reports for a ``double precision`` column, so + that is what a requested double precision renders as -- Microsoft says so + in its own words: "The synonym for **double precision** is + **float(53)**". + https://learn.microsoft.com/en-us/sql/t-sql/data-types/float-and-real-transact-sql + + ``unsigned`` is refused rather than ignored; see + :meth:`_refuse_unsigned_numeric`. ``FLOAT(53)``'s documented range is + "- 1.79E+308 to -2.23E-308, 0 and 2.23E-308 to 1.79E+308" -- signed on + both sides -- so there is no unsigned double-precision column on this + server for the request to have become. + """ + self._check_spelling(data_type, DoubleType) + self._refuse_unsigned_numeric(data_type, "DOUBLE PRECISION") return "FLOAT(53)", () + def _check_decimal_scale_sign(self, data_type: DecimalType) -> None: + """Refuse a negative ``scale``, which T-SQL's documented range excludes. + + The one sentence that fixes the bound is the reference's own, in its + ``s`` (scale) entry: "Scale must be a value from 0 through *p*, and can + only be specified if precision is specified. The default scale is 0, + and so 0 <= s <= p." + https://learn.microsoft.com/en-us/sql/t-sql/data-types/decimal-and-numeric-transact-sql + + So ``0 <= s`` is the lower half of that bound, and it holds for *every* + precision: the smallest documented precision is 1, so no ``p`` makes a + negative ``s`` legal. That is why this is checked before the + un-anchorable-scale refusal rather than folded into it — a negative scale + is wrong whatever precision it is declared with, and a refusal that said + "name the precision too" would be advice that does not work. + + The ``scale <= precision`` comparison further down cannot see this, which + is how ``DECIMAL(10, -1)`` reached the DDL: ``-1`` is not greater than + ``10``, so the check passed and the server rejected the statement. + ``DECIMAL(5, -1)`` and a bare ``DecimalType(scale=-1)`` were the same + story. + + Nothing is clamped to 0. A negative scale is not a small scale, and + rounding it up would hand the caller a column other than the one they + declared — the same defect as dropping the value. + + ``ValueError``, and not ``UnsupportedFeatureError``: ``DECIMAL(p, s)`` is + a declaration this grammar expresses perfectly well, so nothing is + inexpressible here; the number is simply outside the documented range. + The two exceptions do not share a base class, which is why the project's + cross-backend rule keeps them apart. + """ + if data_type.scale is None or data_type.scale >= 0: + return + raise ValueError( + f"SQL Server DECIMAL scale must not be negative, got " + f"{data_type.scale} (T-SQL's reference says 'Scale must be a value " + f"from 0 through p, and can only be specified if precision is " + f"specified. The default scale is 0, and so 0 <= s <= p.' - the " + f"lower bound holds for every precision, since the smallest " + f"documented precision is 1, so no precision makes a negative scale " + f"legal)" + ) + + #: T-SQL's documented inline length bounds, as ``word -> (smallest, + #: largest)``. ``n`` is bytes for ``CHAR`` / ``VARCHAR`` / ``BINARY`` / + #: ``VARBINARY`` ("1 through 8,000") and byte-pairs for ``NCHAR`` / + #: ``NVARCHAR`` ("1 through 4,000"), which is why the national pair has the + #: lower ceiling. The lower bounds differ between the two families for a + #: reason that is about the *documentation*, not about T-SQL, and + #: :meth:`_check_length` sets it out. + #: https://learn.microsoft.com/en-us/sql/t-sql/data-types/char-and-varchar-transact-sql + #: https://learn.microsoft.com/en-us/sql/t-sql/data-types/binary-and-varbinary-transact-sql + #: https://learn.microsoft.com/en-us/sql/t-sql/data-types/nchar-and-nvarchar-transact-sql + _SQLSERVER_LENGTH_LIMITS = { + "CHAR": (0, 8000), + "VARCHAR": (0, 8000), + "NCHAR": (0, 4000), + "NVARCHAR": (0, 4000), + "VARBINARY": (1, 8000), + } + + def _check_length(self, word: str, length) -> None: + """Refuse a declared length outside T-SQL's documented inline range. + + The bound checked here is the **intersection** of every range the + vendor's page states for that ``word`` — the largest lower bound and the + smallest upper bound any documented sentence gives — because a value + outside the intersection is one no documented reading of T-SQL permits, + while a value inside it may still be one some reading excludes. Nothing + else is defensible without a server to ask, and this backend has none. + + What each page states, per family: + + * ``char(n)`` / ``varchar(n)`` — "must be a value from 1 through 8,000" + in the Arguments, and "the n defines the string length in bytes (0 to + 8,000)" in the Remarks. The ceiling agrees; the floor does not. + * ``nchar(n)`` / ``nvarchar(n)`` — "must be a value from 1 through 4,000" + and "byte-pairs (0-4,000)". The same disagreement, at half the size. + * ``binary(n)`` / ``varbinary(n)`` — "n is a value from 1 through 8,000" + and "n can be a value from 1 through 8,000", and no second statement + about ``n``: the Remarks' "The data that is entered can be 0 bytes in + length" is about the *value*, not the parameter, and is not a way to + declare ``n = 0``. This is the one family with a single unambiguous + range, and the only place the enforced floor is 1 rather than 0. + + That is the whole of the asymmetry in the table above, and it is worth + writing out rather than resolving silently. Refusing ``VARCHAR(0)`` would + be enforcing one sentence against another on one page, so it is not + refused; rendering ``VARCHAR(0)`` verbatim is at least faithful — the + declaration the caller wrote is the declaration that reaches the DDL, and + the check does not invent a bound the vendor has not stated. A negative + ``n`` is excluded by *both* readings of both character pages, so it is + refused. + + Above the ceiling the pages agree, and so does the server: ``VARCHAR`` + over 8000 is the classic "The size of the data type varchar(n) exceeds + the maximum allowed for any data type (8000)", and ``NVARCHAR(4001)`` is + the same statement at half the size. Those reached the DDL unchecked + before. + + ``ValueError``, not ``UnsupportedFeatureError``: ``VARCHAR(n)`` is a + declaration this grammar expresses perfectly well; the number is what is + out of range. Nothing is truncated to the ceiling — a clamped length is + a column other than the one declared. + """ + smallest, largest = self._SQLSERVER_LENGTH_LIMITS[word] + if length is None or smallest <= length <= largest: + return + raise ValueError( + f"SQL Server {word} length must be {smallest}-{largest}, got " + f"{length} (T-SQL's own reference for {word} documents the inline " + f"length n as a value from {smallest} through {largest}, and " + f"declaring a length outside that is a statement the server " + f"rejects; above the ceiling it is the well-known 'exceeds the " + f"maximum allowed for any data type (8000)' error)" + ) + def format_data_type_decimal(self, data_type: DecimalType) -> Tuple[str, tuple]: + """``DECIMAL`` for all three spellings, and the two ways a scale or a + precision can be wrong on this grammar. + + T-SQL's own documented interchangeability: ``DECIMAL``, ``NUMERIC`` and + ``DEC`` are one type, and the catalog reports ``decimal``. + + **``scale`` without ``precision`` is refused**, and the reason is the + vendor's own grammar rather than an argument borrowed from PostgreSQL. + The documented syntax is ``decimal [ ( p [ , s ] ) ]`` and ``numeric [ + ( p [ , s ] ) ]`` — the scale is *inside* the precision's brackets, and + the ``s`` entry says so in its own words: "Scale must be a value from 0 + through *p*, **and can only be specified if precision is specified**. The + default scale is 0, and so 0 <= s <= p." + https://learn.microsoft.com/en-us/sql/t-sql/data-types/decimal-and-numeric-transact-sql + + That last clause settles it in T-SQL's terms rather than PostgreSQL's: + there is no declaration in which a scale appears without a precision, so + the alternative reading — "the scale, with T-SQL's default precision of + 18" — does not exist either, the same way it does not on PostgreSQL. + That the two backends reach the same answer here is a coincidence of + their grammar agreeing, not an inheritance: what is quoted above is + Microsoft's sentence, and the checked ranges are this backend's. + + Writing bare ``DECIMAL`` instead would not be a small loss. T-SQL's + documented defaults are a precision of 18 and a scale of 0, so bare + ``DECIMAL`` is ``DECIMAL(18, 0)``: eighteen digits, none of them + fractional. A request that named ``scale=2`` and nothing else asked for + a fractional column and would have been handed one with none. And the + only other spelling that reaches a scale at all, ``DECIMAL(p)``, means + scale 0 for the same reason. Neither the precision nor the scale is this + formatter's to invent, so the render stops and says which field is + missing. + + **A negative scale is refused, with ``ValueError``**, and that is a + different fault from the one above. The same sentence that forbids a + bare scale also fixes the range: "Scale must be a value from 0 through + *p* ... and so 0 <= s <= p". A negative ``s`` is below the lower bound + for *every* possible ``p``, so no precision rescues it — telling the + caller to add a precision would be advice that does not work. The + existing ``scale <= precision`` comparison cannot see it, because ``-1`` + is not greater than ``10``; ``DECIMAL(10, -1)`` used to render that way + and the server rejected it. Nothing is clamped to 0. + + The two exceptions are not interchangeable — they do not share a base + class — so the distinction is kept: ``scale=2`` alone is a declaration + this grammar cannot express (``UnsupportedFeatureError``, with a route + forward), while ``scale=-1`` is a number outside the range this server + accepts (``ValueError``, naming the value and the bound). + + What this is *not*: the scale is honoured, visibly, on every request + that carries a precision. ``DECIMAL(10, 2)`` and ``DECIMAL(10, 0)`` are + different columns — T-SQL's own reference says "SQL Server considers each + combination of precision and scale as a different data type" — and this + dialect renders them differently, which is what keeps them different. + Only the un-anchorable request and the out-of-range value are refused. + + ``unsigned`` is refused as well, and by the same method that refuses it + for the two floating-point concepts; see :meth:`_refuse_unsigned_numeric`. + It is checked **first**, before the spelling and the scale-sign checks + below, and that ordering is deliberate: ``unsigned`` is the one of this + class's identity fields that T-SQL's grammar cannot express at all, so it + is answered the same way whether or not the request also named numbers + this backend would have had to judge. Every check below is untouched and + still fires for a signed declaration. + """ + self._refuse_unsigned_numeric(data_type, "DECIMAL") + self._check_spelling(data_type, DecimalType) + self._check_decimal_scale_sign(data_type) + if data_type.scale is not None and data_type.precision is None: + raise UnsupportedFeatureError( + self.name, + f"a DECIMAL(p,s) column declared with scale={data_type.scale} " + f"and no precision (T-SQL's grammar is decimal[(p[,s])]: the " + f"scale is inside the precision's brackets and, in the " + f"reference's words, 'can only be specified if precision is " + f"specified' - so there is no DECIMAL declaration that carries a " + f"scale alone, and no type modifier position after the type name " + f"where one could go)", + suggestion=( + f"Declare the precision as well: DECIMAL(p,s) renders " + f"DECIMAL(p, {data_type.scale}), and T-SQL requires 0 <= s " + f"<= p with a maximum precision of 38. A bare DECIMAL is " + f"DECIMAL(18, 0) - a default precision of 18 and a default " + f"scale of 0 - so it is not the column a bare scale asked " + f"for, and DECIMAL(p) alone would mean scale 0." + ), + ) if data_type.precision is not None: if not (1 <= data_type.precision <= 38): raise ValueError( @@ -128,18 +825,77 @@ def format_data_type_decimal(self, data_type: DecimalType) -> Tuple[str, tuple]: return "DECIMAL", () def format_data_type_boolean(self, data_type: BooleanType) -> Tuple[str, tuple]: + """``BIT`` for both spellings — T-SQL's own name for the boolean.""" + self._check_spelling(data_type, BooleanType) return "BIT", () def format_data_type_varchar(self, data_type: VarCharType) -> Tuple[str, tuple]: - return (f"VARCHAR({data_type.length})" if data_type.length is not None else "VARCHAR(255)"), () + """``VARCHAR(n)`` for both spellings. + + T-SQL writes neither ``CHARACTER VARYING`` nor ``CHARACTER``: the long + forms are SQL's, not this backend's, so a portable spelling is + normalised to the word the catalog reports. An undeclared width is this + dialect's **255** — which is a choice of this backend and *not* what T-SQL + does, whose own default is 1; :meth:`type_parameter_defaults` writes that + down with the measurement behind it. The number is read from the same + declaration rather than repeated here, because this fallback exists only + for a type built without a dialect: a type bound to this one has already + resolved to the declared 255 in its own ``length``, and the two answers + must not be allowed to drift apart. + + A declared length is range-checked against the 8000-byte inline ceiling + T-SQL documents; see :meth:`_check_length`, which also sets out why this + backend checks the intersection of that page's two statements about the + lower bound rather than picking one of them. + """ + self._check_spelling(data_type, VarCharType) + self._check_length("VARCHAR", data_type.length) + length = data_type.length + if length is None: + length = self.type_parameter_defaults()["varchar"]["length"] + return f"VARCHAR({length})", () def format_data_type_char(self, data_type: CharType) -> Tuple[str, tuple]: - return (f"CHAR({data_type.length})" if data_type.length is not None else "CHAR(1)"), () + """``CHAR(n)`` for both spellings, defaulting to ``CHAR(1)``. + + Fixed length: a shorter value is space-padded to ``n`` rather than + stored as written, and the padding is why the concept is fixed-length + rather than variable-length. Declaring no length is ``CHAR(1)``, and here + that is T-SQL's own default as well — measured, not assumed; see + :meth:`type_parameter_defaults`. As in + :meth:`format_data_type_varchar` the fallback reads the one declaration, + for a type built without a dialect. + + A declared length is range-checked exactly as :meth:`format_data_type_varchar` + range-checks a ``VARCHAR`` one; see :meth:`_check_length`. + """ + self._check_spelling(data_type, CharType) + self._check_length("CHAR", data_type.length) + length = data_type.length + if length is None: + length = self.type_parameter_defaults()["char"]["length"] + return f"CHAR({length})", () def format_data_type_text(self, data_type: TextType) -> Tuple[str, tuple]: + """``NVARCHAR(MAX)`` for both spellings. + + Unbounded text is spelled ``NVARCHAR(MAX)`` on SQL Server, not ``TEXT``: + ``TEXT`` is the deprecated non-Unicode type and cannot hold characters + outside the database collation. The dialect already stores ``json``, + enums and byte strings here, and ``clob`` names the same unbounded + concept, so it is accepted rather than refused. + """ + self._check_spelling(data_type, TextType) return "NVARCHAR(MAX)", () def format_data_type_datetime(self, data_type: DateTimeType) -> Tuple[str, tuple]: + """``DATETIME2`` — T-SQL's own word for a datetime without an offset. + + The deprecated ``DATETIME`` and ``SMALLDATETIME`` are its ancestors, not + its synonyms: they round seconds (or even minutes) differently, which is + why they are separate parser branches rather than spellings of this + concept. + """ if data_type.precision is not None: if not (0 <= data_type.precision <= 7): raise ValueError( @@ -163,6 +919,14 @@ def format_data_type_time(self, data_type: TimeType) -> Tuple[str, tuple]: return "TIME", () def format_data_type_timestamp(self, data_type: TimestampType) -> Tuple[str, tuple]: + """``DATETIME2`` — SQL Server renders this concept as its own datetime. + + Worth stating because it is the opposite of what the spelling suggests: + T-SQL's ``TIMESTAMP`` is *not* a timestamp, it is the ``ROWVERSION`` + 8-byte auto-incrementing counter, so a requested ``TIMESTAMP WITH TIME + ZONE`` must not be confused with either of them. The timezone-aware + concept is :meth:`format_data_type_timestamptz`. + """ if data_type.precision is not None: if not (0 <= data_type.precision <= 7): raise ValueError( @@ -172,10 +936,56 @@ def format_data_type_timestamp(self, data_type: TimestampType) -> Tuple[str, tup return f"DATETIME2({data_type.precision})", () return "DATETIME2", () + def format_data_type_timestamptz(self, data_type: TimestampTzType) -> Tuple[str, tuple]: + """``DATETIMEOFFSET`` — SQL Server's timezone-aware datetime. + + The concept is rendered, not substituted, because T-SQL really has the + type: ``DATETIMEOFFSET`` keeps the local time *and* the offset, which is + what distinguishes it from the ``DATETIME2`` above. The one thing it + does not keep is the zone *identifier* — there is no time-zone name in + the type, so a column that needs one has to carry it separately. + """ + if data_type.precision is not None: + if not (0 <= data_type.precision <= 7): + raise ValueError( + f"SQL Server DATETIMEOFFSET fractional seconds precision " + f"must be 0-7, got {data_type.precision}" + ) + return f"DATETIMEOFFSET({data_type.precision})", () + return "DATETIMEOFFSET", () + def format_data_type_json(self, data_type: JsonType) -> Tuple[str, tuple]: + """``NVARCHAR(MAX)`` — SQL Server has no JSON *type*. + + JSON arrives in SQL Server 2016 as text in an ordinary column, and + ``OPENJSON`` / ``JSON_VALUE`` / ``ISJSON`` are functions over that + text, not a storage type. So the concept is rendered as the text + column that actually holds it, which is also why ``suggested_data_types`` + names this class for ``jsonb`` and ``array``. + """ return "NVARCHAR(MAX)", () + def format_data_type_xml(self, data_type: XmlType) -> Tuple[str, tuple]: + """``XML`` — SQL Server has a native ``xml`` type. + + Unlike ``json``, this concept is not text here: the storage is a binary + representation that supports ``.nodes()`` / ``.value()`` / ``.exist()`` + / ``.modify()``, so the XML concept is rendered rather than substituted + with a string column. An associated XML Schema Collection is a column + attribute, so it does not appear here. + """ + return "XML", () + def format_data_type_blob(self, data_type: BlobType) -> Tuple[str, tuple]: + """``VARBINARY(MAX)`` for both spellings. + + T-SQL's own name for the byte-string storage; ``BYTEA`` is PostgreSQL's + and is accepted because it names the same concept, not because this + backend writes it. ``VARBINARY(MAX)`` is the 2 GB form — T-SQL's + unbounded byte storage — and is what an unbounded blob is on this + dialect. + """ + self._check_spelling(data_type, BlobType) return "VARBINARY(MAX)", () def format_data_type_custom(self, data_type: CustomType) -> Tuple[str, tuple]: @@ -191,6 +1001,9 @@ def supports_data_type_sqlserver_nvarchar(self) -> bool: def supports_data_type_sqlserver_nchar(self) -> bool: return True + def supports_data_type_sqlserver_uniqueidentifier(self) -> bool: + return True + def supports_data_type_sqlserver_nvarchar_max(self) -> bool: return True @@ -215,9 +1028,6 @@ def supports_data_type_sqlserver_image(self) -> bool: def supports_data_type_integer(self) -> bool: return True - def supports_data_type_int(self) -> bool: - return True - def supports_data_type_bigint(self) -> bool: return True @@ -263,7 +1073,38 @@ def supports_data_type_time(self) -> bool: def supports_data_type_timestamp(self) -> bool: return True + def supports_data_type_timestamptz(self) -> bool: + """``DATETIMEOFFSET`` arrived with SQL Server 2008 (10.0). + + The concept is rendered rather than substituted, so this gate is the + only thing standing between a 2005 dialect and a column the server will + reject with 'DATETIMEOFFSET is not a recognized user-defined type'. An + unconditional ``True`` would send the DDL and let the server answer, + which is the failure mode a capability question exists to prevent. + """ + return self.version >= (10, 0, 0) + def supports_data_type_json(self) -> bool: + """Unconditional, because the storage this concept renders to has + always existed. + + JSON itself arrived in SQL Server 2016, but a JSON *value* lives in an + ordinary ``NVARCHAR(MAX)`` column, which is what + :meth:`format_data_type_json` emits, and ``NVARCHAR(MAX)`` is a type + from 2005. Refusing the concept on a pre-2016 server would hide a key + from ``supports_data_types()`` without changing what this dialect can + actually write — the JSON *functions* are version-gated separately, in + ``supports_json_function()``. + """ + return True + + def supports_data_type_xml(self) -> bool: + """SQL Server has had a native ``xml`` type since 2005 (9.0). + + Unconditional on the version rather than a gate, because the type is a + core one for this engine — there is no fallback the type is being + preferred over. + """ return True def supports_data_type_blob(self) -> bool: @@ -275,25 +1116,40 @@ def supports_data_type_custom(self) -> bool: # ------------------------------------------------------------------ # DDLTypeSupport — parsing # ------------------------------------------------------------------ + # + # Canonicality (D8): one spelling in, its own concept class out, with the + # spelling recorded on the instance. Nothing here may yield a *different* + # concept than the word it read — that is how ``char`` came to yield + # ``VarCharType`` and ``date`` came to yield ``DateTimeType``. A name this + # dialect cannot map to a concept the framework models falls through to + # ``CustomType``, which is honest ignorance rather than a wrong answer. _SQLSERVER_INTEGER_TYPES = re.compile( - r"^(?:INT|INTEGER|BIGINT|SMALLINT|TINYINT)\b", + r"^(?:INT8|BIGINT|SMALLINT|INT2|TINYINT|INT1|INTEGER|INT)\b", re.IGNORECASE, ) _SQLSERVER_FLOAT_TYPES = re.compile( - r"^(?:FLOAT|REAL)\b", + r"^(?:FLOAT|REAL|DOUBLE)\b", re.IGNORECASE, ) _SQLSERVER_DECIMAL_TYPES = re.compile( - r"^(?:DECIMAL|NUMERIC|MONEY|SMALLMONEY)\b", + r"^(?:DECIMAL|NUMERIC|DEC|MONEY|SMALLMONEY)\b", re.IGNORECASE, ) _SQLSERVER_STRING_TYPES = re.compile( - r"^(?:CHAR|VARCHAR|NVARCHAR|NCHAR|TEXT|NTEXT)\b", + r"^(?:CHARACTER|NCHAR|VARCHAR|NVARCHAR|CHAR|CLOB|NTEXT|TEXT)\b", re.IGNORECASE, ) _SQLSERVER_DATE_TYPES = re.compile( - r"^(?:DATE|DATETIME|DATETIME2|SMALLDATETIME|TIMESTAMP)\b", + r"^(?:DATETIMEOFFSET|DATETIME2|SMALLDATETIME|DATETIME|DATE)\b", + re.IGNORECASE, + ) + #: The plain calendar date, separated from the datetimes that share its + #: letters. ``startswith("DATE")`` cannot tell them apart — ``DATETIME``, + #: ``DATETIME2`` and ``SMALLDATETIME`` all begin with ``DATE`` — so the + #: check is anchored and word-bounded instead. + _SQLSERVER_DATE_ONLY_TYPES = re.compile( + r"^DATE\b", re.IGNORECASE, ) _SQLSERVER_TIME_TYPES = re.compile( @@ -301,71 +1157,198 @@ def supports_data_type_custom(self) -> bool: re.IGNORECASE, ) _SQLSERVER_BIT_TYPES = re.compile( - r"^(?:BIT)\b", + r"^(?:BIT|BOOL|BOOLEAN)\b", re.IGNORECASE, ) _SQLSERVER_BLOB_TYPES = re.compile( - r"^(?:VARBINARY|IMAGE)\b", + r"^(?:VARBINARY|IMAGE|BLOB|BYTEA)\b", re.IGNORECASE, ) + _SQLSERVER_XML_TYPES = re.compile( + r"^(?:XML)\b", + re.IGNORECASE, + ) + #: The declared argument list: ``(n)`` for a length or a precision, + #: ``(p,s)`` for precision and scale. Group 1 is always the first number, + #: group 2 the scale when one was declared — which is why ``DECIMAL(10,2)`` + #: does not silently parse as ``DECIMAL``. + _SQLSERVER_ARGUMENTS = re.compile(r"\(\s*(\d+)\s*(?:,\s*(\d+)\s*)?\)") def parse_type(self, raw: str) -> DataType: + from ..expression.types import ( + SQLServerBitType, + SQLServerImageType, + SQLServerNCharType, + SQLServerNVarCharType, + SQLServerTinyIntType, + SQLServerUniqueIdentifierType, + SQLServerVarBinaryMaxType, + SQLServerVarBinaryType, + SQLServerXmlType, + ) + stripped = raw.strip() upper = stripped.upper() - - # BIT type + arguments = self._SQLSERVER_ARGUMENTS.search(stripped) + length = int(arguments.group(1)) if arguments else None + scale = int(arguments.group(2)) if arguments and arguments.group(2) else None + + # BIT — the boolean, not MySQL/PostgreSQL's bit string. The spelling + # recorded is the concept's own, so ``BOOLEAN`` and ``BOOL`` survive the + # round trip; T-SQL's ``BIT`` belongs to ``SQLServerBitType``'s own + # name, not to the concept's list. + # + # The lookup is on the whole stripped word and reads ``None`` for a + # parenthesised form, which used to escape as a bare + # ``KeyError: 'BIT(1)'``. A width on ``bit`` is not a declaration + # this server accepts: all three live instances (SQL Server 2019 + # 15.0.4465.1, 2022 16.0.4250.1, 2025 17.0.4035.5) refuse + # ``CREATE TABLE t (b bit(1))`` with error 2716, "Cannot specify a + # column width on data type bit." So the parameter is answered with + # the ``ValueError`` this module uses for a value the server itself + # rejects (the same class as ``_check_length``), quoting the server's + # answer rather than silently dropping the width. It is deliberately + # not ``InvalidTypeNameError``: ``bit(1)`` is a valid type *name*; + # the server rejects its width. if self._SQLSERVER_BIT_TYPES.match(upper): - from ..expression.types import SQLServerBitType - return SQLServerBitType(dialect=self) + spelling = {"BIT": "boolean", "BOOL": "bool", + "BOOLEAN": "boolean"}.get(upper) + if spelling is None: + raise ValueError( + f"{stripped!r} is not a declaration this server accepts: " + f"T-SQL's ``bit`` takes no width, and SQL Server refuses " + f"one with error 2716, 'Cannot specify a column width on " + f"data type bit.' (measured live on SQL Server 2019 " + f"(15.0.4465.1), 2022 (16.0.4250.1) and 2025 " + f"(17.0.4035.5)). Declare the bare word — ``bit``, or its " + f"accepted spellings ``bool`` and ``boolean`` — without " + f"parentheses." + ) + return SQLServerBitType(dialect=self, spelling=spelling) + + # UNIQUEIDENTIFIER — the native GUID. + if upper.startswith("UNIQUEIDENTIFIER"): + return SQLServerUniqueIdentifierType(dialect=self) + + # XML — a binary XML representation, not a varchar (checked before the + # string family because ``XML`` shares no prefix with any of it). + if self._SQLSERVER_XML_TYPES.match(upper): + return SQLServerXmlType(dialect=self) - # Integer family + # Integer family: one class per width, and the spelling preserved. if self._SQLSERVER_INTEGER_TYPES.match(upper): + if upper.startswith("INT8"): + return BigIntType(dialect=self, spelling="int8") if upper.startswith("BIGINT"): - return BigIntType(dialect=self) + return BigIntType(dialect=self, spelling="bigint") + if upper.startswith("INT2"): + return SmallIntType(dialect=self, spelling="int2") if upper.startswith("SMALLINT"): - return SmallIntType(dialect=self) + return SmallIntType(dialect=self, spelling="smallint") + if upper.startswith("INT1"): + return TinyIntType(dialect=self, spelling="int1") if upper.startswith("TINYINT"): - from ..expression.types import SQLServerTinyIntType return SQLServerTinyIntType(dialect=self) - return IntegerType(dialect=self) + spelling = "integer" if upper.startswith("INTEGER") else "int" + return IntegerType(dialect=self, spelling=spelling) - # Float family + # Float family. ``DOUBLE`` / ``DOUBLE PRECISION`` is the concept SQL + # names for 8-byte floating point, which T-SQL spells ``FLOAT(53)``. if self._SQLSERVER_FLOAT_TYPES.match(upper): + if upper.startswith("DOUBLE"): + spelling = "double precision" if "PRECISION" in upper else "double" + return DoubleType(dialect=self, spelling=spelling) if upper.startswith("REAL"): return RealType(dialect=self) - nums = re.findall(r"\d+", stripped) - precision = int(nums[0]) if nums else None - return FloatType(dialect=self, precision=precision) + return FloatType(dialect=self, precision=length) - # Decimal family + # Decimal family. ``MONEY`` / ``SMALLMONEY`` are deliberately *not* + # spellings of this concept: they carry a fixed scale of 4 and a + # currency-formatted result that the schema does not record. Mapping + # them to ``DecimalType`` loses that, and it stays because the framework + # has no money concept this dialect could declare instead. if self._SQLSERVER_DECIMAL_TYPES.match(upper): - nums = re.findall(r"\d+", stripped) - if len(nums) >= 2: - return DecimalType(dialect=self, precision=int(nums[0]), scale=int(nums[1])) - if len(nums) == 1: - return DecimalType(dialect=self, precision=int(nums[0])) - return DecimalType(dialect=self) - - # String family + if upper.startswith("MONEY") or upper.startswith("SMALLMONEY"): + return DecimalType(dialect=self) + if upper.startswith("NUMERIC"): + spelling = "numeric" + elif upper.startswith("DECIMAL"): + spelling = "decimal" + else: + spelling = "dec" + return DecimalType(dialect=self, precision=length, scale=scale, + spelling=spelling) + + # String family. Fixed length and variable length are different + # concepts, so ``CHAR`` never yields a VarCharType and ``VARCHAR`` never + # yields a CharType. + # + # A word that carries no ``n`` is completed from + # ``type_parameter_defaults()`` rather than from a literal here, so the + # parser and the formatter cannot disagree about what an unsized + # declaration means on this server. ``CHARACTER VARYING`` and + # ``CHARACTER`` need no such completion: their concepts are the ones that + # already resolve to a width from the dialect they are constructed with, + # so the bare word arrives as a bound type whose width is the declared + # one. if self._SQLSERVER_STRING_TYPES.match(upper): - if "MAX" in upper or "TEXT" in upper or "NTEXT" in upper: - return TextType(dialect=self) - length_match = re.search(r"\((\d+)", stripped) - length = int(length_match.group(1)) if length_match else None - if "VARCHAR" in upper or "NVARCHAR" in upper: - return VarCharType(dialect=self, length=length or 255) - return (CharType(dialect=self, length=length) if length_match else VarCharType(dialect=self, length=255)) - - # Date/time family + # ``(MAX)`` lifts the length cap rather than setting one, in either + # character set, so it is unbounded text and not a 255-char column. + unbounded = arguments is None and "MAX" in upper + if upper.startswith("NCHAR"): + return SQLServerNCharType( + dialect=self, + length=length or self.type_parameter_defaults()["sqlserver_nchar"]["length"]) + if upper.startswith("NVARCHAR"): + if unbounded: + return TextType(dialect=self) + return SQLServerNVarCharType( + dialect=self, + length=length or self.type_parameter_defaults()["sqlserver_nvarchar"]["length"]) + if upper.startswith("VARCHAR"): + if unbounded: + return TextType(dialect=self) + return VarCharType( + dialect=self, + length=length or self.type_parameter_defaults()["varchar"]["length"]) + if upper.startswith("CHARACTER VARYING"): + return VarCharType(dialect=self, length=length, + spelling="character varying") + if upper.startswith("CHARACTER"): + return CharType(dialect=self, length=length, spelling="character") + if upper.startswith("CHAR"): + return CharType(dialect=self, length=length) + # ``CLOB`` / ``TEXT`` / ``NTEXT`` are all unbounded text: the first + # is the portable spelling, the other two the deprecated T-SQL + # ones, and all three render ``NVARCHAR(MAX)``. + spelling = "clob" if upper.startswith("CLOB") else "text" + return TextType(dialect=self, spelling=spelling) + + # Date/time family. ``DATE`` is its own concept — a calendar date with + # no time of day — and T-SQL ``TIMESTAMP`` is ``ROWVERSION``, an + # 8-byte auto-incrementing counter that is *not* a timestamp; with no + # rowversion concept to declare, that name falls through to + # ``CustomType`` rather than being filed under datetime. if self._SQLSERVER_DATE_TYPES.match(upper): - return DateTimeType(dialect=self) + if upper.startswith("DATETIMEOFFSET"): + return TimestampTzType(dialect=self, precision=length) + if self._SQLSERVER_DATE_ONLY_TYPES.match(upper): + return DateType(dialect=self) + return DateTimeType(dialect=self, precision=length) if self._SQLSERVER_TIME_TYPES.match(upper): - return TimeType(dialect=self) + return TimeType(dialect=self, precision=length) - # Blob family + # Byte-string family. ``VARBINARY(MAX)`` is the unbounded declaration + # and not a 255-byte one, so it gets its own class. if self._SQLSERVER_BLOB_TYPES.match(upper): - from ..expression.types import SQLServerVarBinaryType - return SQLServerVarBinaryType(dialect=self) + if upper.startswith("VARBINARY"): + if arguments is None and "MAX" in upper: + return SQLServerVarBinaryMaxType(dialect=self) + return SQLServerVarBinaryType(dialect=self, length=length) + if upper.startswith("IMAGE"): + return SQLServerImageType(dialect=self) + spelling = "bytea" if upper.startswith("BYTEA") else "blob" + return BlobType(dialect=self, spelling=spelling) return CustomType(dialect=self, raw=stripped) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/uuid.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/uuid.py new file mode 100644 index 0000000..7fdc0bb --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/uuid.py @@ -0,0 +1,46 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/uuid.py +"""SQL Server UUID value expressions. + +T-SQL has no ``gen_random_uuid()``. Its equivalent is ``NEWID()``, which has +existed since SQL Server 2005 and needs no extension, so unlike PostgreSQL +there is nothing version-dependent to resolve here. +""" + +from typing import Tuple, TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.expression.uuid import ( + UUIDCastExpression, + UUIDGenerationExpression, + ) + + +class SQLServerUUIDMixin: + """UUID value support for SQL Server. + + Generation, the nil/max constants and casting are all fixed spellings + here, so they sit in the inherited ``UUID_SQL`` table and need no + overridden formatter. + """ + + UUID_SQL = { + "generation": "NEWID()", + "constant": { + "nil": "CAST('00000000-0000-0000-0000-000000000000' AS UNIQUEIDENTIFIER)", + "max": "CAST('ffffffff-ffff-ffff-ffff-ffffffffffff' AS UNIQUEIDENTIFIER)", + }, + "cast": "CAST({inner} AS UNIQUEIDENTIFIER)", + } + + def supports_uuid_generation(self) -> bool: + """Whether the server can generate a UUID. + + True on every supported version: ``NEWID()`` shipped with SQL Server + 2005 and is a core function. The version gate is kept explicit rather + than returning a constant so the answer has one place to change if a + future deprecation lands. + """ + return self.version >= (9, 0, 0) + + +__all__ = ["SQLServerUUIDMixin"] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/__init__.py index a27e4a0..acb8630 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/__init__.py @@ -17,6 +17,7 @@ from .merge import SQLServerMergeSupport from .partition import SQLServerPartitionSupport from .type import SQLServerUserDefinedTypeSupport +from .data_type import SQLServerTypeSupport __all__ = [ "SQLServerIdentitySupport", @@ -37,4 +38,5 @@ "SQLServerMergeSupport", "SQLServerPartitionSupport", "SQLServerUserDefinedTypeSupport", + "SQLServerTypeSupport", ] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/data_type.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/data_type.py new file mode 100644 index 0000000..e051826 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/data_type.py @@ -0,0 +1,58 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/protocols/data_type.py +"""SQL Server column-type protocol. + +The generic ``format_data_type_`` / ``supports_data_type_`` pair is +the dispatch contract, and it is enough to render a type. It is *not* enough +to answer the questions a caller actually has about SQL Server's own types, +which is why the ones that exist only here get a stated shape: whether ``xml`` +is a real type here or a text column in disguise, whether the 8-bit integer is +signed, whether ``BIT`` is a boolean or a bit string. Those are the questions +this backend got wrong, so they are the ones the protocol asks. + +Membership is structural (``runtime_checkable``), so ``isinstance(dialect, +SQLServerTypeSupport)`` succeeds for any dialect that implements the family — +the point being that the family has one declared shape rather than being +merely implied by a naming pattern. +""" + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class SQLServerTypeSupport(Protocol): + """The support switches for the types SQL Server spells its own way.""" + + def supports_data_type_sqlserver_nvarchar(self) -> bool: + ... + + def supports_data_type_sqlserver_nchar(self) -> bool: + ... + + def supports_data_type_sqlserver_nvarchar_max(self) -> bool: + ... + + def supports_data_type_sqlserver_varbinary(self) -> bool: + ... + + def supports_data_type_sqlserver_varbinary_max(self) -> bool: + ... + + def supports_data_type_sqlserver_image(self) -> bool: + """``IMAGE`` is creatable on every version here but deprecated since + 2005; a backend that stops accepting it must change this gate.""" + ... + + def supports_data_type_sqlserver_tinyint(self) -> bool: + ... + + def supports_data_type_sqlserver_bit(self) -> bool: + ... + + def supports_data_type_sqlserver_xml(self) -> bool: + ... + + def supports_data_type_sqlserver_uniqueidentifier(self) -> bool: + ... + + +__all__ = ["SQLServerTypeSupport"] diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/sqlserver/test_uuid_and_json_truth.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/sqlserver/test_uuid_and_json_truth.py new file mode 100644 index 0000000..373b147 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/sqlserver/test_uuid_and_json_truth.py @@ -0,0 +1,212 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/sqlserver/test_uuid_and_json_truth.py +"""UUID values and JSON honesty on SQL Server. + +Three things this backend got wrong, all of the same shape: a probe or a +factory that named SQL Server does not have. ``NEWID()`` was absent +entirely, four JSON factories emitted MySQL function names, and +``supports_json_table`` claimed a capability the formatter refused. + +No database is needed: every assertion is on rendered SQL. +""" + +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/sqlserver/test_uuid_and_json_truth.py +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression import ( + Column, + Literal, + StringColumn, + UUIDCastExpression, + UUIDConstantExpression, + UUIDGenerationExpression, +) +from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect +from rhosocial.activerecord.backend.impl.sqlserver.expression.types import ( + SQLServerUniqueIdentifierType, +) +from rhosocial.activerecord.backend.impl.sqlserver.functions import json as json_functions + + +def _dialect(version=(16, 0, 0)): + dialect = SQLServerDialect() + dialect._version = version + return dialect + + +# --------------------------------------------------------------------------- +# UUID values +# --------------------------------------------------------------------------- + + +def test_generation_uses_newid(): + assert UUIDGenerationExpression(_dialect()).to_sql() == ("NEWID()", ()) + + +def test_generation_carries_its_alias(): + dialect = _dialect() + ident = dialect.format_identifier("u") + assert UUIDGenerationExpression(dialect, alias="u").to_sql() == ( + f"NEWID() AS {ident}", + (), + ) + + +def test_generation_needs_no_extension(): + """NEWID() is a core function from SQL Server 2005.""" + assert _dialect((9, 0, 0)).supports_uuid_generation() is True + + +@pytest.mark.parametrize( + "which, expected", + [ + ( + "nil", + "CAST('00000000-0000-0000-0000-000000000000' AS UNIQUEIDENTIFIER)", + ), + ( + "max", + "CAST('ffffffff-ffff-ffff-ffff-ffffffffffff' AS UNIQUEIDENTIFIER)", + ), + ], +) +def test_constants_are_cast_literals(which, expected): + assert UUIDConstantExpression(_dialect(), which).to_sql() == (expected, ()) + + +def test_cast_uses_the_t_sql_form(): + dialect = _dialect() + expr = UUIDCastExpression(dialect, Literal(dialect, "not-a-uuid")) + assert expr.to_sql() == ("CAST(? AS UNIQUEIDENTIFIER)", ("not-a-uuid",)) + + +# --------------------------------------------------------------------------- +# UNIQUEIDENTIFIER as a real column type +# --------------------------------------------------------------------------- + + +def test_uniqueidentifier_renders(): + assert _dialect().format_data_type(SQLServerUniqueIdentifierType()) == ( + "UNIQUEIDENTIFIER", + (), + ) + + +def test_type_name_is_namespaced_to_this_backend(): + """The prefix keeps format_data_type_ families from colliding.""" + assert SQLServerUniqueIdentifierType.name == "sqlserver_uniqueidentifier" + + +def test_suggestion_does_not_point_at_a_type_this_dialect_renders(): + """A suggestion exists precisely for types the dialect cannot render.""" + dialect = _dialect() + suggestions = dialect.suggested_data_types() + assert suggestions["uuid"] is SQLServerUniqueIdentifierType + assert suggestions["uuid"].name != "uuid" + + +def test_introspection_round_trips(): + dialect = _dialect() + parsed = dialect.parse_type("UNIQUEIDENTIFIER") + assert isinstance(parsed, SQLServerUniqueIdentifierType) + assert dialect.format_data_type(parsed) == ("UNIQUEIDENTIFIER", ()) + + +# --------------------------------------------------------------------------- +# JSON factories that used to emit MySQL +# --------------------------------------------------------------------------- + + +def test_json_unquote_becomes_json_value(): + """T-SQL has no JSON_UNQUOTE; JSON_VALUE already returns a bare scalar.""" + expr = json_functions.json_unquote(_dialect(), "data", path="$.name") + assert type(expr).__name__ == "SQLServerJSONExtractExpression" + assert "JSON_VALUE" in expr.to_sql()[0] + + +def test_json_unquote_without_a_path_is_refused(): + """There is nothing to extract, so the request cannot be expressed.""" + with pytest.raises(UnsupportedFeatureError, match="without a path"): + json_functions.json_unquote(_dialect(), "data") + + +def test_json_contains_becomes_an_openjson_predicate(): + expr = json_functions.json_contains(_dialect(), "data", "x", path="$.tags") + assert type(expr).__name__ == "SQLServerJSONContainsExpression" + sql, params = expr.to_sql() + assert "OPENJSON" in sql and "EXISTS" in sql + assert params == ("$.tags", "x") + + +@pytest.mark.parametrize("factory, args", [("json_type", ("data",)), ("json_search", ("data", "x"))]) +def test_functions_sql_server_lacks_are_refused(factory, args): + with pytest.raises(UnsupportedFeatureError): + getattr(json_functions, factory)(_dialect(), *args) + + +@pytest.mark.parametrize( + "factory, args, kwargs", + [ + ("json_unquote", ("data",), {}), # no path -> refuses + ("json_unquote", ("data",), {"path": "$.a"}), # -> JSON_VALUE + ("json_contains", ("data", "x"), {}), # -> OPENJSON + ("json_contains", ("data", "x"), {"path": "$.tags"}), + ("json_type", ("data",), {}), + ("json_search", ("data", "x"), {}), + ], +) +def test_no_factory_emits_a_mysql_function_name(factory, args, kwargs): + """The four factories bypassed the formatter that already refused these. + + Scoped to the four factories under test rather than sweeping the module: + the other JSON factories take argument shapes a generic call cannot + supply — nested path lists — so a sweeping loop fails inside + format_function_call on a filler call instead of on the defect. A refusal + counts as the correct outcome, since it means the name is unreachable. + """ + dialect = _dialect() + try: + rendered = getattr(json_functions, factory)(dialect, *args, **kwargs).to_sql()[0] + except UnsupportedFeatureError: + return + + for foreign in ("JSON_UNQUOTE", "JSON_CONTAINS", "JSON_TYPE", "JSON_SEARCH"): + assert foreign not in rendered, f"{factory} rendered {rendered!r}" + + +# --------------------------------------------------------------------------- +# JSON_TABLE +# --------------------------------------------------------------------------- + + +def test_json_table_is_not_claimed(): + """The formatter always refused, so the probe claiming True was a trap.""" + assert _dialect().supports_json_table() is False + + +def test_json_table_error_points_at_openjson(): + with pytest.raises(UnsupportedFeatureError, match="OPENJSON"): + _dialect().format_json_table_expression(None) + + +# --------------------------------------------------------------------------- +# OUTPUT clause accepts typed columns +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("column_class", [Column, StringColumn]) +def test_output_clause_recognises_both_column_kinds(column_class): + """A typed column must not fall through to the expressions branch. + + `ColumnBase` used to be the second kind; it is now an ABC and cannot be + built, so the pair is the bare reference plus a concrete typed column. + """ + from rhosocial.activerecord.backend.expression.statements.dml import ReturningClause + + dialect = _dialect() + clause = ReturningClause( + dialect, expressions=[column_class(dialect, "id", table="INSERTED")] + ) + sql, params = dialect.format_returning_clause(clause) + assert sql.startswith("OUTPUT INSERTED.") + assert params == () diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_data_type_parsing.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_data_type_parsing.py index 821d031..adff97d 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_data_type_parsing.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_data_type_parsing.py @@ -1,22 +1,37 @@ import pytest from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect +from rhosocial.activerecord.backend.impl.sqlserver.expression.types import ( + SQLServerBitType, + SQLServerImageType, + SQLServerNCharType, + SQLServerNVarCharType, + SQLServerTinyIntType, + SQLServerUniqueIdentifierType, + SQLServerVarBinaryMaxType, + SQLServerVarBinaryType, + SQLServerXmlType, +) from rhosocial.activerecord.backend.expression.types._base import DataType from rhosocial.activerecord.backend.expression.types import ( BigIntType, + BlobType, BooleanType, CharType, CustomType, DateTimeType, + DateType, DecimalType, + DoubleType, FloatType, IntegerType, RealType, SmallIntType, TextType, TimeType, + TimestampTzType, + TinyIntType, VarCharType, ) -from rhosocial.activerecord.backend.expression.types import BlobType SQL_SERVER_2022 = (16, 0, 0) @@ -36,11 +51,17 @@ def test_parse_integer_types(self, dialect): assert isinstance(self._parse(dialect, "INTEGER"), IntegerType) assert isinstance(self._parse(dialect, "BIGINT"), BigIntType) assert isinstance(self._parse(dialect, "SMALLINT"), SmallIntType) - assert isinstance(self._parse(dialect, "TINYINT"), IntegerType) + # TINYINT is the 1-byte integer, not a small INTEGER: 0-255 against + # -2147483648..2147483647. The class used to claim INTEGER. + assert isinstance(self._parse(dialect, "TINYINT"), SQLServerTinyIntType) + assert isinstance(self._parse(dialect, "TINYINT"), TinyIntType) + assert not isinstance(self._parse(dialect, "TINYINT"), IntegerType) def test_parse_float_types(self, dialect): assert isinstance(self._parse(dialect, "FLOAT"), FloatType) assert isinstance(self._parse(dialect, "REAL"), RealType) + assert isinstance(self._parse(dialect, "DOUBLE"), DoubleType) + assert isinstance(self._parse(dialect, "DOUBLE PRECISION"), DoubleType) def test_parse_decimal_types(self, dialect): dt = self._parse(dialect, "DECIMAL(10,2)") @@ -68,7 +89,7 @@ def test_parse_string_types(self, dialect): assert dt.length == 255 dt2 = self._parse(dialect, "NVARCHAR(100)") - assert isinstance(dt2, VarCharType) + assert isinstance(dt2, SQLServerNVarCharType) dt3 = self._parse(dialect, "CHAR(10)") assert isinstance(dt3, CharType) @@ -82,19 +103,107 @@ def test_parse_string_types(self, dialect): dt6 = self._parse(dialect, "NVARCHAR(MAX)") assert isinstance(dt6, TextType) + def test_char_is_fixed_length_not_variable(self, dialect): + """The bug this parser had: ``CHAR`` yielded ``VarCharType``. + + A fixed-length character value is blank-padded to its declared width, + which is exactly what ``VarCharType`` says it is not, so the parser was + describing the wrong storage — and re-rendered ``CHAR(10)`` as + ``VARCHAR(255)``. + """ + for raw in ("CHAR", "CHAR(10)"): + parsed = self._parse(dialect, raw) + assert isinstance(parsed, CharType), f"{raw} -> {type(parsed).__name__}" + assert not isinstance(parsed, VarCharType) + assert dialect.format_data_type(self._parse(dialect, "CHAR(10)"))[0] == "CHAR(10)" + + def test_varchar_is_variable_length_not_fixed(self, dialect): + parsed = self._parse(dialect, "VARCHAR(255)") + assert isinstance(parsed, VarCharType) + assert not isinstance(parsed, CharType) + + def test_nchar_is_its_own_class(self, dialect): + """``NCHAR`` is SQL Server's own fixed-length Unicode type. + + It does not have to resolve to ``SQLServerNCharType`` for correctness — + ``CharType`` would carry the right width — but the parser is what feeds + a schema diff, and re-rendering an ``NCHAR`` column as ``CHAR`` would + silently change its code page. + """ + parsed = self._parse(dialect, "NCHAR(10)") + assert isinstance(parsed, SQLServerNCharType) + assert dialect.format_data_type(parsed)[0] == "NCHAR(10)" + def test_parse_date_time_types(self, dialect): - assert isinstance(self._parse(dialect, "DATE"), DateTimeType) + # DATE is its own concept: a calendar date with no time of day. It + # yielded DateTimeType, which renders DATETIME2. + assert isinstance(self._parse(dialect, "DATE"), DateType) assert isinstance(self._parse(dialect, "DATETIME"), DateTimeType) assert isinstance(self._parse(dialect, "DATETIME2"), DateTimeType) assert isinstance(self._parse(dialect, "SMALLDATETIME"), DateTimeType) assert isinstance(self._parse(dialect, "TIME"), TimeType) + assert isinstance(self._parse(dialect, "DATETIMEOFFSET"), TimestampTzType) + + def test_declared_precision_survives_a_round_trip(self, dialect): + """``DATETIME2(3)`` used to parse as a bare ``DATETIME2``. + + Precision is in the declaration and in ``PARAMETERS``, so losing it + here made a diff report a change on a column that had not changed. + """ + for raw, expected in ( + ("DATETIME2(3)", "DATETIME2(3)"), + ("DATETIMEOFFSET(7)", "DATETIMEOFFSET(7)"), + ("TIME(3)", "TIME(3)"), + ("DECIMAL(10,2)", "DECIMAL(10, 2)"), + ("FLOAT(24)", "FLOAT(24)"), + ("VARBINARY(64)", "VARBINARY(64)"), + ): + parsed = self._parse(dialect, raw) + assert dialect.format_data_type(parsed)[0] == expected, raw def test_parse_bit_type(self, dialect): + # BIT is the boolean on SQL Server, so the parser yields the boolean + # class rather than a bit string. + assert isinstance(self._parse(dialect, "BIT"), SQLServerBitType) assert isinstance(self._parse(dialect, "BIT"), BooleanType) + def test_parse_xml_type(self, dialect): + """``xml`` is a binary representation on SQL Server, not a varchar.""" + parsed = self._parse(dialect, "xml") + assert isinstance(parsed, SQLServerXmlType) + assert dialect.format_data_type(parsed)[0] == "XML" + def test_parse_binary_types(self, dialect): + assert isinstance(self._parse(dialect, "VARBINARY(MAX)"), SQLServerVarBinaryMaxType) assert isinstance(self._parse(dialect, "VARBINARY(MAX)"), BlobType) - assert isinstance(self._parse(dialect, "IMAGE"), BlobType) + assert isinstance(self._parse(dialect, "IMAGE"), SQLServerImageType) + assert isinstance(self._parse(dialect, "VARBINARY"), SQLServerVarBinaryType) + + def test_parse_varbinary_max_is_not_a_255_byte_column(self, dialect): + """``VARBINARY(MAX)`` used to parse as ``VARBINARY(255)``. + + Same storage, wrong declaration: the 2 GB form and the 255-byte form are + different columns as far as a schema is concerned. + """ + parsed = self._parse(dialect, "VARBINARY(MAX)") + assert dialect.format_data_type(parsed)[0] == "VARBINARY(MAX)" + + def test_parse_uniqueidentifier(self, dialect): + parsed = self._parse(dialect, "UNIQUEIDENTIFIER") + assert isinstance(parsed, SQLServerUniqueIdentifierType) + assert dialect.format_data_type(parsed)[0] == "UNIQUEIDENTIFIER" + + def test_parse_timestamp_is_rowversion_and_says_nothing_it_does_not_know(self, dialect): + """T-SQL ``TIMESTAMP`` is ``ROWVERSION``, not a timestamp. + + It used to parse as ``DateTimeType``, which renders ``DATETIME2`` — an + 8-byte auto-incrementing counter turned into a date. There is no + rowversion concept to yield, so ``CustomType`` is the honest answer and + it round-trips exactly. + """ + parsed = self._parse(dialect, "timestamp") + assert isinstance(parsed, CustomType) + assert dialect.format_data_type(parsed)[0] == "timestamp" def test_parse_unknown_type(self, dialect): result = self._parse(dialect, "GEOGRAPHY") @@ -104,3 +213,85 @@ def test_parse_type_case_insensitive(self, dialect): assert isinstance(self._parse(dialect, "int"), IntegerType) assert isinstance(self._parse(dialect, "VARCHAR(50)"), VarCharType) assert isinstance(self._parse(dialect, "decimal(8,2)"), DecimalType) + + def test_spellings_parse_to_their_own_concept(self, dialect): + """Every concept's ``SPELLINGS`` maps back to that concept (D8). + + The failure this guards is a parser that reads ``character varying`` and + hands back ``CharType``: one spelling in, a *different* class out, which + is the bug ``SPELLINGS`` exists to make impossible. + """ + cases = [ + ("integer", IntegerType, ["integer", "int"]), + ("bigint", BigIntType, ["bigint", "int8"]), + ("smallint", SmallIntType, ["smallint", "int2"]), + ("tinyint", TinyIntType, ["tinyint", "int1"]), + ("double", DoubleType, ["double", "double precision"]), + ("decimal", DecimalType, ["decimal", "numeric", "dec"]), + ("boolean", BooleanType, ["boolean", "bool"]), + ("char", CharType, ["char", "character"]), + ("varchar", VarCharType, ["varchar", "character varying"]), + ("text", TextType, ["text", "clob"]), + ("blob", BlobType, ["blob", "bytea"]), + ] + for concept, expected_cls, spellings in cases: + for spelling in spellings: + parsed = self._parse(dialect, spelling) + assert isinstance(parsed, expected_cls), ( + f"{spelling!r} parsed as {type(parsed).__name__}, " + f"expected {expected_cls.__name__}" + ) + assert parsed.spelling == spelling, ( + f"{spelling!r} lost its spelling: {parsed.spelling!r}" + ) + + @pytest.mark.parametrize( + "raw, expected", + [ + ("INT", "INT"), + ("INTEGER", "INT"), + ("INT8", "BIGINT"), + ("INT2", "SMALLINT"), + ("INT1", "TINYINT"), + ("TINYINT", "TINYINT"), + ("CHAR", "CHAR(1)"), + ("CHAR(10)", "CHAR(10)"), + ("CHARACTER", "CHAR(1)"), + ("CHARACTER VARYING", "VARCHAR(255)"), + ("CHARACTER VARYING(50)", "VARCHAR(50)"), + ("VARCHAR", "VARCHAR(255)"), + ("VARCHAR(255)", "VARCHAR(255)"), + ("VARCHAR(MAX)", "NVARCHAR(MAX)"), + ("NVARCHAR", "NVARCHAR(255)"), + ("NVARCHAR(100)", "NVARCHAR(100)"), + ("NVARCHAR(MAX)", "NVARCHAR(MAX)"), + ("NCHAR", "NCHAR(1)"), + ("NCHAR(10)", "NCHAR(10)"), + ("TEXT", "NVARCHAR(MAX)"), + ("NTEXT", "NVARCHAR(MAX)"), + ("CLOB", "NVARCHAR(MAX)"), + ("BLOB", "VARBINARY(MAX)"), + ("BYTEA", "VARBINARY(MAX)"), + ("BOOLEAN", "BIT"), + ("BOOL", "BIT"), + ("BIT", "BIT"), + ("DOUBLE", "FLOAT(53)"), + ("DOUBLE PRECISION", "FLOAT(53)"), + ("DEC", "DECIMAL"), + ("DEC(5,2)", "DECIMAL(5, 2)"), + ("DATE", "DATE"), + ("DATETIME", "DATETIME2"), + ("DATETIME2", "DATETIME2"), + ("SMALLDATETIME", "DATETIME2"), + ("DATETIMEOFFSET", "DATETIMEOFFSET"), + ("TIME", "TIME"), + ("XML", "XML"), + ("UNIQUEIDENTIFIER", "UNIQUEIDENTIFIER"), + ("VARBINARY", "VARBINARY(255)"), + ("VARBINARY(MAX)", "VARBINARY(MAX)"), + ("IMAGE", "IMAGE"), + ], + ) + def test_parse_render_round_trip(self, dialect, raw, expected): + """Every spelling the parser accepts renders back to stable SQL.""" + assert dialect.format_data_type(self._parse(dialect, raw))[0] == expected diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_ddl_source_mixin.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_ddl_source_mixin.py index 18c1070..f59377f 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_ddl_source_mixin.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_ddl_source_mixin.py @@ -7,7 +7,7 @@ import pytest -from rhosocial.activerecord.backend.expression import ColumnDefinition +from rhosocial.activerecord.backend.expression import ColumnDefinition, ComparisonPredicate from rhosocial.activerecord.backend.expression.core import Column, Literal from rhosocial.activerecord.backend.expression.statements import ( ColumnConstraintType, @@ -66,11 +66,11 @@ def apply(cls, new_class, field_name, annotation, metadata): def check_condition(dialect): - return Column(dialect, "value_col") == "active" + return ComparisonPredicate(dialect, "=", Column(dialect, "value_col"), Literal(dialect, "active")) def index_condition(dialect): - return Column(dialect, "value_col") == "active" + return ComparisonPredicate(dialect, "=", Column(dialect, "value_col"), Literal(dialect, "active")) PRIMARY_TYPE = SQLServerNVarCharType(length=64) @@ -172,7 +172,7 @@ def index_condition(dialect): TABLE_SPACE = "ts_data" BATCH_METHODS = ( "columns_name", - "columns_type", + "columns_data_type", "columns_constraints", "columns_attributes", "columns_indexes", @@ -306,12 +306,12 @@ def test_field_declarations_are_collected_in_declaration_order(model): } assert model.column_name("pk_field") == "id" assert model.column_name("value_field") == "value_col" - assert model.column_type("value_field") is TYPE_MARKER - assert model.column_type("value_field").data_types == (PRIMARY_TYPE, FALLBACK_TYPE) - assert model.column_type("value_field").data_types[0] is PRIMARY_TYPE - assert model.column_type("value_field").data_types[1] is FALLBACK_TYPE - assert isinstance(model.column_type("value_field").data_type, SQLServerNVarCharType) - assert model.column_type("value_field").data_type.length == 64 + assert model.column_data_type("value_field") is TYPE_MARKER + assert model.column_data_type("value_field").data_types == (PRIMARY_TYPE, FALLBACK_TYPE) + assert model.column_data_type("value_field").data_types[0] is PRIMARY_TYPE + assert model.column_data_type("value_field").data_types[1] is FALLBACK_TYPE + assert isinstance(model.column_data_type("value_field").data_type, SQLServerNVarCharType) + assert model.column_data_type("value_field").data_type.length == 64 value_constraints = model.column_constraints("value_field") assert value_constraints[0] is DEFAULT_CONSTRAINT.constraint @@ -355,7 +355,7 @@ def test_field_declarations_are_collected_in_declaration_order(model): def test_collected_backend_data_type_renders_with_dialect(): dialect = SQLServerDialect((16, 0, 0)) - data_type = SyncModel.column_type("value_field").data_types[0] + data_type = SyncModel.column_data_type("value_field").data_types[0] assert isinstance(data_type, SQLServerNVarCharType) data_type.dialect = dialect try: @@ -442,7 +442,7 @@ def test_default_declarations_and_batch_interfaces(model): ColumnConstraintType.NOT_NULL, ] assert model.column_constraints("optional") == [] - assert model.columns_type() == {"id": None, "optional": None} + assert model.columns_data_type() == {"id": None, "optional": None} assert model.columns_constraints()["optional"] == [] assert model.columns_attributes() == {"id": [], "optional": []} assert model.columns_indexes() == {"id": [], "optional": []} @@ -467,7 +467,7 @@ def test_batch_interfaces_preserve_requested_field_order(model): fields = ["value_field", "pk_field", "optional_field"] names = model.columns_name(fields) - types = model.columns_type(fields) + types = model.columns_data_type(fields) constraints = model.columns_constraints(fields) attributes = model.columns_attributes(fields) indexes = model.columns_indexes(fields) @@ -500,7 +500,7 @@ def test_batch_interfaces_preserve_requested_field_order(model): def test_sync_and_async_ddl_sources_collect_the_same_declarations(): assert SyncModel.columns_name() == AsyncModel.columns_name() - assert SyncModel.columns_type() == AsyncModel.columns_type() + assert SyncModel.columns_data_type() == AsyncModel.columns_data_type() assert SyncModel.columns_attributes() == AsyncModel.columns_attributes() assert SyncModel.columns_comment() == AsyncModel.columns_comment() assert SyncModel.columns_generated() == AsyncModel.columns_generated() 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 121b59b..5a3f733 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 @@ -631,7 +631,11 @@ def dialect(self): def test_json_support_2022(self, dialect): assert dialect.supports_json_type() is True - assert dialect.supports_json_table() is True + # SQL Server has no JSON_TABLE; the capability is OPENJSON. The probe + # used to claim True on 2016+ while format_json_table_expression + # raised unconditionally, so a caller trusting it got an exception at + # render time. + assert dialect.supports_json_table() is False def test_json_access_operator(self, dialect): assert dialect.get_json_access_operator() is None 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 7279803..89d96d5 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 @@ -143,10 +143,16 @@ def test_json_function_gates(self, function, version, expected): @pytest.mark.parametrize( ("version", "json_bits"), [ + # supports_json_table is False on every version. SQL Server has no + # JSON_TABLE; the capability is OPENJSON, which + # SQLServerOpenJsonExpression implements. This used to claim 13.0+ + # while format_json_table_expression raised unconditionally, and + # test_json_function_gates above already asserted the opposite for + # the same backend — the two tests contradicted each other. ((11, 0, 0), {"supports_json_type": False, "supports_json_table": False}), - ((13, 0, 0), {"supports_json_type": True, "supports_json_table": True}), - ((15, 0, 0), {"supports_json_type": True, "supports_json_table": True}), - ((16, 0, 0), {"supports_json_type": True, "supports_json_table": True}), + ((13, 0, 0), {"supports_json_type": True, "supports_json_table": False}), + ((15, 0, 0), {"supports_json_type": True, "supports_json_table": False}), + ((16, 0, 0), {"supports_json_type": True, "supports_json_table": False}), ], ) def test_json_capability_bits_per_version(self, version, json_bits): 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 8894b75..d6804df 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 @@ -37,15 +37,40 @@ 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. +* ``UnsupportedFeatureError`` from a probe inside a formatter, for 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. +* ``UnsupportedFeatureError`` from the dispatch, for a class not named in the + dict -- the dialect declares no ``format_*`` method for it at all, which is + a wiring fact about this backend rather than a capability answer. The + method the dispatch names must be a row in :data:`UNMODELLED_FORMATTERS`, + so the gap is a decision with a reason rather than an omission; see + :func:`_dispatched_formatter`. * **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`. +Why the dispatch's refusal is no longer allowed on its type alone +================================================================= + +The two ``UnsupportedFeatureError`` outcomes above were one branch until core +turned ``trim``/``lpad``/``rpad``/``repeat`` into dedicated nodes. SQL Server +spells ``TRIM``/``LTRIM``/``RTRIM`` and has no ``LPAD``, ``RPAD`` or ``REPEAT`` +at all, so those three nodes reach a dispatch that finds no formatter -- and +before this split, that refusal read as "SQL Server lacks the feature" and +passed, indistinguishable from a probe declining something SQL Server +genuinely declines. The exception itself records who is refusing: a probe +names the feature, while the dispatch frames the formatting method it could +not find as ``the '' statement`` -- see :func:`_dispatched_formatter`. +Only the dispatch's refusal is a wiring fact about this backend, so only it +must be accounted for: +:meth:`TestMatrixIntegrity.test_unmodelled_formatter_list_is_exact` pins +:data:`UNMODELLED_FORMATTERS` in both directions, and a method nobody listed +fails the run. + And when a class cannot be constructed ====================================== @@ -102,6 +127,8 @@ ) from rhosocial.activerecord.backend.expression.statements.dql import QueryExpression from rhosocial.activerecord.backend.expression.types import IntegerType +from rhosocial.activerecord.backend.expression.types.custom import CustomType +from rhosocial.activerecord.backend.expression.uuid import UUIDConstantExpression from rhosocial.activerecord.testsuite.utils.expression import ( collect_expression_classes, make_instance, @@ -694,6 +721,18 @@ def shortest_path(d): register_special_constructor( "graph.SQLServerShortestPathExpression", shortest_path ) + # -- two core classes that build as soon as they get a valid argument ---- + # Both were measured before being named here: with ``raw`` carrying a real + # type name CUSTOM renders it, and with ``which="nil"`` the UUID constant + # renders the nil literal cast to UNIQUEIDENTIFIER. Neither is a skip, and + # neither belongs in UNCONSTRUCTIBLE -- a constructor is the honest answer + # for a class that constructs fine once its own slot is filled. + register_special_constructor( + "types.custom.CustomType", lambda d: CustomType(d, "VARCHAR(10)") + ) + register_special_constructor( + "uuid.UUIDConstantExpression", lambda d: UUIDConstantExpression(d, "nil") + ) register_specials() @@ -1000,9 +1039,6 @@ def shortest_path(d): "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 ), @@ -1049,6 +1085,125 @@ def shortest_path(d): } +# --------------------------------------------------------------------------- +# The dispatch gap: a missing formatter is a decision, not an omission +# --------------------------------------------------------------------------- +# +# ``to_sql()`` reports a dialect that names no formatter for a class through +# the same ``UnsupportedFeatureError`` a capability probe uses, so the type +# alone cannot say "this backend never wired the feature in". The frame the +# dispatch puts around the method name can, and it is the only place in the +# tree that opens it -- see :func:`_dispatched_formatter`. + +#: The two ends of the frame ``to_sql()`` puts around a formatting method name +#: in the ``feature_name`` of the ``UnsupportedFeatureError`` it raises for a +#: dialect that has no such formatter. +_DISPATCH_FEATURE_PREFIX = "the '" +_DISPATCH_FEATURE_SUFFIX = "' statement" + + +def _dispatched_formatter(exc): + """The formatting method *exc* says the dialect does not have, or ``None``. + + ``None`` means *exc* is not the dispatch reporting a missing formatter -- + it is a probe inside a formatter that refused a feature, which is the + ``"unsupported"`` branch and needs no row anywhere. + + Args: + exc: An :class:`UnsupportedFeatureError` raised out of ``to_sql()``. + + Returns: + The method name out of the ``feature_name`` frame, or ``None`` when + *exc* came from a probe rather than from the dispatch. + """ + feature = exc.feature_name + if not feature.startswith(_DISPATCH_FEATURE_PREFIX): + return None + if not feature.endswith(_DISPATCH_FEATURE_SUFFIX): + return None + method = feature[len(_DISPATCH_FEATURE_PREFIX) : len(feature) - len(_DISPATCH_FEATURE_SUFFIX)] + return method if method else None + + +#: Formatters SQL Server declares no implementation of, grouped by the feature +#: that is absent, each group naming every method the matrix's classes need. +#: +#: A class needing one of these fails with ``UnsupportedFeatureError`` naming +#: the method it wanted, and :func:`assert_sql_roundtrip_classified` asserts +#: that method is a row here. :meth:`TestMatrixIntegrity.\ +#: test_unmodelled_formatter_list_is_exact` pins the table in both directions: +#: a formatter that starts existing moves its classes out of the table and the +#: pin fails until the entry goes, and a class that starts needing a method +#: outside the table fails outright instead of passing as "unsupported". +#: +#: These are not defects: they are features SQL Server does not have, which the +#: shared renderer reports by naming the method it could not find. The +#: pad/repeat group is the one that made the accounting necessary: SQL Server +#: has no ``LPAD``/``RPAD``/``REPEAT`` functions, the nodes dispatch to +#: formatters no base in this dialect provides, and before the table existed +#: those refusals were allowed on their type alone. +UNMODELLED_FORMATTERS = { + "SQL/XML (SQL Server's native XML/XQuery is not the SQL/XML feature set, " + "so the dialect declares no ``format_xml*`` method at all)": ( + "format_xmlagg_expression", + "format_xmlattributes_expression", + "format_xmlcomment_expression", + "format_xmlconcat_expression", + "format_xmlelement_expression", + "format_xmlexists_expression", + "format_xmlforest_expression", + "format_xmlpi_expression", + "format_xmlparse_expression", + "format_xmlquery_expression", + "format_xmlroot_expression", + "format_xmlserialize_expression", + "format_xmltable_expression", + ), + "DOMAIN (SQL Server has no DOMAIN object, so no domain statement or " + "action renders)": ( + "format_create_domain_statement", + "format_alter_domain_statement", + "format_drop_domain_statement", + "format_domain_alter_action", + "format_domain_check_constraint", + "format_domain_value_expression", + ), + "SQL/PGQ property graphs (no GRAPH_TABLE, so no graph DDL either)": ( + "format_create_property_graph_statement", + "format_alter_property_graph_statement", + "format_drop_property_graph_statement", + "format_graph_table_expression", + "format_vertex_table", + "format_edge_table", + "format_graph_columns_clause", + "format_table_properties_clause", + ), + "COMMENT ON (SQL Server has no standalone COMMENT ON; extended properties " + "spell it, and the dialect's negative list records CommentSupport)": ( + "format_comment_statement", + ), + "LPAD / RPAD (SQL Server has no pad functions; padding is spelled with " + "REPLICATE together with RIGHT / LEFT)": ( + "format_lpad_expression", + "format_rpad_expression", + ), + "REPEAT (SQL Server spells it REPLICATE)": ("format_repeat_expression",), + "the core PIVOT / UNPIVOT expressions (SQL Server spells PIVOT its own " + "way, through SQLServerPivotExpression; core's generic PIVOT is not the " + "same statement)": ( + "format_pivot_expression", + "format_unpivot_expression", + ), + "the TableSource root (every concrete row source overrides its formatter; " + "the root carries nothing but an alias and is never rendered)": ("format_table_source",), +} + + +def _unmodelled_methods(): + """The flat set of formatter names in :data:`UNMODELLED_FORMATTERS`.""" + return {method for methods in UNMODELLED_FORMATTERS.values() for method in methods} + + # --------------------------------------------------------------------------- # The local SQL assertion: classify the outcome instead of swallowing it # --------------------------------------------------------------------------- @@ -1068,6 +1223,10 @@ def assert_sql_roundtrip_classified( * ``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. + * ``UnsupportedFeatureError`` naming a formatting method, for a class not + named in the dict -- the dispatch found nothing, which is a wiring fact + about this backend rather than a capability answer. The method must be + in :data:`UNMODELLED_FORMATTERS`; the table entry is the reason. * **anything else** -- a failure naming the class and the exception. The dict is consulted before the ``UnsupportedFeatureError`` branch, not @@ -1115,6 +1274,17 @@ def assert_sql_roundtrip_classified( ) return "non-render" if type(exc) is UnsupportedFeatureError: + method = _dispatched_formatter(exc) + if method is not None: + assert method in _unmodelled_methods(), ( + f"{fqn}: to_sql() reported that {exc.dialect_name} declares no " + f"{method!r}, which is not in UNMODELLED_FORMATTERS.\n" + f" Either SQL Server now needs that formatter -- in which " + f"case the class should render and this entry should go -- " + f"or the feature is absent and the method belongs in that " + f"table with its reason." + ) + return "unmodelled" return "unsupported" raise AssertionError( f"{fqn}: to_sql() raised {type(exc).__name__}, which is neither a " @@ -1246,6 +1416,61 @@ def test_pinned_non_render_really_does_not_render(self, sqlserver_dialect): f"{exc_info.value}" ) + def test_unmodelled_formatter_list_is_exact(self, sqlserver_dialect): + """Pin the unmodelled-formatter table against what the dialect lacks. + + Observed rather than assumed: for each class the matrix covers, either + it renders or it fails, and every dispatch refusal is attributed to the + method it named -- including the classes pinned in + :data:`LEGITIMATE_NON_RENDERS`, whose dispatch failures those pins + record class by class. Then the table is compared with the set + observed, in both directions, so: + + * a method in the table that SQL Server now implements fails here, + because its classes render and are no longer attributed to it -- + which is the moment to delete the entry rather than leave a lie in + the table; + * a class that starts needing a method outside the table fails the + per-class assertion in :func:`assert_sql_roundtrip_classified` + instead of being absorbed into "unsupported". + + This is the regression test for the pad/trim incident. Core turned + ``trim``/``lpad``/``rpad``/``repeat`` into dedicated nodes; SQL Server + renders ``trim`` and has no ``LPAD``, ``RPAD`` or ``REPEAT``, so those + three nodes are exactly the shape this test exists for: classes whose + formatter no base in this dialect provides, which before the split + were allowed through as "SQL Server lacks the feature" without + anything asking whether the feature was even claimed. + """ + observed = set() + for fqn in sorted(REGISTERED): + instance, source = make_instance(REGISTERED[fqn], sqlserver_dialect) + if instance is None: + continue + try: + instance.to_sql() + except UnsupportedFeatureError as exc: + method = _dispatched_formatter(exc) + if method is not None: + observed.add(method) + continue + except Exception: + continue + + declared = _unmodelled_methods() + assert not (observed - declared), ( + "classes need formatters that are not in UNMODELLED_FORMATTERS: " + f"{sorted(observed - declared)}. Add each with the feature that is " + f"absent and why -- or mix the formatter in, in which case the " + f"classes render and the entry is not needed." + ) + assert not (declared - observed), ( + "UNMODELLED_FORMATTERS names formatters no class actually needs: " + f"{sorted(declared - observed)}. SQL Server may have gained one of " + f"these, in which case the classes needing it now render and the " + f"entry should go." + ) + def test_matrix_covers_both_packages(self): """The matrix covers every concrete class both packages define. @@ -1287,7 +1512,7 @@ def test_every_covered_class_is_registered_for_deserialization(self): 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} + branches = {"rendered": 0, "unsupported": 0, "unmodelled": 0, "non-render": 0} constructible = [] for fqn in sorted(REGISTERED): instance, source = make_instance(REGISTERED[fqn], sqlserver_dialect) @@ -1304,7 +1529,11 @@ def test_coverage_report(self, sqlserver_dialect): f"{len(UNCONSTRUCTIBLE)} pinned-unconstructible; " f"rendered {branches['rendered']}, " f"unsupported {branches['unsupported']}, " + f"unmodelled {branches['unmodelled']} (pinned in " + f"UNMODELLED_FORMATTERS), " f"pinned non-render {branches['non-render']}" ) for fqn in UNCONSTRUCTIBLE: - print(f" not constructible: {fqn}") \ No newline at end of file + print(f" not constructible: {fqn}") + for feature, methods in sorted(UNMODELLED_FORMATTERS.items()): + print(f" not modelled: {feature} ({len(methods)} formatters)") diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_functions.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_functions.py index cfd8374..a43369d 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_functions.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_functions.py @@ -7,6 +7,7 @@ import pytest +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.impl.sqlserver import functions from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect @@ -148,9 +149,14 @@ def test_json_extract(self, dialect): assert result is not None def test_json_type(self, dialect): - """Test json_type factory.""" - result = functions.json_type(dialect, "column") - assert result is not None + """json_type refuses: T-SQL has no JSON_TYPE. + + It used to emit a FunctionCall named JSON_TYPE, which the server + rejects. The refusal names OPENJSON as the route that does work. + """ + with pytest.raises(UnsupportedFeatureError) as excinfo: + functions.json_type(dialect, "column") + assert "OPENJSON" in str(excinfo.value) def test_json_valid(self, dialect): """Test json_valid factory.""" @@ -158,9 +164,14 @@ def test_json_valid(self, dialect): assert result is not None def test_json_search(self, dialect): - """Test json_search factory.""" - result = functions.json_search(dialect, "column", "search") - assert result is not None + """json_search refuses: T-SQL has no JSON_SEARCH. + + It used to emit a FunctionCall named JSON_SEARCH, which the server + rejects. The refusal names OPENJSON with a LIKE predicate. + """ + with pytest.raises(UnsupportedFeatureError) as excinfo: + functions.json_search(dialect, "column", "search") + assert "OPENJSON" in str(excinfo.value) def test_st_geom_from_text(self, dialect): """Test st_geom_from_text factory.""" diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_introspection_sql_builders.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_introspection_sql_builders.py index 96424b5..b5592c6 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_introspection_sql_builders.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_introspection_sql_builders.py @@ -1,12 +1,22 @@ # tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_introspection_sql_builders.py -"""Offline tests for the parameterized SQL builder methods in the introspector. +"""Offline tests for the introspector's pure-Python paths. Covers the SQL injection hardening changes: every ``_build_*_sql`` method now returns parameterized SQL (``?`` placeholders) with separate params, -instead of string-interpolated values. +instead of string-interpolated values. Also covers the catalog-row to +``full_type`` rebuild in ``_parse_columns``: the ``-1`` (MAX) marker that the +rebuild used to drop, and the ``DATETIME_PRECISION`` parameter of +``time``/``datetime2``/``datetimeoffset``, which it used to ignore. """ +from rhosocial.activerecord.backend.expression.types import DataType, TextType from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig +from rhosocial.activerecord.backend.impl.sqlserver.expression.types import ( + SQLServerImageType, + SQLServerNVarCharType, + SQLServerVarBinaryMaxType, + SQLServerXmlType, +) from rhosocial.activerecord.backend.impl.sqlserver.introspection import ( SyncSQLServerIntrospector, ) @@ -49,6 +59,12 @@ def test_parameterized(self): assert "c.TABLE_SCHEMA = ?" in sql assert params == ("dbo", "users") + def test_selects_datetime_precision(self): + """The temporal parameter lives only in ``DATETIME_PRECISION``.""" + introspector = make_introspector() + sql, _ = introspector._build_column_info_sql("users", "dbo") + assert "c.DATETIME_PRECISION" in sql + class TestBuildPrimaryKeySql: def test_parameterized(self): @@ -109,4 +125,171 @@ def test_returns_sql(self): introspector = make_introspector() sql, params = introspector._build_database_info_sql() assert "DB_NAME()" in sql - assert params == () \ No newline at end of file + assert params == () + + +class TestParseColumnsFullTypeRebuild: + """Offline tests for the ``full_type`` rebuild in ``_parse_columns``. + + ``INFORMATION_SCHEMA.COLUMNS.CHARACTER_MAXIMUM_LENGTH`` reports ``-1`` + for the MAX-capable declarations. The rebuild used to skip any + non-positive length, so ``NVARCHAR(MAX)`` came back as the bare word and + re-read as the declared default width (``NVARCHAR(255)``): a phantom + diff against a declared ``NVARCHAR(MAX)``, and a missed change against + a real ``NVARCHAR(255)``. + """ + + @staticmethod + def _parse_one(data_type: str, **catalog): + row = { + "COLUMN_NAME": "col", + "DATA_TYPE": data_type, + "ORDINAL_POSITION": 1, + "IS_NULLABLE": "YES", + **catalog, + } + introspector = make_introspector() + return introspector._parse_columns([row], "t", "dbo")[0] + + def test_nvarchar_max_marker_rebuilds_to_max(self): + dialect = make_introspector()._backend.dialect + col = self._parse_one("nvarchar", CHARACTER_MAXIMUM_LENGTH=-1) + assert col.data_type_full == "nvarchar(MAX)" + assert isinstance(col.parsed_data_type, TextType) + # Declared ``NVARCHAR(MAX)`` (TextType) and the introspected column + # now agree, so the phantom diff is gone... + assert col.parsed_data_type == TextType(dialect) + assert col.parsed_data_type == DataType.parse_data_type_str(dialect, "NVARCHAR(MAX)") + # ...and a MAX column no longer compares equal to a sized one. + assert col.parsed_data_type != SQLServerNVarCharType(dialect, length=255) + assert col.parsed_data_type != DataType.parse_data_type_str(dialect, "NVARCHAR(255)") + + def test_varchar_max_marker_rebuilds_to_max(self): + dialect = make_introspector()._backend.dialect + col = self._parse_one("varchar", CHARACTER_MAXIMUM_LENGTH=-1) + assert col.data_type_full == "varchar(MAX)" + assert isinstance(col.parsed_data_type, TextType) + assert col.parsed_data_type == TextType(dialect) + assert col.parsed_data_type != DataType.parse_data_type_str(dialect, "VARCHAR(255)") + + def test_varbinary_max_marker_rebuilds_to_max_type(self): + col = self._parse_one("varbinary", CHARACTER_MAXIMUM_LENGTH=-1) + assert col.data_type_full == "varbinary(MAX)" + assert isinstance(col.parsed_data_type, SQLServerVarBinaryMaxType) + + def test_introspected_max_differs_from_introspected_255(self): + """The missed-change half: MAX and 255 must not compare equal.""" + sized = self._parse_one("nvarchar", CHARACTER_MAXIMUM_LENGTH=255) + maxed = self._parse_one("nvarchar", CHARACTER_MAXIMUM_LENGTH=-1) + assert sized.data_type_full == "nvarchar(255)" + assert sized.parsed_data_type != maxed.parsed_data_type + + def test_sized_column_rebuild_unchanged(self): + col = self._parse_one("nvarchar", CHARACTER_MAXIMUM_LENGTH=30) + assert col.data_type_full == "nvarchar(30)" + assert isinstance(col.parsed_data_type, SQLServerNVarCharType) + assert col.parsed_data_type.length == 30 + + def test_legacy_lob_and_xml_marker_handling_unchanged(self): + # text/ntext/image report their own maximum rather than -1... + text = self._parse_one("text", CHARACTER_MAXIMUM_LENGTH=2147483647) + assert text.data_type_full == "text(2147483647)" + assert isinstance(text.parsed_data_type, TextType) + ntext = self._parse_one("ntext", CHARACTER_MAXIMUM_LENGTH=1073741823) + assert ntext.data_type_full == "ntext(1073741823)" + assert isinstance(ntext.parsed_data_type, TextType) + image = self._parse_one("image", CHARACTER_MAXIMUM_LENGTH=2147483647) + assert image.data_type_full == "image(2147483647)" + assert isinstance(image.parsed_data_type, SQLServerImageType) + # ...and xml takes no length argument, so a -1 marker stays bare. + xml = self._parse_one("xml", CHARACTER_MAXIMUM_LENGTH=-1) + assert xml.data_type_full == "xml" + assert isinstance(xml.parsed_data_type, SQLServerXmlType) + + +class TestParseColumnsDatetimePrecision: + """Offline tests for the ``DATETIME_PRECISION`` rebuild in ``_parse_columns``. + + Appendix F.4-1..3: the three temporal types that carry a + fractional-seconds precision report it only in ``DATETIME_PRECISION`` + (their ``NUMERIC_PRECISION`` is NULL), and the rebuild used not to read + it at all: ``time(3)`` came back bare and re-read as the default + precision, while ``time(0)`` and ``time(7)`` folded into the very same + bare type. The catalog answers, measured live and identical on SQL + Server 2019 (15.0.4465.1), 2022 (16.0.4250.1) and 2025 (17.0.4035.5): + + bare time/datetime2/datetimeoffset -> 7 + (0) -> 0 + (3) -> 3 + (7) -> 7 + + So the rebuild emits ``(n)`` only when ``n`` differs from the server + default ``7``, and rebuilds the bare word at the default — which is also + what the server reports for an explicit ``(7)``, a declaration it cannot + tell apart from the bare word. + """ + + PRECISIONED = ("time", "datetime2", "datetimeoffset") + + @staticmethod + def _parse_one(data_type: str, dt_precision): + row = { + "COLUMN_NAME": "col", + "DATA_TYPE": data_type, + "ORDINAL_POSITION": 1, + "IS_NULLABLE": "YES", + "DATETIME_PRECISION": dt_precision, + } + introspector = make_introspector() + return introspector._parse_columns([row], "t", "dbo")[0] + + def test_explicit_precision_rebuilds_and_matches_declared(self): + dialect = make_introspector()._backend.dialect + for word in self.PRECISIONED: + col = self._parse_one(word, 3) + assert col.data_type_full == f"{word}(3)" + assert col.parsed_data_type == DataType.parse_data_type_str( + dialect, f"{word}(3)") + + def test_zero_precision_is_rebuilt(self): + dialect = make_introspector()._backend.dialect + for word in self.PRECISIONED: + col = self._parse_one(word, 0) + assert col.data_type_full == f"{word}(0)" + assert col.parsed_data_type == DataType.parse_data_type_str( + dialect, f"{word}(0)") + + def test_server_default_precision_stays_bare(self): + dialect = make_introspector()._backend.dialect + for word in self.PRECISIONED: + col = self._parse_one(word, 7) + assert col.data_type_full == word + assert col.parsed_data_type == DataType.parse_data_type_str( + dialect, word) + + def test_missing_catalog_precision_stays_bare(self): + # Defensive: a row without the field rebuilds exactly as before. + for word in self.PRECISIONED: + col = self._parse_one(word, None) + assert col.data_type_full == word + + def test_missed_change_between_zero_and_default_is_closed(self): + zero = self._parse_one("time", 0) + three = self._parse_one("time", 3) + default = self._parse_one("time", 7) + assert zero.parsed_data_type != default.parsed_data_type + assert three.parsed_data_type != default.parsed_data_type + + def test_legacy_datetime_aliases_keep_their_bare_path(self): + """``datetime`` (dtprec 3) and ``smalldatetime`` (dtprec 0) are fixed + legacy aliases folded into the ``DATETIME2`` concept; the rebuild + leaves them bare rather than inventing a parameter their grammar does + not have.""" + dialect = make_introspector()._backend.dialect + dt = self._parse_one("datetime", 3) + assert dt.data_type_full == "datetime" + assert dt.parsed_data_type == DataType.parse_data_type_str(dialect, "datetime") + sdt = self._parse_one("smalldatetime", 0) + assert sdt.data_type_full == "smalldatetime" + assert sdt.parsed_data_type == DataType.parse_data_type_str( + dialect, "smalldatetime") diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_json_expressions.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_json_expressions.py index 3a85a87..1450a8b 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_json_expressions.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_json_expressions.py @@ -40,8 +40,13 @@ def test_get_json_access_operator(self, dialect): assert op is None or op == "JSON_VALUE/JSON_QUERY" def test_supports_json_table(self, dialect): - """Test JSON table (OPENJSON) support.""" - assert dialect.supports_json_table() is True + """JSON_TABLE is absent; the capability here is OPENJSON. + + The probe used to claim 2016+ while format_json_table_expression raised + unconditionally, so a caller trusting it was promised a table + expression and got an exception at render time. + """ + assert dialect.supports_json_table() is False class TestSQLServerJSONAdapter: diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_column_type.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_column_type.py new file mode 100644 index 0000000..b65cb87 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_column_type.py @@ -0,0 +1,389 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_column_type.py +"""SQL Server's eighteen-entry column-type table. + +Pure dialect tests -- no server, no connection. What is asserted here is the +*declaration*: that the dialect composes core's +:class:`~rhosocial.activerecord.backend.dialect.mixins.column_type.ColumnTypeMixin` +and answers every one of the framework's common Python types, that the cells +SQL Server deviates on are the ones the live sweep measured, and that no entry +is left to a ``None`` that nobody decided on. + +The rendering those cells imply is Phase 2b's work and is deliberately not +asserted here. Two of the old assertions that *were* asserted here -- +``ilike`` and the JSON quantifier family -- measured capabilities rather than +renderings; they lived on ``supports_column_operation()``, which the core +rebuild deleted along with the rest of the capability mechanism. Their evidence +is carried in the mixin's docstring until the protocol grows an accessor for +it, and the module records the same thing so the loss is visible rather than +silent. +""" + +import datetime +import decimal +import enum +import uuid + +import pytest + +from rhosocial.activerecord.backend.dialect.mixins import ColumnTypeMixin +from rhosocial.activerecord.backend.expression.column_types import ( + ArrayColumn, + BinaryColumn, + BooleanColumn, + ColumnBase, + IntegerColumn, + JSONColumn, + NumericColumn, + StringColumn, + TimestampColumn, + UUIDColumn, +) +from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect +from rhosocial.activerecord.backend.impl.sqlserver.mixins import ( + SQLServerColumnTypeMixin, +) +from rhosocial.activerecord.testsuite.feature.query.typed_column.column_helpers import ( + COMMON_TYPES, + resolve_column_class, +) + +#: The three servers the live sweep behind this table ran against, as the +#: backend records them (catalog versions, not the marketing names): 2019 CU32, +#: 2022 CU24 and 2025 CU4. +SQL_SERVER_2019 = (15, 0, 4465) +SQL_SERVER_2022 = (16, 0, 4250) +SQL_SERVER_2025 = (17, 0, 4035) + +_MEASURED_VERSIONS = [SQL_SERVER_2019, SQL_SERVER_2022, SQL_SERVER_2025] +_VERSION_IDS = ["2019", "2022", "2025"] + +_ENTRY_IDS = [getattr(entry, "__name__", str(entry)) for entry in COMMON_TYPES] + + +def _answer_name(answer): + """A class reads as its name; ``None`` reads as the deliberate ``None``.""" + return "None" if answer is None else answer.__name__ + + +@pytest.fixture(params=_MEASURED_VERSIONS, ids=_VERSION_IDS) +def measured_dialect(request): + """A dialect at each of the three measured versions.""" + return SQLServerDialect(version=request.param) + + +@pytest.fixture +def dialect(): + return SQLServerDialect(version=SQL_SERVER_2022) + + +class TestTableCompleteness: + """A hole in the table is a silent gap, and the protocol forbids one.""" + + def test_the_dialect_carries_the_mixin(self, dialect): + assert isinstance(dialect, ColumnTypeMixin) + + def test_the_dialect_carries_the_sql_server_mixin(self, dialect): + """The backend's own half, not just core's plumbing. + + Core supplies no table of its own -- its ``suggested_column_types()`` + raises -- so the answer a model layer reads can only come from the + backend's own mixin being in the MRO. + """ + assert isinstance(dialect, SQLServerColumnTypeMixin) + + def test_the_mixin_overrides_core(self): + """The backend's answer, not core's NotImplementedError, is reached.""" + assert ( + SQLServerColumnTypeMixin.suggested_column_types + is not ColumnTypeMixin.suggested_column_types + ) + + def test_every_entry_is_answered(self, dialect): + """All eighteen, by name -- an omission fails here, not at lookup.""" + missing = [e for e in COMMON_TYPES if e not in dialect.suggested_column_types()] + assert missing == [] + + def test_the_table_has_exactly_the_protocol_entries(self, dialect): + """No backend-only extension is claimed, so the key space is closed here.""" + assert set(dialect.suggested_column_types()) == set(COMMON_TYPES) + + def test_no_entry_is_answered_none(self, dialect): + """``None`` is the last resort, and SQL Server never needs it. + + Every value family this server carries rides on a working pairing: a + document goes in ``NVARCHAR(MAX)`` and the JSON functions read it, a + sequence has no native array so it takes the JSON route, a span has no + interval type so it is a count. Refusing any of them would read the + *storage* as a statement about the *operations*. + """ + table = dialect.suggested_column_types() + assert [e for e in COMMON_TYPES if table[e] is None] == [] + + @pytest.mark.parametrize("entry", COMMON_TYPES, ids=_ENTRY_IDS) + def test_every_answer_is_a_column_class(self, dialect, entry): + answer = dialect.suggested_column_types()[entry] + assert isinstance(answer, type) + assert issubclass(answer, ColumnBase) + + @pytest.mark.parametrize("entry", COMMON_TYPES, ids=_ENTRY_IDS) + def test_every_entry_resolves_through_the_selection(self, dialect, entry): + """Through the field accessor's own resolution, not by reading the dict. + + ``FieldAccessor._select_column_class`` is the production path a model + layer takes, so the table is only really answered if it surfaces there. + """ + assert resolve_column_class(dialect, entry) is dialect.suggested_column_types()[entry] + + def test_the_table_is_a_copy(self, dialect): + """Mutating one answer must not corrupt the class attribute.""" + first = dialect.suggested_column_types() + first[str] = NumericColumn + assert dialect.suggested_column_types()[str] is StringColumn + + def test_the_extra_table_is_empty(self, dialect): + """SQL Server offers nothing beyond the framework's common types.""" + assert dialect.suggested_extra_column_types() == {} + + +class TestBaselineCells: + """The baseline every backend answers, restated cell by cell.""" + + @pytest.mark.parametrize( + "entry, expected", + [ + (bool, BooleanColumn), + (int, IntegerColumn), + (float, NumericColumn), + (decimal.Decimal, NumericColumn), + (str, StringColumn), + (bytes, BinaryColumn), + (bytearray, BinaryColumn), + (datetime.date, TimestampColumn), + (datetime.time, TimestampColumn), + (datetime.datetime, TimestampColumn), + (datetime.timedelta, NumericColumn), + (uuid.UUID, UUIDColumn), + (enum.Enum, StringColumn), + ], + ids=[ + "bool", + "int", + "float", + "Decimal", + "str", + "bytes", + "bytearray", + "date", + "time", + "datetime", + "timedelta", + "UUID", + "Enum", + ], + ) + def test_baseline_entry(self, dialect, entry, expected): + assert dialect.suggested_column_types()[entry] is expected + + def test_the_tz_form_lands_on_the_same_entry(self, dialect): + """A tz-aware ``datetime`` is annotated as ``datetime``; nothing is refused. + + The protocol has no separate tz entry, and this backend has no reason to + want one: ``AT TIME ZONE 'UTC'`` was measured working in both directions + on 2019, 2022 and 2025, and ``DATETIMEOFFSET`` is a real storage type. + The tz concern that does exist -- the old ODBC driver refusing to bind a + ``date`` / ``time`` parameter -- is value-layer, not a column class. + """ + from datetime import timezone + + aware = datetime.datetime(2026, 10, 9, 12, 0, tzinfo=timezone.utc) + keys = dialect.suggested_column_types() + assert resolve_column_class(dialect, type(aware)) is keys[datetime.datetime] + + def test_uuid_is_native_and_still_the_baseline(self, measured_dialect): + """``UNIQUEIDENTIFIER`` is native, and the column answer is unchanged. + + Its unusual **order** (measured ``[2, 1, 3]`` where the text order is + ``[1, 2, 3]``, restored by ``CONVERT(varchar(36), u)``) is a rendering + fact for Phase 2b, so it must not leak into the table as a refusal. + """ + assert measured_dialect.suggested_column_types()[uuid.UUID] is UUIDColumn + + +class TestSQLServerDeviations: + """The groups of cells that differ from the portable baseline.""" + + def test_the_numeric_family_is_one_class(self, dialect): + """``float`` and ``Decimal`` share the one numeric class. + + Measured: ``ROUND(2.675, 2)`` is ``2.68`` on a ``decimal`` column and + ``2.67`` on a ``float`` one -- a real difference in SQL Server, because + ``FLOAT`` and ``DECIMAL(p,s)`` are two families. Core's column layer + cannot express it any longer: :class:`NumericColumn` is one class for + every numeric width and precision, the operations being the same + either way. The measured fact survives in the DDL layer's ``DataType`` + declaration rather than being dropped here. + """ + table = dialect.suggested_column_types() + assert table[float] is NumericColumn + assert table[decimal.Decimal] is NumericColumn + + def test_dict_is_json_column(self, dialect): + """The χ route: ``NVARCHAR(MAX)`` plus the 2016 function family.""" + assert dialect.suggested_column_types()[dict] is JSONColumn + + @pytest.mark.parametrize( + "entry", [list, tuple, set, frozenset], ids=["list", "tuple", "set", "frozenset"] + ) + def test_containers_take_the_json_route(self, dialect, entry): + """SQL Server has no array type on any version, so the JSON path answers. + + ``ArrayColumn``'s whole surface is ``array_length`` / ``unnest`` over a + *native* array -- a column this server cannot declare at all. + """ + assert dialect.suggested_column_types()[entry] is JSONColumn + + def test_no_entry_answers_array_column(self, dialect): + """The portable baseline says ``ArrayColumn`` for the containers; SQL Server may not.""" + table = dialect.suggested_column_types() + assert [e for e, cls in table.items() if cls is ArrayColumn] == [] + + def test_timedelta_is_numeric(self, dialect): + """There is no interval *type*; a span exists only as ``DATEDIFF``.""" + assert dialect.suggested_column_types()[datetime.timedelta] is NumericColumn + + +class TestColumnClassResolution: + """What a model layer gets when it asks the dialect for a class.""" + + def test_dict_resolves_to_json_column_on_2019(self): + assert resolve_column_class(SQLServerDialect(version=SQL_SERVER_2019), dict) is JSONColumn + + def test_list_resolves_to_json_column_on_2025(self): + assert resolve_column_class(SQLServerDialect(version=SQL_SERVER_2025), list) is JSONColumn + + def test_timedelta_resolves_to_numeric_column(self, dialect): + assert resolve_column_class(dialect, datetime.timedelta) is NumericColumn + + def test_the_refusal_is_a_type_error(self): + """``ColumnTypeResolutionError`` subclasses ``TypeError`` by design.""" + from rhosocial.activerecord.base import ColumnTypeResolutionError + + assert issubclass(ColumnTypeResolutionError, TypeError) + + def test_an_explicit_declaration_wins(self): + """``UseColumnType`` is the escape hatch, and it is backend-independent.""" + from rhosocial.activerecord.base.fields import UseColumnType + + server = SQLServerDialect(version=SQL_SERVER_2019) + resolved = resolve_column_class(server, dict, UseColumnType(BinaryColumn)) + assert resolved is BinaryColumn + + @pytest.mark.parametrize( + "annotation", + [list, tuple, set, frozenset], + ids=["list", "tuple", "set", "frozenset"], + ) + def test_container_subclasses_normalise(self, dialect, annotation): + """A user subclass walks to its protocol entry, per the normalisation rules.""" + + class MyList(list): + pass + + expected = JSONColumn + assert resolve_column_class(dialect, annotation) is expected + assert resolve_column_class(dialect, MyList) is expected + + def test_an_enum_subclass_normalises(self, dialect): + class Weekday(enum.Enum): + MONDAY = 1 + + assert resolve_column_class(dialect, Weekday) is StringColumn + + def test_a_str_subclass_normalises(self, dialect): + class MyStr(str): + pass + + assert resolve_column_class(dialect, MyStr) is StringColumn + + def test_an_unknown_annotation_is_not_guessed(self, dialect): + """No universal column: an annotation outside the list fails by name.""" + from rhosocial.activerecord.base import ColumnTypeResolutionError + + with pytest.raises(ColumnTypeResolutionError): + resolve_column_class(dialect, complex) + + +class TestEveryAnswerBuildsAWorkingColumn: + """A table answer is only real if the column it names can be constructed.""" + + @pytest.mark.parametrize("entry", COMMON_TYPES, ids=_ENTRY_IDS) + def test_every_entry_builds_a_column_that_renders(self, dialect, entry): + """The selection answering and the object refusing to build would leave + the failure at query time with a message about a column rather than + about the annotation, which is the outcome the narrow column classes + were introduced to remove.""" + column = resolve_column_class(dialect, entry)(dialect, "c") + assert isinstance(column, ColumnBase) + assert column.to_sql() == ("[c]", ()) + + +class TestTheUnadaptedDialectAnswers: + """A dialect built without a version answers rather than raising.""" + + def test_the_table_needs_no_version(self): + assert SQLServerDialect().suggested_column_types()[dict] is JSONColumn + + +class TestTheUnicodeDialectInheritsTheSameAnswers: + """The default dialect is the Unicode one, so it must answer identically.""" + + def test_the_backend_dialect_is_a_sql_server_dialect(self): + from rhosocial.activerecord.backend.impl.sqlserver.backend.backend import ( + SQLServerUnicodeDialect, + ) + + assert issubclass(SQLServerUnicodeDialect, SQLServerDialect) + + def test_the_answers_are_identical(self): + from rhosocial.activerecord.backend.impl.sqlserver.backend.backend import ( + SQLServerUnicodeDialect, + ) + + plain = SQLServerDialect(version=SQL_SERVER_2022).suggested_column_types() + unicode_ = SQLServerUnicodeDialect(version=SQL_SERVER_2022).suggested_column_types() + assert plain == unicode_ + + +def test_the_summary_table_reads_the_way_the_spec_writes_it(): + """One test that states the whole answer, so a diff is legible at a glance.""" + table = SQLServerDialect(version=SQL_SERVER_2022).suggested_column_types() + summary = {entry.__name__: _answer_name(table[entry]) for entry in COMMON_TYPES} + assert summary == { + "bool": "BooleanColumn", + "int": "IntegerColumn", + "float": "NumericColumn", + "Decimal": "NumericColumn", + "str": "StringColumn", + "bytes": "BinaryColumn", + "bytearray": "BinaryColumn", + "date": "TimestampColumn", + "time": "TimestampColumn", + "datetime": "TimestampColumn", + "timedelta": "NumericColumn", + "UUID": "UUIDColumn", + "dict": "JSONColumn", + "list": "JSONColumn", + "tuple": "JSONColumn", + "set": "JSONColumn", + "frozenset": "JSONColumn", + "Enum": "StringColumn", + } + + +def test_the_three_measured_servers_agree_item_for_item(): + """No version gate on the table: 2019, 2022 and 2025 give one answer.""" + answers = [] + for version in _MEASURED_VERSIONS: + table = SQLServerDialect(version=version).suggested_column_types() + answers.append({e.__name__: _answer_name(cls) for e, cls in table.items()}) + assert answers[0] == answers[1] == answers[2] 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 da9f783..588ea42 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 @@ -78,6 +78,7 @@ def _classified_keys(protocols) -> set: dialect_protocols.CTESupport, dialect_protocols.CollationSupport, dialect_protocols.ColumnAttributeSupport, + dialect_protocols.ColumnTypeSupport, dialect_protocols.ConstraintSupport, dialect_protocols.DataTypeSupport, dialect_protocols.DDLTypeSupport, @@ -96,6 +97,7 @@ def _classified_keys(protocols) -> set: dialect_protocols.OrderedSetAggregationSupport, dialect_protocols.PartitionSupport, dialect_protocols.QualifyClauseSupport, + dialect_protocols.UUIDSupport, dialect_protocols.ReturningSupport, dialect_protocols.SQLFunctionSupport, dialect_protocols.SetOperationSupport, @@ -146,7 +148,7 @@ def _classified_keys(protocols) -> set: dialect_protocols.CreateSchemaSupport, dialect_protocols.DropSchemaSupport, dialect_protocols.AlterDatabaseSupport, - dialect_protocols.DateTimeSupport, + dialect_protocols.TimestampSupport, dialect_protocols.DqlOrderSupport, dialect_protocols.PivotSupport, ] diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_type_protocol.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_type_protocol.py index 8e3fa8a..87ddd8a 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_type_protocol.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_type_protocol.py @@ -3,27 +3,79 @@ Covers: - format/supports 1:1 correspondence -- supports_data_types() includes sqlserver_* + core entries -- suggested_data_types() returns classes, keys disjoint from supported +- supports_data_types() includes sqlserver_* + core entries, on a cold import +- suggested_data_types() returns classes, keys disjoint from supported, and + every suggested class is a type this dialect really renders (D9) +- spelling gates: the default spelling always renders, an unknown one is + refused and named - dialect_options forwarding and equality - Precision validation """ import pytest +from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, +) from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect from rhosocial.activerecord.backend.impl.sqlserver.expression.types import ( + SQLServerBitType, + SQLServerNCharType, SQLServerNVarCharType, + SQLServerTinyIntType, + SQLServerUniqueIdentifierType, SQLServerVarBinaryType, + SQLServerXmlType, +) +from rhosocial.activerecord.backend.impl.sqlserver.protocols import ( + SQLServerTypeSupport, ) from rhosocial.activerecord.backend.expression.types import ( - FloatType, - DecimalType, + BigIntType, + BlobType, + BooleanType, + CharType, + DataType, DateTimeType, + DecimalType, + DoubleType, + FloatType, + IntegerType, + JsonType, + SmallIntType, + TextType, TimeType, TimestampType, + TimestampTzType, + TinyIntType, + UUIDType, + VarCharType, + XmlType, ) SQL_SERVER_2022 = (16, 0, 0) +SQL_SERVER_2005 = (9, 0, 0) + +#: Words to try when a test needs a spelling that is genuinely *outside* a +#: concept's closed list. Scanned per concept rather than hardcoded, because a +#: hardcoded probe goes stale: an earlier revision of this file used ``int4`` +#: throughout, and ``int4`` stopped being outside the list once core recorded it +#: as a documented synonym of ``INTEGER`` (PostgreSQL's own name for the 4-byte +#: integer). Every word here is alphanumeric — the probe is also used as a +#: ``pytest.raises(match=...)`` pattern — and the first that the concept under +#: test does not claim is used. +_UNKNOWN_SPELLING_POOL = ("int4", "int64", "uint", "int16", "float4", "decimal4") + + +def _absent_spelling(concept): + """A word that is certainly not in ``concept.SPELLINGS``.""" + for candidate in _UNKNOWN_SPELLING_POOL: + if candidate not in concept.SPELLINGS: + return candidate + raise AssertionError( + f"{concept.__name__}.SPELLINGS now contains every word in " + f"{_UNKNOWN_SPELLING_POOL}; add another probe to the pool rather than " + f"hardcoding one here" + ) class TestFormatSupportsCorrespondence: @@ -85,10 +137,15 @@ def test_returns_dict(self, dialect): def test_includes_core_types(self, dialect): supported = dialect.supports_data_types() - for name in ["integer", "int", "bigint", "smallint", "tinyint", + # ``int`` is deliberately absent: it is a *spelling* of ``integer``, + # not a second concept, so it has no name to be dispatched by. The + # spelling is honoured inside ``format_data_type_integer`` instead — + # see TestSpellingGates below. + for name in ["integer", "bigint", "smallint", "tinyint", "varchar", "char", "text", "boolean", "date", - "datetime", "time", "timestamp", "float", "real", - "double", "decimal", "json", "blob", "custom"]: + "datetime", "time", "timestamp", "timestamptz", "float", + "real", "double", "decimal", "json", "xml", "blob", + "custom"]: assert name in supported, f"Core type {name!r} missing from supports_data_types()" def test_includes_sqlserver_types(self, dialect): @@ -107,6 +164,48 @@ def test_values_are_type_classes(self, dialect): f"supports_data_types()[{name!r}] = {cls!r} is not a type class" ) + def test_every_key_names_the_class_behind_it(self, dialect): + """A key with a different class behind it is a dispatch that lies.""" + supported = dialect.supports_data_types() + for name, cls in supported.items(): + assert issubclass(cls, DataType), f"{name!r} -> {cls!r}" + assert cls.name == name, f"{cls.__name__}.name={cls.name!r} != {name!r}" + + def test_backend_types_are_advertised_without_a_prior_import(self): + """``supports_data_types()`` must not depend on load order. + + ``_type_class_for(name)`` resolves a dispatch key by walking the live + subclass tree of ``DataType``, so a name whose class nobody imported + resolves to ``None`` and silently drops out of the mapping. It used to + drop out: every test that caught this had imported a ``SQLServer*`` + class itself first, which is how the defect survived. So this runs in + a subprocess, where the dialect module is the only import. + """ + import subprocess + import sys + + script = ( + "from rhosocial.activerecord.backend.impl.sqlserver.dialect " + "import SQLServerDialect\n" + f"supported = SQLServerDialect({SQL_SERVER_2022!r}).supports_data_types()\n" + "print(','.join(sorted(supported)))\n" + ) + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, text=True, check=True, + ) + advertised = set(result.stdout.strip().split(",")) + for name in ("sqlserver_nvarchar", "sqlserver_nchar", + "sqlserver_nvarchar_max", "sqlserver_varbinary", + "sqlserver_varbinary_max", "sqlserver_xml", + "sqlserver_tinyint", "sqlserver_bit", + "sqlserver_image", "sqlserver_uniqueidentifier"): + assert name in advertised, ( + f"{name!r} missing from supports_data_types() on a cold import; " + f"the backend expression types are not being loaded. Advertised: " + f"{sorted(advertised)}" + ) + class TestSuggestedDataTypes: """suggested_data_types() returns classes, keys disjoint from supported.""" @@ -122,8 +221,8 @@ def test_returns_dict(self, dialect): def test_values_are_type_classes(self, dialect): suggested = dialect.suggested_data_types() for name, cls in suggested.items(): - assert isinstance(cls, type), ( - f"suggested_data_types()[{name!r}] = {cls!r} is not a type class" + assert isinstance(cls, type) and issubclass(cls, DataType), ( + f"suggested_data_types()[{name!r}] = {cls!r} is not a DataType class" ) def test_keys_disjoint_from_supported(self, dialect): @@ -134,15 +233,162 @@ def test_keys_disjoint_from_supported(self, dialect): f"Keys appear in both supports and suggested: {overlap}" ) + def test_every_substitute_is_a_type_this_dialect_renders(self, dialect): + """A suggestion the backend cannot produce is worse than no suggestion. + + The whole map used to be made of classes this dialect had no formatter + for: ``IntervalType``, ``ArrayType``, ``JsonBType``, ``TimestampTzType`` + and ``TimeTzType`` were each handed back to a caller as the answer and + then refused on render. D9 asks for "what this backend stores instead", + and that has to be a thing this backend stores. + """ + supported = dialect.supports_data_types() + suggested = dialect.suggested_data_types() + for key, substitute in sorted(suggested.items()): + assert substitute.name in supported, ( + f"suggested_data_types()[{key!r}] is {substitute.__name__}, " + f"whose name {substitute.name!r} this dialect does not render" + ) + def test_suggests_unsupported_core_types(self, dialect): suggested = dialect.suggested_data_types() - # SQL Server doesn't natively support these core types - for name in ["uuid", "interval", "array", "enum", "jsonb", - "timestamptz", "timetz"]: + # Concepts SQL Server has no T-SQL word for. + for name in ["uuid", "interval", "array", "enum", "jsonb", "timetz", + "binary", "varbinary"]: assert name in suggested, ( f"Expected suggestion for {name!r} in suggested_data_types()" ) + def test_does_not_suggest_what_it_renders(self, dialect): + """``xml`` and ``timestamptz`` have native SQL Server types. + + Substituting text for ``xml`` or a zoneless ``DATETIME2`` for + ``timestamptz`` would describe a backend that is not this one. + """ + suggested = dialect.suggested_data_types() + for name in ("xml", "timestamptz"): + assert name not in suggested, ( + f"{name!r} is rendered natively on SQL Server " + f"({'XML' if name == 'xml' else 'DATETIMEOFFSET'}); it must not " + f"be substituted" + ) + assert name in dialect.supports_data_types() + + def test_uuid_substitute_is_the_backend_class_not_the_core_one(self, dialect): + """``uuid`` maps to ``UNIQUEIDENTIFIER``, which this dialect renders. + + It used to map to the core ``UUIDType``, whose ``name`` equals the key — + and which this dialect cannot render, so the suggestion could not be + followed. Pointing at ``SQLServerUniqueIdentifierType`` is the answer + D9 asks for: a class whose own ``name`` *is* in ``supports_data_types()``. + """ + assert dialect.suggested_data_types()["uuid"] is SQLServerUniqueIdentifierType + + def test_jsonb_substitute_is_json_because_sql_server_has_one_json(self, dialect): + """``jsonb`` is PostgreSQL's binary JSON; SQL Server has no such split.""" + assert dialect.suggested_data_types()["jsonb"] is JsonType + assert dialect.suggested_data_types()["array"] is JsonType + + +class TestSpellingGates: + """Which spellings of one concept this dialect renders.""" + + @pytest.fixture + def dialect(self): + return SQLServerDialect(SQL_SERVER_2022) + + #: The rendering table this backend commits to. Locked here so a change to + #: any one row has to be a deliberate edit to this test rather than an + #: accident. + RENDERED = { + "integer": {"integer": "INT", "int": "INT"}, + "bigint": {"bigint": "BIGINT", "int8": "BIGINT"}, + "smallint": {"smallint": "SMALLINT", "int2": "SMALLINT"}, + "tinyint": {"tinyint": "TINYINT", "int1": "TINYINT"}, + "double": {"double": "FLOAT(53)", "double precision": "FLOAT(53)"}, + "decimal": {"decimal": "DECIMAL", "numeric": "DECIMAL", "dec": "DECIMAL"}, + "boolean": {"boolean": "BIT", "bool": "BIT"}, + "char": {"char": "CHAR(1)", "character": "CHAR(1)"}, + "varchar": {"varchar": "VARCHAR(255)", "character varying": "VARCHAR(255)"}, + "text": {"text": "NVARCHAR(MAX)", "clob": "NVARCHAR(MAX)"}, + "blob": {"blob": "VARBINARY(MAX)", "bytea": "VARBINARY(MAX)"}, + } + + CLASSES = { + "integer": IntegerType, + "bigint": BigIntType, + "smallint": SmallIntType, + "tinyint": TinyIntType, + "double": DoubleType, + "decimal": DecimalType, + "boolean": BooleanType, + "char": CharType, + "varchar": VarCharType, + "text": TextType, + "blob": BlobType, + } + + @pytest.mark.parametrize("concept", sorted(RENDERED)) + def test_spelling_renders_as_declared(self, dialect, concept): + for spelling, expected in self.RENDERED[concept].items(): + sql, _ = dialect.format_data_type(self.CLASSES[concept]( + dialect, spelling=spelling)) + assert sql == expected, f"{concept}[{spelling!r}] rendered {sql!r}" + + @pytest.mark.parametrize("concept", sorted(RENDERED)) + def test_an_unknown_spelling_is_refused_and_named(self, dialect, concept): + """The closed list is what makes the spelling safe. + + Without the gate an arbitrary string would reach the SQL. With it, the + caller is told which spelling is the problem and what the accepted set + is — and ``TINYINT; DROP TABLE`` is a ``TypeError``, not a statement. + + The probe is chosen per concept: ``int4`` is a documented synonym of + ``INTEGER`` and so is inside that concept's list, which is what made a + hardcoded probe fail for one concept and pass for the rest. + """ + klass = self.CLASSES[concept] + unknown = _absent_spelling(klass) + with pytest.raises(TypeError, match=unknown): + dialect.format_data_type(klass(dialect, spelling=unknown)) + + def test_no_int_key_in_the_dispatch_family(self, dialect): + """``int`` is a spelling, so it has no ``format_data_type_int``. + + It had one, which meant two dispatch keys for one concept and a + ``supports_data_types()`` entry that vanished because no class is named + ``int`` any more. + """ + assert not hasattr(dialect, "format_data_type_int") + assert not hasattr(dialect, "supports_data_type_int") + + def test_timestamptz_is_version_gated(self): + """``DATETIMEOFFSET`` is SQL Server 2008+.""" + assert SQLServerDialect(SQL_SERVER_2005).supports_data_type_timestamptz() is False + assert SQLServerDialect(SQL_SERVER_2022).supports_data_type_timestamptz() is True + + +class TestSQLServerTypeSupportProtocol: + """The stated shape of the backend's own type family.""" + + def test_dialect_implements_the_family(self): + assert isinstance(SQLServerDialect(SQL_SERVER_2022), SQLServerTypeSupport) + + def test_the_family_is_stated_not_implied(self): + for name in ("supports_data_type_sqlserver_nvarchar", + "supports_data_type_sqlserver_nchar", + "supports_data_type_sqlserver_nvarchar_max", + "supports_data_type_sqlserver_varbinary", + "supports_data_type_sqlserver_varbinary_max", + "supports_data_type_sqlserver_image", + "supports_data_type_sqlserver_tinyint", + "supports_data_type_sqlserver_bit", + "supports_data_type_sqlserver_xml", + "supports_data_type_sqlserver_uniqueidentifier"): + assert name in vars(SQLServerTypeSupport), ( + f"{name} is dispatched on but not stated in the protocol" + ) + class TestDialectOptionsRemoved: """The data-type value objects no longer carry a dialect_options bag.""" @@ -154,9 +400,11 @@ def test_constructor_rejects_dialect_options(self): - def test_varbinary_type_params(self): + def test_varbinary_identity(self): + assert SQLServerVarBinaryType.PARAMETERS == ("length",) t = SQLServerVarBinaryType(length=512) - assert t._type_params() == (512,) + assert t.identity() == (512,) + assert t != SQLServerVarBinaryType(length=255) def test_varbinary_equality(self): t1 = SQLServerVarBinaryType(length=512) @@ -165,17 +413,66 @@ def test_varbinary_equality(self): assert t1 == t2 assert t1 != t3 - def test_decimal_type_params(self): + def test_decimal_identity(self, sqlserver_dialect): + # Precision, scale and signedness are identity. The spelling is not: + # DECIMAL(10,2) and NUMERIC(10,2) are the same column to the server, + # which reports one word for both, so counting it would report a change + # that was never made. The declared word is still what gets rendered. + assert DecimalType.PARAMETERS == ("precision", "scale", "unsigned") t = DecimalType(precision=10, scale=2) - assert t._type_params() == (10, 2) + assert t.identity() == (10, 2, False) + assert DecimalType(precision=10, scale=2, spelling="numeric") == t + assert DecimalType(sqlserver_dialect, 10, 2, + spelling="numeric").to_sql()[0] \ + == "DECIMAL(10, 2)" + # scale really is part of identity, unlike the spelling + assert DecimalType(precision=10, scale=2) != DecimalType(precision=10) + + def test_decimal_unsigned_is_refused_not_ignored(self, sqlserver_dialect): + """``unsigned`` joined ``PARAMETERS``; T-SQL cannot express it. - def test_float_type_params(self): + So the field is identity (a signed and an unsigned DECIMAL(10,2) are + different columns to the differ) and the formatter refuses it by name + rather than rendering the signed column and reporting success. + """ + assert DecimalType(precision=10, scale=2) != DecimalType( + precision=10, scale=2, unsigned=True) + assert hash(DecimalType(precision=10, scale=2)) != hash( + DecimalType(precision=10, scale=2, unsigned=True)) + with pytest.raises(UnsupportedFeatureError) as excinfo: + sqlserver_dialect.format_data_type( + DecimalType(sqlserver_dialect, 10, 2, unsigned=True)) + assert "unsigned" in str(excinfo.value) + assert excinfo.value.suggestion + + def test_float_identity(self): + assert FloatType.PARAMETERS == ("precision", "unsigned") t = FloatType(precision=24) - assert t._type_params() == (24,) + assert t.identity() == (24, False) + assert t != FloatType() + + def test_float_unsigned_is_refused_not_ignored(self, sqlserver_dialect): + assert FloatType(precision=24) != FloatType(precision=24, unsigned=True) + with pytest.raises(UnsupportedFeatureError) as excinfo: + sqlserver_dialect.format_data_type( + FloatType(sqlserver_dialect, 24, unsigned=True)) + assert "unsigned" in str(excinfo.value) - def test_datetime_type_params(self): + def test_double_unsigned_is_refused_not_ignored(self, sqlserver_dialect): + assert DoubleType() != DoubleType(unsigned=True) + with pytest.raises(UnsupportedFeatureError) as excinfo: + sqlserver_dialect.format_data_type( + DoubleType(sqlserver_dialect, unsigned=True)) + assert "unsigned" in str(excinfo.value) + # ...and the signed rendering is exactly what it was. + assert sqlserver_dialect.format_data_type( + DoubleType(sqlserver_dialect)) == ("FLOAT(53)", ()) + + def test_datetime_identity(self): + assert DateTimeType.PARAMETERS == ("precision",) t = DateTimeType(precision=3) - assert t._type_params() == (3,) + assert t.identity() == (3,) + assert t != DateTimeType() class TestPrecisionValidation: @@ -232,3 +529,1101 @@ def test_time_valid(self, dialect): def test_timestamp_precision_validation(self, dialect): with pytest.raises(ValueError, match="precision.*must be 0-7"): dialect.format_data_type(TimestampType(precision=8)) + + def test_timestamptz_precision_validation(self, dialect): + """``DATETIMEOFFSET`` bounds fractional seconds at 7 like DATETIME2.""" + assert dialect.format_data_type(TimestampTzType(dialect, precision=7))[0] == ( + "DATETIMEOFFSET(7)" + ) + with pytest.raises(ValueError, match="precision.*must be 0-7"): + dialect.format_data_type(TimestampTzType(dialect, precision=8)) + + +class TestConceptAnchoring: + """A backend type that says "I am this concept" must be that concept. + + These are the three assertions the previous hierarchy got wrong, so they are + written as guards rather than as comments: the mistake was invisible to the + renderer (dispatch runs on ``name``) and invisible to equality (it ran on + ``type(self)``), so nothing else would have caught a reversion. + """ + + def test_xml_is_the_xml_concept_and_not_a_varchar(self): + assert issubclass(SQLServerXmlType, XmlType) + assert not issubclass(SQLServerXmlType, VarCharType) + assert not issubclass(SQLServerXmlType, TextType) + + def test_tinyint_is_the_eight_bit_concept_and_not_a_32_bit_integer(self): + assert issubclass(SQLServerTinyIntType, TinyIntType) + assert not issubclass(SQLServerTinyIntType, IntegerType) + + def test_tinyint_records_that_it_is_unsigned(self): + """Signedness is a field (D5), so it is asserted through the field. + + ``unsigned`` is fixed to ``True`` because no T-SQL spelling of this type + is signed, and it is declared in identity so a comparison against a + signed ``TinyIntType`` — or against ``INTEGER`` — actually differs. + """ + assert SQLServerTinyIntType.PARAMETERS == ("unsigned",) + tinyint = SQLServerTinyIntType() + assert tinyint.unsigned is True + assert tinyint.identity() == (True,) + assert tinyint != TinyIntType() + assert tinyint != TinyIntType(unsigned=True) + assert tinyint != IntegerType() + + def test_tinyint_keeps_both_spellings(self): + """``TINYINT`` and ``INT1`` both render, and neither is a different type. + + The spelling is **not** in ``PARAMETERS``, so the two instances are + deliberately *not* equal — the declaration differs even though the + rendered SQL does not. What matters here is that both spellings are + accepted rather than one being refused. + """ + dialect = SQLServerDialect(SQL_SERVER_2022) + assert dialect.format_data_type(SQLServerTinyIntType())[0] == "TINYINT" + assert dialect.format_data_type( + SQLServerTinyIntType(dialect, spelling="int1"))[0] == "TINYINT" + with pytest.raises(TypeError, match="int2"): + dialect.format_data_type( + SQLServerTinyIntType(dialect, spelling="int2")) + + def test_bit_is_the_boolean_and_not_a_bit_string(self): + """SQL Server's ``BIT`` is 0/1/NULL (D6). + + The same *spelling* in MySQL and PostgreSQL is a bit string of ``n`` + bits, which is why those backends keep their own root-level class. The + consequence worth locking is that this class is a ``BooleanType`` and + takes no bit count. + """ + assert issubclass(SQLServerBitType, BooleanType) + with pytest.raises(TypeError): + SQLServerBitType(n=8) + + def test_uniqueidentifier_is_the_uuid_concept(self): + assert issubclass(SQLServerUniqueIdentifierType, UUIDType) + + def test_rendered_sql_is_unchanged_by_the_reparenting(self): + """The three re-parented classes render exactly what they always did. + + Dispatch runs on ``name``, so a re-parenting cannot change the SQL — + which is precisely why this assertion is here and not merely assumed. + """ + dialect = SQLServerDialect(SQL_SERVER_2022) + assert dialect.format_data_type(SQLServerXmlType(dialect)) == ("XML", ()) + assert dialect.format_data_type(SQLServerTinyIntType(dialect)) == ("TINYINT", ()) + assert dialect.format_data_type(SQLServerBitType(dialect)) == ("BIT", ()) + assert dialect.format_data_type(SQLServerUniqueIdentifierType(dialect)) == ( + "UNIQUEIDENTIFIER", () + ) + assert dialect.format_data_type(XmlType(dialect)) == ("XML", ()) + + def test_dialect_is_the_first_positional_parameter(self): + """A signature that puts ``length`` first fails *silently*. + + ``SQLServerNVarCharType(dialect, 100)`` follows the documented + convention; a dialect passed to ``(100, dialect)`` would be stored as + the length and the error would only surface at render time. + """ + dialect = SQLServerDialect(SQL_SERVER_2022) + t = SQLServerNVarCharType(dialect, 100) + assert t.dialect is dialect + assert t.length == 100 + assert t.to_sql() == ("NVARCHAR(100)", ()) + assert SQLServerVarBinaryType(dialect, 64).to_sql() == ("VARBINARY(64)", ()) + + +class TestBitTakesNoWidth: + """``parse_type("bit(1)")`` used to escape as a bare ``KeyError``. + + Appendix F.4-8: the BIT branch spelled its three spellings out in an + exact-match dictionary keyed by the whole uppercased word, so a + parenthesised form fell past the lookup and raised ``KeyError: 'BIT(1)'`` + from inside a parser whose documented answer for anything it cannot map is + a type (or ``CustomType``), never a crash. The measurement that settles + the replacement: all three live instances (SQL Server 2019 15.0.4465.1, + 2022 16.0.4250.1, 2025 17.0.4035.5) reject ``CREATE TABLE t (b bit(1))`` + with error 2716, "Cannot specify a column width on data type bit." — so the + width is not a spelling to be silently dropped; it is an invalid + declaration, and the parser says so with the ``ValueError`` this module + uses for values the server itself rejects. + """ + + @pytest.fixture + def dialect(self): + return SQLServerDialect(SQL_SERVER_2022) + + @pytest.mark.parametrize("raw", ["BIT", "bit", "BOOL", "bool", + "BOOLEAN", "boolean"]) + def test_bare_spellings_still_parse_to_the_bit_class(self, dialect, raw): + assert isinstance(dialect.parse_type(raw), SQLServerBitType) + + @pytest.mark.parametrize("raw", ["BIT(1)", "bit(1)", "BIT(0)", "BOOL(1)", + "bit (1)"]) + def test_a_width_is_refused_with_the_server_answer(self, dialect, raw): + with pytest.raises(ValueError, + match="Cannot specify a column width on data type bit") as excinfo: + dialect.parse_type(raw) + assert "2716" in str(excinfo.value) + # A server-rejected value, not an inexpressible declaration: the two + # exception families stay apart, as everywhere else in this file. + assert not isinstance(excinfo.value, UnsupportedFeatureError) + + +# --- unsigned is a field, so it must be honoured or refused --- + +#: Every integer formatter on this backend, with the signed SQL it has always +#: rendered and the word its refusal names. +#: +#: The evidence is T-SQL's own documentation, and it splits the four widths in +#: two, which is why the message does too: +#: +#: * ``bigint`` -2^63 to 2^63-1, ``int`` -2^31 to 2^31-1, ``smallint`` -2^15 +#: to 2^15-1, and the exact-numeric category is closed +#: (tinyint/smallint/int/bigint/bit/decimal/numeric/money/smallmoney), so +#: those three have no unsigned form — +#: https://learn.microsoft.com/en-us/sql/t-sql/data-types/data-types-transact-sql +#: https://learn.microsoft.com/en-us/sql/t-sql/data-types/int-bigint-smallint-and-tinyint-transact-sql +#: * ```` has no type modifier after ```` — no +#: ``UNSIGNED`` token, so there is not even a spelling that would parse — +#: https://learn.microsoft.com/en-us/sql/t-sql/statements/create-table-transact-sql +#: * ``tinyint`` is documented "0 to 255" (``2^0-1 to 2^8-1``), i.e. it *is* +#: the unsigned 8-bit integer, which is why that width is refused by name +#: rather than by the "no unsigned integer type" argument. +INTEGER_WIDTHS = [ + (TinyIntType, "TINYINT"), + (SmallIntType, "SMALLINT"), + (IntegerType, "INT"), + (BigIntType, "BIGINT"), +] + +INTEGER_WIDTH_IDS = [klass.name for klass, _word in INTEGER_WIDTHS] + + +class TestIntegerSignednessIsRefused: + """``unsigned`` is in the integer concepts' ``PARAMETERS``, so it is part of + their identity — and the four core formatters used to never read it: + ``IntegerType(unsigned=True)`` rendered a plain ``INT``, so the constructor + accepted a signedness and the render threw it away. + + That is the paradigm violation this file's round exists to close: the + declared column is not the column the caller gets, the SQL is byte-identical + to the signed declaration, and nothing reports it. Either the field changes + the rendering or the render stops with a message that says why. + """ + + @pytest.fixture + def dialect(self): + return SQLServerDialect(SQL_SERVER_2022) + + @pytest.mark.parametrize("klass,signed_sql", INTEGER_WIDTHS, + ids=INTEGER_WIDTH_IDS) + def test_the_signed_rendering_is_unchanged(self, dialect, klass, signed_sql): + """The refusal is not paid for by moving the signed rendering. + + These four words are what this dialect has always emitted, default flag + and explicit ``unsigned=False`` alike. + """ + assert dialect.format_data_type(klass(dialect)) == (signed_sql, ()) + assert dialect.format_data_type( + klass(dialect, unsigned=False)) == (signed_sql, ()) + + @pytest.mark.parametrize("klass,signed_sql", INTEGER_WIDTHS, + ids=INTEGER_WIDTH_IDS) + def test_unsigned_is_refused_not_rendered_signed(self, dialect, klass, + signed_sql): + """The defect itself: flipping the field stops the render.""" + with pytest.raises(UnsupportedFeatureError) as excinfo: + dialect.format_data_type(klass(dialect, unsigned=True)) + message = str(excinfo.value) + assert signed_sql in message, ( + f"{message!r} does not name the width {signed_sql!r}") + assert "unsigned" in message + + @pytest.mark.parametrize("klass,signed_sql", INTEGER_WIDTHS, + ids=INTEGER_WIDTH_IDS) + def test_the_refusal_says_what_to_do_instead(self, dialect, klass, + signed_sql): + """A refusal with no route forward is the same as silence, only louder. + + Every one of the four points at something the caller can actually + declare: a ``CHECK`` constraint where the backend has no unsigned type + at all, and :class:`SQLServerTinyIntType` on the one width whose + unsigned column T-SQL really does have. + """ + with pytest.raises(UnsupportedFeatureError) as excinfo: + dialect.format_data_type(klass(dialect, unsigned=True)) + message = str(excinfo.value) + suggestion = excinfo.value.suggestion + assert suggestion, "the refusal must carry a way forward" + if signed_sql == "TINYINT": + assert "SQLServerTinyIntType" in message + assert "SQLServerTinyIntType" in suggestion + else: + assert "no unsigned integer type" in message + assert "CHECK" in suggestion + + @pytest.mark.parametrize("klass,signed_sql", INTEGER_WIDTHS, + ids=INTEGER_WIDTH_IDS) + def test_the_named_way_forward_actually_renders(self, dialect, klass, + signed_sql): + """Advice that does not run is worse than a bare refusal. + + ``SQLServerTinyIntType`` is the class that declares the unsigned 8-bit + column, so the suggestion must name something this dialect renders and + must render it. + """ + if signed_sql != "TINYINT": + pytest.skip("only the 8-bit width has a class to point at") + assert dialect.format_data_type(SQLServerTinyIntType(dialect)) == ( + "TINYINT", () + ) + assert SQLServerTinyIntType(dialect).unsigned is True + + @pytest.mark.parametrize("klass,signed_sql", INTEGER_WIDTHS, + ids=INTEGER_WIDTH_IDS) + def test_a_bad_spelling_is_still_the_spelling_error(self, dialect, klass, + signed_sql): + """Order is part of the contract: the spelling gate runs first. + + The probe is chosen per concept, because a hardcoded one goes stale — + ``int4`` is a documented synonym of ``INTEGER`` and so is inside that + concept's list. If the signedness check ran first the caller would be + told about ``unsigned``'s meaning on a request whose actual fault is a + malformed word, and the closed list would stop being the only thing + standing between an arbitrary string and the DDL. + """ + unknown = _absent_spelling(klass) + with pytest.raises(TypeError, match=unknown): + dialect.format_data_type( + klass(dialect, spelling=unknown, unsigned=True)) + + @pytest.mark.parametrize("klass,signed_sql", INTEGER_WIDTHS, + ids=INTEGER_WIDTH_IDS) + def test_no_spelling_reaches_an_unsigned_column(self, dialect, klass, + signed_sql): + """Both spellings of each concept are refused for an unsigned request. + + Some backends accept only one of a concept's spellings; this one + accepts them all and normalises to the T-SQL word. Either way, no + spelling may be a way round the refusal, or the closed list would be a + decoration. + """ + for spelling in klass.SPELLINGS: + assert dialect.format_data_type( + klass(dialect, spelling=spelling)) == (signed_sql, ()) + with pytest.raises(UnsupportedFeatureError): + dialect.format_data_type( + klass(dialect, spelling=spelling, unsigned=True)) + + @pytest.mark.parametrize("klass,signed_sql", INTEGER_WIDTHS, + ids=INTEGER_WIDTH_IDS) + def test_signedness_still_participates_in_identity(self, dialect, klass, + signed_sql): + """Why refusing is the right answer: the two are different types. + + ``unsigned`` is in ``PARAMETERS``, so it reaches ``identity()`` and the + differ can see that a caller changed its mind. Refusing at render time + stops the DDL from lying about that; it must not flatten the model to + make the refusal easier. + """ + assert klass.PARAMETERS == ("unsigned",), klass.__name__ + signed = klass(dialect) + unsigned = klass(dialect, unsigned=True) + assert signed.identity() == (False,) + assert unsigned.identity() == (True,) + assert signed != unsigned + assert hash(signed) != hash(unsigned) + # ...and a signed declaration still equals itself and hashes equal. + assert klass(dialect) == signed + assert hash(klass(dialect)) == hash(signed) + assert hash(signed) == hash(klass(dialect)) + + @pytest.mark.parametrize("name", [klass.name for klass, _w in INTEGER_WIDTHS]) + def test_no_integer_formatter_drops_the_flag_silently(self, dialect, name): + """The rule in one assertion, over every width this dialect renders: + flipping the field either changes the SQL or raises. Byte-identical SQL + for both signs is the violation, whatever the reason for it. + """ + klass = dialect.supports_data_types()[name] + signed = dialect.format_data_type(klass(dialect))[0] + try: + unsigned = dialect.format_data_type( + klass(dialect, unsigned=True))[0] + except UnsupportedFeatureError: + return # refused — the other permitted answer + assert unsigned != signed, ( + f"{name}: unsigned=True renders {unsigned!r}, the same as " + f"unsigned=False — the flag is silently dropped") + + def test_the_backend_class_pins_the_flag_so_it_cannot_be_flipped(self, dialect): + """``SQLServerTinyIntType`` owns the unsigned 8-bit column. + + It pins ``unsigned=True`` in its own ``__init__``, so its formatter + cannot be reached with ``unsigned=False`` and has nothing to refuse — + which is why the gate sits on the generic concept instead. + """ + assert SQLServerTinyIntType.PARAMETERS == ("unsigned",) + with pytest.raises(TypeError): + SQLServerTinyIntType(dialect, unsigned=False) + assert dialect.format_data_type(SQLServerTinyIntType(dialect)) == ( + "TINYINT", () + ) + + def test_sql_server_has_exactly_one_unsigned_integer(self): + """Guards the conclusion the messages rest on, not the code. + + Every server-side claim in ``_refuse_unsigned_integer`` is + documentation-based — there is no SQL Server in this environment to + measure — so the evidence is written out here, and this fails if a + future reference documents an unsigned variant of one of the three + signed widths. + """ + documented_ranges = { + "BIGINT": "-9,223,372,036,854,775,808 to 9,223,372,036,854,775,807", + "INT": "-2,147,483,648 to 2,147,483,647", + "SMALLINT": "-32,768 to 32,767", + "TINYINT": "0 to 255", + } + for _klass, word in INTEGER_WIDTHS: + assert word in documented_ranges, word + # A range that reaches below zero is a signed one, and only TINYINT's + # does not — which is exactly why the four refusals do not share one + # reason and why two of them are two different messages. + unsigned_words = { + word for word, span in documented_ranges.items() + if not span.split(" to ")[0].startswith("-") + } + assert unsigned_words == {"TINYINT"} + + def test_the_range_table_the_messages_quote_is_registered(self): + """The formatters are copied onto the dialect class, so a table that + stayed on the mixin would be an ``AttributeError`` at render time — + which the refusal test would surface as a missing range, not as the + reason. Assert the copy directly instead. + """ + ranges = SQLServerDialect._SQLSERVER_INTEGER_RANGES + assert set(ranges) == {"INT", "SMALLINT", "BIGINT"}, ranges + assert "TINYINT" not in ranges, ( + "TINYINT is refused by _refuse_unsigned_tinyint, which quotes no " + "signed range because there is none" + ) + + +# --------------------------------------------------------------------------- +# DECIMAL scale: honoured when anchored to a precision, refused when not +# --------------------------------------------------------------------------- + +#: The T-SQL grammar this backend's ``decimal`` formatters implement, as the +#: reference writes it. Quoted here rather than only in the formatter because +#: every server-side claim on this file is documentation-based — there is no SQL +#: Server in this environment (``pyodbc`` has no data source) to measure — and a +#: test that only asserted behaviour would keep passing if the vendor changed +#: the grammar. +#: https://learn.microsoft.com/en-us/sql/t-sql/data-types/decimal-and-numeric-transact-sql +SQL_SERVER_DECIMAL_GRAMMAR = "decimal [ ( p [ , s ] ) ] and numeric [ ( p [ , s ] ) ]" + +SQL_SERVER_SCALE_RULE = ( + "Scale must be a value from 0 through p, and can only be specified if " + "precision is specified." +) + +#: Every concept this dialect renders whose ``PARAMETERS`` carry a precision or a +#: scale field, with the T-SQL it renders when the field is left alone. Five of +#: these six honour the field outright; ``DecimalType`` is the odd one out +#: because ``scale`` alone is not a declaration T-SQL admits. +FRACTIONAL_FIELDS = [ + (FloatType, ("precision",), "FLOAT"), + (DateTimeType, ("precision",), "DATETIME2"), + (TimeType, ("precision",), "TIME"), + (TimestampType, ("precision",), "DATETIME2"), + (TimestampTzType, ("precision",), "DATETIMEOFFSET"), + (DecimalType, ("precision", "scale"), "DECIMAL"), +] + +FRACTIONAL_FIELD_IDS = [ + f"{klass.name}.{'+'.join(fields)}" + for klass, fields, _sql in FRACTIONAL_FIELDS +] + +#: A value to flip each field to, chosen so the probe distinguishes "honoured" +#: from "refused" and not "refused because the number was out of range": every +#: one of these is inside the documented bound for its field on every formatter +#: below (``FLOAT`` 1-53, ``DATETIME2``/``TIME``/``DATETIMEOFFSET`` 0-7, +#: ``DECIMAL`` precision 1-38 with 0 <= s <= p). +FRACTIONAL_PROBE_VALUES = {"precision": 3, "scale": 2} + + +def _rendered_concepts_with_a_fractional_field(dialect): + """Every concept this dialect renders whose ``PARAMETERS`` name ``precision`` + or ``scale``, found by walking the supported-types mapping. + + Walked rather than listed so the table above cannot quietly fall behind: a + new concept with a fractional field turns up here and fails the equality + check against it. + """ + found = {} + for klass in dialect.supports_data_types().values(): + fields = tuple( + field for field in klass.PARAMETERS + if field in FRACTIONAL_PROBE_VALUES + ) + if fields: + found[klass] = fields + return found + + +class TestFractionalFieldsAreHonouredOrRefused: + """``precision`` and ``scale`` are in ``PARAMETERS``, so they are part of + these types' identity. + + Five of the six formatters here already read their field and render it. + ``DecimalType`` did not read ``scale`` unless a precision came with it, so a + scale on its own rendered bare ``DECIMAL`` — a different column to + ``identity()`` and to the differ, with byte-identical DDL. That is the + paradigm violation, and the fix is a refusal rather than a rewrite, because + T-SQL has no declaration for a bare scale. + """ + + @pytest.fixture + def dialect(self): + return SQLServerDialect(SQL_SERVER_2022) + + def test_the_table_is_every_rendered_concept_with_a_fractional_field(self, + dialect): + """The list above is the complete set, not a sample.""" + assert _rendered_concepts_with_a_fractional_field(dialect) == { + klass: fields for klass, fields, _sql in FRACTIONAL_FIELDS + }, ( + "a concept this dialect renders has gained, lost or renamed a " + "precision/scale identity field; the table and its refusals must " + "follow it" + ) + + @pytest.mark.parametrize("klass,fields,sql", FRACTIONAL_FIELDS, + ids=FRACTIONAL_FIELD_IDS) + def test_the_fieldless_rendering_is_unchanged(self, dialect, klass, fields, + sql): + """The refusal is not paid for by moving the bare rendering. + + These are the SQL fragments each of these concepts has always produced + here, for the default field *and* for every accepted spelling. + """ + for spelling in (klass.SPELLINGS or (None,)): + kwargs = {} if spelling is None else {"spelling": spelling} + assert dialect.format_data_type(klass(dialect, **kwargs)) == (sql, ()) + + @pytest.mark.parametrize("klass,fields,sql", FRACTIONAL_FIELDS, + ids=FRACTIONAL_FIELD_IDS) + def test_no_fractional_field_is_silently_dropped(self, dialect, klass, fields, + sql): + """The rule, in one assertion, over every fractional field this dialect + renders: flipping it either changes the SQL or raises. + + Byte-identical SQL for both values is the violation, whatever the reason + for it — which is why this asserts the *rule* rather than a list of the + particular fields that were fixed. + """ + for field in fields: + value = FRACTIONAL_PROBE_VALUES[field] + try: + flipped = dialect.format_data_type( + klass(dialect, **{field: value}))[0] + except UnsupportedFeatureError: + continue # refused — the other permitted answer + assert flipped != sql, ( + f"{klass.__name__}.{field}={value!r} renders {flipped!r}, the " + f"same as no {field} at all — the field is silently dropped" + ) + + @pytest.mark.parametrize("klass,fields,sql", FRACTIONAL_FIELDS, + ids=FRACTIONAL_FIELD_IDS) + def test_the_field_still_participates_in_identity(self, dialect, klass, fields, + sql): + """Why refusing is the right answer: the two really are different types. + + The fields are in ``PARAMETERS``, so they reach ``identity()`` and the + differ. Refusing at render time stops the DDL from lying about that; it + must not flatten the model to make the refusal easier. + """ + for field in fields: + assert field in klass.PARAMETERS, f"{klass.__name__}.{field}" + plain = klass(dialect) + declared = klass(dialect, **{field: FRACTIONAL_PROBE_VALUES[field]}) + assert plain != declared, f"{klass.__name__}.{field}" + assert hash(plain) != hash(declared), f"{klass.__name__}.{field}" + assert plain == klass(dialect) + + +class TestDecimalScaleNeedsAPrecision: + """``DecimalType.scale`` — T-SQL's grammar is ``decimal[(p[,s])]``. + + The scale is honoured on every request that carries a precision; a scale with + nothing to attach it to is refused, because the reference says a scale "can + only be specified if precision is specified" and there is no type-modifier + position after the type name where one could go instead. + https://learn.microsoft.com/en-us/sql/t-sql/data-types/decimal-and-numeric-transact-sql + """ + + @pytest.fixture + def dialect(self): + return SQLServerDialect(SQL_SERVER_2022) + + def test_the_renderings_that_carry_a_precision_are_unchanged(self, dialect): + """Before and after are identical for every request with a precision.""" + assert dialect.format_data_type(DecimalType(dialect)) == ("DECIMAL", ()) + assert dialect.format_data_type( + DecimalType(dialect, precision=10)) == ("DECIMAL(10)", ()) + assert dialect.format_data_type( + DecimalType(dialect, precision=10, scale=0)) == ("DECIMAL(10, 0)", ()) + assert dialect.format_data_type( + DecimalType(dialect, precision=10, scale=2)) == ("DECIMAL(10, 2)", ()) + assert dialect.format_data_type( + DecimalType(dialect, precision=38, scale=38)) == ("DECIMAL(38, 38)", ()) + for spelling in DecimalType.SPELLINGS: + assert dialect.format_data_type( + DecimalType(dialect, precision=10, scale=2, spelling=spelling) + ) == ("DECIMAL(10, 2)", ()) + + def test_an_anchored_scale_is_honoured_not_merely_accepted(self, dialect): + """The other permitted answer, pinned so it cannot be quietly swapped out. + + T-SQL's own reference is explicit that each pair is a distinct type: + "SQL Server considers each combination of precision and scale as a + different data type. For example, decimal(5,5) and decimal(5,0) are + considered different data types." So this dialect renders them + differently, and must keep doing so. + """ + zero = dialect.format_data_type( + DecimalType(dialect, precision=5, scale=0))[0] + five = dialect.format_data_type( + DecimalType(dialect, precision=5, scale=5))[0] + assert five == "DECIMAL(5, 5)" + assert zero != five + assert DecimalType(dialect, precision=5, scale=0) != DecimalType( + dialect, precision=5, scale=5) + + @pytest.mark.parametrize("scale", [0, 1, 2, 18, 38]) + def test_an_unanchored_scale_is_refused_not_ignored(self, dialect, scale): + """The defect itself: flipping ``scale`` with no precision stops the + render, where it used to render bare ``DECIMAL``.""" + with pytest.raises(UnsupportedFeatureError) as excinfo: + dialect.format_data_type(DecimalType(dialect, scale=scale)) + message = str(excinfo.value) + assert "scale" in message + assert f"scale={scale}" in message + assert "precision" in message, ( + f"{message!r} does not name the field that is missing" + ) + + def test_the_refusal_is_not_a_range_error(self, dialect): + """``scale=38`` with no precision is a *shape* problem, not a range one. + + The existing range checks still answer for values T-SQL does bound: a + scale that exceeds its precision, and a precision outside 1-38. A bare + scale is neither — it is a request with no spelling at all — so it is + reported as the missing field it is rather than as an out-of-range + number, which would send the caller looking in the wrong place. + """ + with pytest.raises(UnsupportedFeatureError): + dialect.format_data_type(DecimalType(dialect, scale=38)) + with pytest.raises(ValueError, match="must not exceed precision"): + dialect.format_data_type(DecimalType(dialect, precision=5, scale=10)) + with pytest.raises(ValueError, match="precision must be 1-38"): + dialect.format_data_type(DecimalType(dialect, precision=39, scale=0)) + + def test_the_refusal_says_what_to_do_instead(self, dialect): + """The route is a declaration, and it renders.""" + with pytest.raises(UnsupportedFeatureError) as excinfo: + dialect.format_data_type(DecimalType(dialect, scale=2)) + suggestion = excinfo.value.suggestion + assert suggestion + assert "precision" in suggestion + assert dialect.format_data_type( + DecimalType(dialect, precision=10, scale=2)) == ("DECIMAL(10, 2)", ()) + + def test_the_bare_decimal_is_not_the_default_column_it_looks_like(self, dialect): + """Why writing ``DECIMAL`` instead would be wrong, not merely lossy. + + T-SQL documents a default precision of 18 and a default scale of 0, so + bare ``DECIMAL`` is ``DECIMAL(18, 0)`` — 18 digits, none of them + fractional. A caller who asked for ``scale=2`` and nothing else asked + for a fractional column, and would have been handed one with none. The + refusal says what bare ``DECIMAL`` actually is so the caller can see the + difference rather than infer it. + """ + with pytest.raises(UnsupportedFeatureError) as excinfo: + dialect.format_data_type(DecimalType(dialect, scale=2)) + assert "DECIMAL(18, 0)" in excinfo.value.suggestion + assert "scale 0" in excinfo.value.suggestion + + def test_a_bad_spelling_is_still_the_spelling_error(self, dialect): + """Order is part of the contract: the spelling gate runs first. + + Were the scale check to run first, a request whose actual fault is a + malformed word would be told about scales instead — and the closed list + would stop being the only thing standing between an arbitrary string and + the DDL. + """ + with pytest.raises(TypeError, match="int4"): + dialect.format_data_type( + DecimalType(dialect, spelling="int4", scale=2)) + + def test_no_spelling_is_a_way_round_the_refusal(self, dialect): + for spelling in DecimalType.SPELLINGS: + with pytest.raises(UnsupportedFeatureError): + dialect.format_data_type( + DecimalType(dialect, spelling=spelling, scale=2)) + + def test_the_parser_never_produces_an_unanchored_scale(self, dialect): + """The refusal cannot be reached from this dialect's own round trip. + + ``parse_type`` reads ``(p, s)`` with a regex whose first group is the + precision, so a parsed ``scale`` always has a precision beside it — the + refusal is unreachable from introspection, which is what makes refusing + it safe. + """ + for raw, expected in ( + ("DECIMAL(10,2)", "DECIMAL(10, 2)"), + ("NUMERIC(10, 2)", "DECIMAL(10, 2)"), + ("dec(10,2)", "DECIMAL(10, 2)"), + ("DECIMAL(10)", "DECIMAL(10)"), + ("DECIMAL", "DECIMAL"), + ("NUMERIC", "DECIMAL"), + ("MONEY", "DECIMAL"), + ("SMALLMONEY", "DECIMAL"), + ): + assert dialect.format_data_type(dialect.parse_type(raw))[0] == expected, raw + + def test_the_documented_grammar_still_puts_the_scale_second(self): + """Guards the conclusion the refusal rests on, not the code. + + The reference's own syntax line and its ``s`` entry are written out here + so that a future version of the page which gave ``DECIMAL`` a bare scale + fails here — at which point the refusal above is wrong rather than merely + conservative. + """ + assert SQL_SERVER_DECIMAL_GRAMMAR == ( + "decimal [ ( p [ , s ] ) ] and numeric [ ( p [ , s ] ) ]" + ) + assert "can only be specified if precision is specified" in ( + SQL_SERVER_SCALE_RULE + ) + + +# --------------------------------------------------------------------------- +# Numbers outside the range the vendor documents — ValueError, not a refusal +# --------------------------------------------------------------------------- + + +#: The bound itself, from the rest of the same ``s`` (scale) entry above — the +#: part ``_check_decimal_scale_sign`` enforces, and the part the earlier +#: ``scale <= precision`` comparison could not see because it has no lower edge. +#: https://learn.microsoft.com/en-us/sql/t-sql/data-types/decimal-and-numeric-transact-sql +SQL_SERVER_SCALE_BOUND = "The default scale is 0, and so 0 <= s <= p." + + +class TestNegativeDecimalScaleIsRefused: + """``DecimalType(precision=10, scale=-1)`` used to render ``DECIMAL(10, -1)``. + + The existing ``scale <= precision`` comparison could not see it, because + ``-1`` is not greater than ``10`` — so the check passed, the statement was + written, and the server rejected it. Nothing was dropped: the declared value + reached the DDL exactly as declared, which is why the fix belongs here, at the + point where the mistake is made, and not in a clamp. + + The bound is the vendor's, quoted in the same entry that forbids a bare scale: + "Scale must be a value from 0 through *p* ... and so 0 <= s <= p". + https://learn.microsoft.com/en-us/sql/t-sql/data-types/decimal-and-numeric-transact-sql + """ + + @pytest.fixture + def dialect(self): + return SQLServerDialect(SQL_SERVER_2022) + + @pytest.mark.parametrize("precision,scale", [ + (10, -1), (1, -1), (38, -1), (5, -38), (10, -2), (1, -100), + ]) + def test_a_negative_scale_is_refused(self, dialect, precision, scale): + with pytest.raises(ValueError) as excinfo: + dialect.format_data_type( + DecimalType(dialect, precision=precision, scale=scale)) + message = str(excinfo.value) + assert "must not be negative" in message, message + assert f"got {scale}" in message, ( + f"the message must name the offending value; got {message!r}" + ) + assert "0 <= s <= p" in message, ( + f"the message must quote the documented bound; got {message!r}" + ) + + def test_a_bare_negative_scale_is_refused_too(self, dialect): + """No precision is needed to know a negative scale is wrong. + + The lower bound holds for every precision — the smallest documented + precision is 1 — so ``scale=-1`` cannot be rescued by adding one. That + is why it is a range error rather than the un-anchorable-scale refusal: + telling the caller to "declare the precision as well" would be advice + that does not work. + """ + with pytest.raises(ValueError, match="must not be negative"): + dialect.format_data_type(DecimalType(dialect, scale=-1)) + + def test_it_is_not_clamped_to_zero(self, dialect): + """Clamping would be the same defect as the silent drop it replaces. + + ``DECIMAL(10, 0)`` is a column other than the one declared, and the + caller would never be told. + """ + with pytest.raises(ValueError): + dialect.format_data_type( + DecimalType(dialect, precision=10, scale=-1)) + assert dialect.format_data_type( + DecimalType(dialect, precision=10, scale=0)) == ("DECIMAL(10, 0)", ()) + + def test_it_is_a_value_error_and_not_a_refusal(self, dialect): + """The cross-backend convention, checked rather than assumed. + + ``UnsupportedFeatureError`` does **not** subclass ``ValueError``, so the + two are not interchangeable to a caller writing an ``except`` clause, and + only the second carries a route forward. ``DECIMAL(p, s)`` with ``s < 0`` + is a declaration this grammar expresses perfectly well — the number is + what is wrong — so it must not answer with the inexpressible-declaration + error. + """ + with pytest.raises(ValueError) as excinfo: + dialect.format_data_type(DecimalType(dialect, precision=10, scale=-1)) + assert not isinstance(excinfo.value, UnsupportedFeatureError) + + def test_the_scale_bounds_still_hold_on_either_side(self, dialect): + """The neighbouring boundaries, so the new check cannot have moved them.""" + assert dialect.format_data_type( + DecimalType(dialect, precision=10, scale=0)) == ("DECIMAL(10, 0)", ()) + assert dialect.format_data_type( + DecimalType(dialect, precision=10, scale=10)) == ("DECIMAL(10, 10)", ()) + with pytest.raises(ValueError, match="must not exceed precision"): + dialect.format_data_type( + DecimalType(dialect, precision=10, scale=11)) + # T-SQL's documented precision ceiling, unchanged. + assert dialect.format_data_type( + DecimalType(dialect, precision=38, scale=38)) == ("DECIMAL(38, 38)", ()) + with pytest.raises(ValueError, match="precision must be 1-38"): + dialect.format_data_type( + DecimalType(dialect, precision=39, scale=0)) + with pytest.raises(ValueError, match="precision must be 1-38"): + dialect.format_data_type( + DecimalType(dialect, precision=0, scale=0)) + + def test_the_precision_bound_is_still_enforced_on_the_scaled_path(self, + dialect): + """``1..38`` is checked on the path that carries a scale as well. + + Both bounds live on the same path in ``format_data_type_decimal``, so a + check added for one can quietly disable the other; this pins both. + """ + for precision in (0, 39, 100): + for scale in (None, 0, 5): + with pytest.raises(ValueError, match="precision must be 1-38"): + dialect.format_data_type(DecimalType( + dialect, precision=precision, scale=scale)) + + def test_a_bad_spelling_is_still_the_spelling_error(self, dialect): + """Order is part of the contract, and it is unchanged. + + The spelling gate runs before the range check, so a request whose real + fault is a malformed word is told about the word rather than about the + scale. + """ + unknown = _absent_spelling(DecimalType) + with pytest.raises(TypeError, match=unknown): + dialect.format_data_type( + DecimalType(dialect, spelling=unknown, precision=10, scale=-1)) + + def test_no_spelling_is_a_way_round_the_check(self, dialect): + for spelling in DecimalType.SPELLINGS: + with pytest.raises(ValueError, match="must not be negative"): + dialect.format_data_type( + DecimalType(dialect, spelling=spelling, precision=10, + scale=-1)) + + def test_the_valid_renderings_are_byte_identical(self, dialect): + """Before and after are the same SQL for every documented value.""" + for precision, scale, expected in ( + (None, None, "DECIMAL"), + (10, None, "DECIMAL(10)"), + (10, 0, "DECIMAL(10, 0)"), + (10, 2, "DECIMAL(10, 2)"), + (1, 1, "DECIMAL(1, 1)"), + (38, 38, "DECIMAL(38, 38)"), + ): + data_type = (DecimalType(dialect) if precision is None + else DecimalType(dialect, precision=precision, + scale=scale)) + assert dialect.format_data_type(data_type) == (expected, ()), data_type + + def test_the_documented_bound_is_still_what_is_checked(self): + """Guards the conclusion, not the code — there is no SQL Server here. + + Every server-side claim in ``_check_decimal_scale_sign`` is + documentation-based, so the sentence is written out rather than asserted + against a server. + https://learn.microsoft.com/en-us/sql/t-sql/data-types/decimal-and-numeric-transact-sql + """ + assert "0 <= s <= p" in SQL_SERVER_SCALE_BOUND + assert "0 through p" in SQL_SERVER_SCALE_RULE + assert "can only be specified if precision is specified" in ( + SQL_SERVER_SCALE_RULE + ) + + +class TestLengthsAreRangeChecked: + """``VARCHAR(n)``, ``CHAR(n)``, ``NVARCHAR(n)``, ``NCHAR(n)``, + ``VARBINARY(n)`` — a length outside the documented inline range. + + ``VARCHAR(9000)`` and ``NVARCHAR(4001)`` rendered, and the server answers + those with the classic "The size of the data type varchar(n) exceeds the + maximum allowed for any data type (8000)". Nothing was dropped — the length + reached the DDL exactly as declared — so the refusal belongs at the point of + the mistake, not in a truncation. + https://learn.microsoft.com/en-us/sql/t-sql/data-types/char-and-varchar-transact-sql + https://learn.microsoft.com/en-us/sql/t-sql/data-types/nchar-and-nvarchar-transact-sql + https://learn.microsoft.com/en-us/sql/t-sql/data-types/binary-and-varbinary-transact-sql + """ + + @pytest.fixture + def dialect(self): + return SQLServerDialect(SQL_SERVER_2022) + + #: ``(class, factory)`` for every formatter that carries a length. + LENGTH_CARRIERS = [ + (CharType, lambda d, n: CharType(d, length=n)), + (VarCharType, lambda d, n: VarCharType(d, length=n)), + (SQLServerNCharType, lambda d, n: SQLServerNCharType(d, length=n)), + (SQLServerNVarCharType, lambda d, n: SQLServerNVarCharType(d, length=n)), + (SQLServerVarBinaryType, lambda d, n: SQLServerVarBinaryType(d, length=n)), + ] + + CARRIER_IDS = [klass.name for klass, _ in LENGTH_CARRIERS] + + @pytest.mark.parametrize("klass,build", LENGTH_CARRIERS, ids=CARRIER_IDS) + @pytest.mark.parametrize("length", [-1, -8000]) + def test_a_negative_length_is_refused(self, dialect, klass, build, length): + """Both documented readings of these pages exclude a negative ``n``. + + ``char-and-varchar`` says "1 through 8,000" in its Arguments and + "(0 to 8,000)" in its Remarks, so ``n = 0`` is one the documentation + disagrees about and is rendered verbatim. A negative is excluded by both, + and by ``binary-and-varbinary``'s single stated range too. + """ + with pytest.raises(ValueError) as excinfo: + dialect.format_data_type(build(dialect, length)) + message = str(excinfo.value) + assert f"got {length}" in message, ( + f"the message must name the offending value; got {message!r}" + ) + assert "length must be" in message + + @pytest.mark.parametrize( + "klass,build,smallest,largest", + [ + (CharType, lambda d, n: CharType(d, length=n), 0, 8000), + (VarCharType, lambda d, n: VarCharType(d, length=n), 0, 8000), + (SQLServerNCharType, lambda d, n: SQLServerNCharType(d, length=n), + 0, 4000), + (SQLServerNVarCharType, lambda d, n: SQLServerNVarCharType(d, length=n), + 0, 4000), + (SQLServerVarBinaryType, + lambda d, n: SQLServerVarBinaryType(d, length=n), 1, 8000), + ], + ids=CARRIER_IDS, + ) + def test_a_length_above_the_documented_ceiling_is_refused( + self, dialect, klass, build, smallest, largest): + with pytest.raises(ValueError) as excinfo: + dialect.format_data_type(build(dialect, largest + 1)) + message = str(excinfo.value) + assert f"must be {smallest}-{largest}" in message, message + assert f"got {largest + 1}" in message + + @pytest.mark.parametrize( + "klass,build,smallest,largest,word", + [ + (CharType, lambda d, n: CharType(d, length=n), 0, 8000, "CHAR"), + (VarCharType, lambda d, n: VarCharType(d, length=n), 0, 8000, + "VARCHAR"), + (SQLServerNCharType, lambda d, n: SQLServerNCharType(d, length=n), + 0, 4000, "NCHAR"), + (SQLServerNVarCharType, lambda d, n: SQLServerNVarCharType(d, length=n), + 0, 4000, "NVARCHAR"), + (SQLServerVarBinaryType, + lambda d, n: SQLServerVarBinaryType(d, length=n), 1, 8000, + "VARBINARY"), + ], + ids=CARRIER_IDS, + ) + def test_a_length_on_the_ceiling_still_renders( + self, dialect, klass, build, smallest, largest, word): + """The boundary value itself, on the side that must keep working.""" + assert dialect.format_data_type(build(dialect, largest)) == ( + f"{word}({largest})", () + ) + if smallest: + assert dialect.format_data_type(build(dialect, smallest)) == ( + f"{word}({smallest})", () + ) + + def test_the_national_types_have_the_smaller_ceiling(self, dialect): + """Why ``NVARCHAR`` is 4000 and ``VARCHAR`` is 8000. + + ``n`` counts byte-pairs in the national types and bytes in the others, so + the same number of characters costs twice as much storage — which is the + vendor's own reason for the halved ceiling, and the reason the bound is + looked up per word rather than shared. + """ + assert dialect.format_data_type( + VarCharType(dialect, length=8000)) == ("VARCHAR(8000)", ()) + with pytest.raises(ValueError, match="must be 0-4000"): + dialect.format_data_type(SQLServerNVarCharType(dialect, length=8000)) + assert dialect.format_data_type( + SQLServerNVarCharType(dialect, length=4000)) == ("NVARCHAR(4000)", ()) + + def test_varbinary_zero_is_refused_and_the_other_zero_is_not(self, dialect): + """The one asymmetry in the enforced table, stated as a test. + + ``binary-and-varbinary`` documents a single range — "n can be a value from + 1 through 8,000" — so 0 is outside it and is refused. The two character + pages state two different lower bounds ("1 through 8,000" and + "(0 to 8,000)"), so 0 is a value the documentation does not exclude, and + refusing it would be picking a side. ``VARCHAR(0)`` is rendered verbatim: + what the caller wrote is what reaches the DDL. + """ + with pytest.raises(ValueError, match="must be 1-8000"): + dialect.format_data_type(SQLServerVarBinaryType(dialect, length=0)) + assert dialect.format_data_type( + VarCharType(dialect, length=0)) == ("VARCHAR(0)", ()) + assert dialect.format_data_type(CharType(dialect, length=0)) == ("CHAR(0)", ()) + + def test_no_length_still_renders_the_dialect_default(self, dialect): + """The absent parameter is untouched: only a *declared* length is bound.""" + assert dialect.format_data_type(VarCharType(dialect)) == ("VARCHAR(255)", ()) + assert dialect.format_data_type(CharType(dialect)) == ("CHAR(1)", ()) + assert dialect.format_data_type(SQLServerNVarCharType(dialect)) == ( + "NVARCHAR(255)", () + ) + assert dialect.format_data_type(SQLServerNCharType(dialect)) == ( + "NCHAR(1)", () + ) + assert dialect.format_data_type(SQLServerVarBinaryType(dialect)) == ( + "VARBINARY(255)", () + ) + + @pytest.mark.parametrize("klass,build", LENGTH_CARRIERS, ids=CARRIER_IDS) + @pytest.mark.parametrize("length", [1, 2, 255, 1000]) + def test_an_ordinary_length_is_byte_identical(self, dialect, klass, build, + length): + """Before and after are the same SQL for every length in range.""" + assert dialect.format_data_type(build(dialect, length))[0].endswith( + f"({length})" + ) + + @pytest.mark.parametrize("klass,build", LENGTH_CARRIERS, ids=CARRIER_IDS) + def test_it_is_a_value_error_and_not_a_refusal(self, dialect, klass, build): + """The cross-backend convention, checked rather than assumed. + + ``VARCHAR(n)`` is a declaration this grammar expresses perfectly well; + the number is out of range. ``UnsupportedFeatureError`` does not + subclass ``ValueError`` and carries a ``suggestion`` meant for the case + where there is no spelling at all, so neither belongs here. + """ + with pytest.raises(ValueError) as excinfo: + dialect.format_data_type(build(dialect, 8001)) + assert not isinstance(excinfo.value, UnsupportedFeatureError) + assert not hasattr(excinfo.value, "suggestion") + + def test_the_documented_ceilings_are_still_what_is_enforced(self): + """Guards the conclusion, not the code — there is no SQL Server here. + + Every server-side claim in ``_check_length`` is documentation-based, so + the bounds are written out here rather than asserted against a server. + """ + assert SQLServerDialect._SQLSERVER_LENGTH_LIMITS == { + "CHAR": (0, 8000), + "VARCHAR": (0, 8000), + "NCHAR": (0, 4000), + "NVARCHAR": (0, 4000), + "VARBINARY": (1, 8000), + } + + +# --------------------------------------------------------------------------- +# parse_type: the shared round-trip sweep over the whole declared surface + + +class TestParseRoundTripSweep: + """Every rendered type parses back coherently, in one assertion each way. + + The formatter contracts above are thorough on the write side -- refusals, + ranges, identity -- but the read side had exactly one test + (``test_the_parser_never_produces_an_unanchored_scale``). The testsuite's + ``parse_roundtrip_failures`` now sweeps the whole round-trip registry: + **string stability** -- parsing a rendering and re-rendering the answer + produces the identical string -- and **class honesty** -- the answer is + the declared instance, or the documented widening answer recorded below + with its reason. + + The widening answers are storage facts: ``BLOB`` is ``VARBINARY(MAX)`` and + ``BIT`` is this backend's boolean; ``DATETIME2`` is the storage of the + timestamp concept here and also of the ``DATETIME`` concept, so the + storage's own concept answers; ``NVARCHAR(MAX)`` is unbounded text, which + is what a JSON or NVARCHAR(MAX) declaration is stored as; ``FLOAT(53)`` + is the one float type, and the parse answers with the concept whose + rendering that is; ``XML`` and ``TINYINT`` are the backend's own classes + for those storages. A bare ``SQLServerVarBinaryType`` declaration carries + the dialect's default length, and the parse hands back the same class + with the parameters the rendered string named. + """ + + WIDENING_ANSWERS = { + "BlobType": "SQLServerVarBinaryMaxType", + "BooleanType": "SQLServerBitType", + "TimestampType": "DateTimeType", + "TinyIntType": "SQLServerTinyIntType", + "JsonType": "TextType", + "DoubleType": "FloatType", + "XmlType": "SQLServerXmlType", + "SQLServerNVarCharMaxType": "TextType", + "SQLServerVarBinaryType": "SQLServerVarBinaryType", + } + + @pytest.fixture(scope="class") + def registry(self): + """The round-trip module's registry, loaded from beside this file. + + Executing it also registers its special constructors, which the + sweep's ``make_instance`` consults -- the same registrations the + full suite performs at collection time, repeated here so the + sweep also holds when this file runs alone. + """ + import importlib.util + from pathlib import Path + + path = Path(__file__).with_name("test_expression_roundtrip_all.py") + spec = importlib.util.spec_from_file_location("sqlserver_rt_registry", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + registry = getattr(module, "ALL_CLASSES", None) or module.REGISTERED + assert registry, "the round-trip module exposes no registry" + return registry + + def test_every_rendered_type_parses_back_coherently(self, registry): + from rhosocial.activerecord.testsuite.utils.parse_contract import ( + parse_roundtrip_failures, + ) + + dialect = SQLServerDialect(SQL_SERVER_2022) + failures = parse_roundtrip_failures( + dialect, + registry, + widening=self.WIDENING_ANSWERS, + ) + assert failures == [], "\n".join(failures) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_table_type_definition_roundtrip.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_table_type_definition_roundtrip.py new file mode 100644 index 0000000..5401e73 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_table_type_definition_roundtrip.py @@ -0,0 +1,232 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_table_type_definition_roundtrip.py +"""Round-trip contract for ``SQLServerTableTypeDefinition``. + +A SQL Server table TYPE has two spellings for the same list of table +constraints: ``constraints`` and the keyword-only ``table_constraints`` +(named to match the model-level attribute). They are mutually exclusive. This +class used to override ``get_params()`` to always emit the list under +``constraints``; core's contract test +``test_expression_contract.py::TestInitParamAttributeContract::test_no_get_params_override`` +forbids that, because the generic introspection path is the single +serialization path. + +The repair is to fold the state into ``__init__``: it stores the merged list +under the slot matching the parameter the caller used and leaves the unused +spelling ``None``. These tests prove the generic path then reconstructs the +same expression — from *both* spellings — with identical SQL, and that it +never emits a key the constructor would reject. +""" + +import inspect + +import pytest + +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.serialization import ( + deserialize, + deserialize_json, + serialize, + serialize_json, +) +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + ColumnConstraint, + ColumnConstraintType, + ColumnDefinition, + TableConstraint, + TableConstraintType, +) +from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType +from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect +from rhosocial.activerecord.backend.impl.sqlserver.expression.ddl.type import ( + SQLServerTableTypeDefinition, +) +from rhosocial.activerecord.backend.impl.sqlserver.expression.index import ( + SQLServerIndexDefinition, +) +from rhosocial.activerecord.testsuite.utils.expression import assert_params_equal + +SQL_SERVER_2014 = (12, 0, 0) + + +@pytest.fixture +def dialect(): + return SQLServerDialect(SQL_SERVER_2014) + + +def _columns(dialect): + return [ + ColumnDefinition( + dialect, + "id", + IntegerType(dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)], + ), + ColumnDefinition(dialect, "name", VarCharType(dialect, length=100)), + ] + + +def _constraints(dialect): + return [TableConstraint(dialect, TableConstraintType.PRIMARY_KEY, columns=["id"])] + + +def _round_trip(expr, dialect): + """get_params() -> reconstruct from those params alone -> prove fidelity. + + Returns the emitted params so the caller can inspect their shape. + """ + params = expr.get_params() + + accepted = set(inspect.signature(type(expr).__init__).parameters) + rejected = sorted((set(params) | set(serialize(expr)["params"])) - accepted) + assert not rejected, ( + f"get_params() emitted keys the constructor would reject: {rejected}" + ) + + for restored in ( + deserialize(serialize(expr), dialect), + deserialize_json(serialize_json(expr), dialect), + ): + rebuilt = type(expr)(dialect, **restored.get_params()) + assert_params_equal(rebuilt.get_params(), expr.get_params()) + assert rebuilt.to_sql() == expr.to_sql() + + return params + + +class TestSQLServerTableTypeDefinitionRoundTrip: + """Both spellings survive the generic get_params() round trip.""" + + def test_constraints_spelling_round_trips(self, dialect): + constraints = _constraints(dialect) + indexes = [SQLServerIndexDefinition(dialect, "ix_name", ["name"], type="NONCLUSTERED")] + expr = SQLServerTableTypeDefinition( + dialect, + columns=_columns(dialect), + constraints=constraints, + indexes=indexes, + memory_optimized=True, + ) + + params = _round_trip(expr, dialect) + + # The used spelling carries the list; the unused one says "not supplied". + assert params["constraints"] == constraints + assert params["table_constraints"] is None + assert expr.constraints == constraints + assert expr.table_constraints is None + assert expr.constraint_definitions == constraints + sql, bind_params = expr.to_sql() + assert sql == ( + "AS TABLE ([id] INT NOT NULL, [name] VARCHAR(100), " + "PRIMARY KEY NONCLUSTERED ([id]), INDEX [ix_name] NONCLUSTERED ([name])) " + "WITH (MEMORY_OPTIMIZED = ON)" + ) + assert bind_params == () + + def test_table_constraints_spelling_round_trips(self, dialect): + constraints = _constraints(dialect) + indexes = [SQLServerIndexDefinition(dialect, "ix_name", ["name"], type="NONCLUSTERED")] + expr = SQLServerTableTypeDefinition( + dialect, + columns=_columns(dialect), + indexes=indexes, + memory_optimized=True, + table_constraints=constraints, + ) + + params = _round_trip(expr, dialect) + + assert params["table_constraints"] == constraints + assert params["constraints"] is None + assert expr.table_constraints == constraints + assert expr.constraints is None + assert expr.constraint_definitions == constraints + # Byte-identical to the `constraints=` spelling. + assert expr.to_sql() == SQLServerTableTypeDefinition( + dialect, + columns=_columns(dialect), + constraints=constraints, + indexes=indexes, + memory_optimized=True, + ).to_sql() + + def test_neither_spelling_round_trips(self, dialect): + expr = SQLServerTableTypeDefinition( + dialect, columns=[ColumnDefinition(dialect, "id", IntegerType(dialect))] + ) + + params = _round_trip(expr, dialect) + + assert params["constraints"] is None + assert params["table_constraints"] is None + assert expr.constraint_definitions == [] + assert expr.to_sql() == ("AS TABLE ([id] INT)", ()) + + def test_empty_constraints_sequence_round_trips(self, dialect): + expr = SQLServerTableTypeDefinition( + dialect, columns=_columns(dialect), constraints=[] + ) + + params = _round_trip(expr, dialect) + + assert params["constraints"] == [] + assert params["table_constraints"] is None + assert expr.to_sql() == ("AS TABLE ([id] INT NOT NULL, [name] VARCHAR(100))", ()) + + def test_memory_optimized_key_constraint_path_uses_merged_list(self, dialect): + """The memory-optimized index/key budget must see the constraints.""" + expr = SQLServerTableTypeDefinition( + dialect, + columns=[ColumnDefinition(dialect, "id", IntegerType(dialect))], + memory_optimized=True, + table_constraints=[ + TableConstraint(dialect, TableConstraintType.PRIMARY_KEY, columns=["id"]) + ], + ) + + params = _round_trip(expr, dialect) + + assert params["constraints"] is None + assert [c.constraint_type for c in params["table_constraints"]] == [ + TableConstraintType.PRIMARY_KEY + ] + assert expr.to_sql() == ( + "AS TABLE ([id] INT, PRIMARY KEY NONCLUSTERED ([id])) " + "WITH (MEMORY_OPTIMIZED = ON)", + (), + ) + + def test_both_spellings_at_once_still_rejected(self, dialect): + with pytest.raises(ValueError, match="mutually exclusive"): + SQLServerTableTypeDefinition( + dialect, + columns=_columns(dialect), + constraints=_constraints(dialect), + table_constraints=_constraints(dialect), + ) + + def test_check_constraint_condition_survives(self, dialect): + condition = Literal(dialect, 1, inline_literals=True) > Literal( + dialect, 0, inline_literals=True + ) + check = TableConstraint( + dialect, + TableConstraintType.CHECK, + check_condition=condition, + ) + expr = SQLServerTableTypeDefinition( + dialect, + columns=_columns(dialect), + constraints=[check], + ) + + params = _round_trip(expr, dialect) + + assert params["constraints"] == [check] + assert params["table_constraints"] is None + assert expr.to_sql()[0].endswith("CHECK (1 > 0))") + + def test_class_does_not_override_get_params(self): + from rhosocial.activerecord.backend.expression.bases import BaseExpression + + assert SQLServerTableTypeDefinition.get_params is BaseExpression.get_params \ No newline at end of file