From 32b769eab7180c037ded55a531f61c295762ab4b Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 11 Sep 2026 02:45:55 +0800 Subject: [PATCH 01/58] feat(sqlserver): adapt to node-based expression system and declarative format_method - Backend types carry sqlserver_-prefixed generic names; dispatch by naming convention (format_data_type_); @DDLTypeMixin.handles registration removed. - Formatting functions take (self, expr); CAST is a CastExpression node. - CI installs rhosocial-activerecord and the testsuite from their active branches. - Renamed private format functions to remove backend name suffix. --- .github/workflows/test.yml | 36 ++-- .../backend/impl/sqlserver/dialect.py | 32 +-- .../impl/sqlserver/expression/types.py | 147 ++++++++++++-- .../backend/impl/sqlserver/mixins/routine.py | 14 +- .../backend/impl/sqlserver/mixins/types.py | 184 ++++++++++++------ .../test_alter_table_if_exists_backend.py | 4 +- 6 files changed, 302 insertions(+), 115 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e46a578..87e009a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -59,8 +59,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/parameter-inline" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" - name: Create test database run: | @@ -148,8 +148,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/parameter-inline" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" - name: Create test database run: | @@ -237,8 +237,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/parameter-inline" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" - name: Create test database run: | @@ -326,8 +326,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/parameter-inline" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" - name: Create test database run: | @@ -415,8 +415,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/parameter-inline" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" - name: Create test database run: | @@ -504,8 +504,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/parameter-inline" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" - name: Create test database run: | @@ -594,8 +594,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/parameter-inline" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" - name: Create test database run: | @@ -683,8 +683,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/parameter-inline" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" - name: Create test database run: | @@ -781,8 +781,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/parameter-inline" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" - name: Create test database run: | diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index fd2ba85..817d9b9 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -43,6 +43,7 @@ IntrospectionSupport, TransactionControlSupport, SQLFunctionSupport, + DDLTypeSupport, ) from .protocols import ( SQLServerTableSupport, @@ -97,7 +98,6 @@ DDLColumnMixin, DDLTypeMixin, ) -# SQLServerTypeSupportMixin is imported lazily in _register_type_formatters() from rhosocial.activerecord.backend.dialect.protocols import PartitionSupport from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from .collation import validate_sqlserver_collation_name @@ -254,6 +254,8 @@ class SQLServerDialect( IntrospectionSupport, TransactionControlSupport, SQLFunctionSupport, + # DataType Support Protocol + DDLTypeSupport, # SQL Server-specific protocols (marker classes for isinstance() checks; # implementations live in SQLServerProtocolSupportMixin / SQLServerSequenceMixin) SQLServerOutputSupport, @@ -386,7 +388,7 @@ def format_date_trunc_expression(self, expr: "Any") -> Tuple[str, Tuple]: sql = f"DATETRUNC({field}, {source_sql})" else: raise UnsupportedFeatureError(self.name, f"date_trunc({expr.field.value})") - return self._apply_value_expression_modifiers(sql, source_params, expr) + return self.apply_alias(sql, source_params, expr) def format_interval_expression(self, expr: "Any") -> Tuple[str, Tuple]: raise UnsupportedFeatureError( @@ -399,7 +401,7 @@ def format_datetime_add_expression(self, expr: "Any") -> Tuple[str, Tuple]: source_sql, source_params = expr.source.to_sql() unit = expr.interval.unit.value.upper() sql = f"DATEADD({unit}, ?, {source_sql})" - return self._apply_value_expression_modifiers( + return self.apply_alias( sql, (expr.interval.value,) + source_params, expr ) @@ -407,7 +409,7 @@ def format_datetime_subtract_expression(self, expr: "Any") -> Tuple[str, Tuple]: source_sql, source_params = expr.source.to_sql() unit = expr.interval.unit.value.upper() sql = f"DATEADD({unit}, ?, {source_sql})" - return self._apply_value_expression_modifiers( + return self.apply_alias( sql, (-expr.interval.value,) + source_params, expr ) @@ -415,7 +417,7 @@ def format_datetime_diff_expression(self, expr: "Any") -> Tuple[str, Tuple]: start_sql, start_params = expr.start.to_sql() end_sql, end_params = expr.end.to_sql() sql = f"DATEDIFF({expr.unit.value.upper()}, {start_sql}, {end_sql})" - return self._apply_value_expression_modifiers(sql, start_params + end_params, expr) + return self.apply_alias(sql, start_params + end_params, expr) def supports_collate_expression(self) -> bool: """SQL Server supports expression-level COLLATE.""" @@ -1819,7 +1821,7 @@ def format_function_call( name = getattr(expr, "func_name", None) if name and name.upper() in self._NILADIC_FUNCTION_EQUIVALENTS: sql = self._NILADIC_FUNCTION_EQUIVALENTS[name.upper()] - return self._apply_value_expression_modifiers(sql, (), expr) + return self.apply_alias(sql, (), expr) if name and name.upper() in self._FUNCTION_NAME_EQUIVALENTS: renamed_expr = self._clone_with_func_name(expr, self._FUNCTION_NAME_EQUIVALENTS[name.upper()]) return super().format_function_call(renamed_expr, filter_predicate) @@ -1891,19 +1893,17 @@ def format_query_option_clause(self, clause) -> Tuple[str, tuple]: # Lazy import to avoid circular dependency (backend → dialect → mixins.types → backend) def _register_type_formatters(): + """Copy ``format_data_type_*`` / ``parse_type`` methods from the mixin + to the dialect class so that the naming-convention dispatch in + ``DDLTypeMixin.format_data_type()`` finds them. + """ from .mixins.types import SQLServerTypeSupportMixin - if not hasattr(SQLServerDialect, "_type_formatters"): - SQLServerDialect._type_formatters = {} for member_name in dir(SQLServerTypeSupportMixin): - member = getattr(SQLServerTypeSupportMixin, member_name, None) - handles_types = getattr(member, "_handles_types", None) - if handles_types is not None: - for dt_cls in handles_types: - SQLServerDialect._type_formatters[dt_cls] = member_name - setattr(SQLServerDialect, member_name, member) - elif member_name == "parse_type" and callable(member): - setattr(SQLServerDialect, member_name, member) + if member_name.startswith("format_data_type_") or member_name == "parse_type": + member = getattr(SQLServerTypeSupportMixin, member_name, None) + if callable(member): + setattr(SQLServerDialect, member_name, member) def _register_partition_formatters(): diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/types.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/types.py index d34d4ea..458f7ca 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/types.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/types.py @@ -1,52 +1,165 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/expression/types.py -"""SQL Server-specific DDL DataType subclasses.""" +"""SQL Server-specific DataType subclasses. -from rhosocial.activerecord.backend.expression.types import VarCharType, IntegerType +Backend-specific types carry the ``sqlserver_`` prefix in their ``name`` +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. +""" + +from typing import Optional, Set + +from rhosocial.activerecord.backend.expression.types import ( + VarCharType, + CharType, + IntegerType, + BlobType, + BooleanType, +) class SQLServerNVarCharType(VarCharType): """SQL Server NVARCHAR type.""" - pass + name = "sqlserver_nvarchar" + + def __init__(self, length: Optional[int] = None, dialect=None): + super().__init__(length, dialect) + + def __eq__(self, other: object) -> bool: + if type(self) is not type(other): + return False + return self.length == other.length + + def __hash__(self) -> int: + return hash((type(self), self.length)) -class SQLServerNCharType(VarCharType): + @classmethod + def synonyms(cls) -> Set[str]: + return {"VarCharType"} + + +class SQLServerNCharType(CharType): """SQL Server NCHAR type.""" - pass + + name = "sqlserver_nchar" + + def __init__(self, length: Optional[int] = None, dialect=None): + super().__init__(length, dialect) + + def __eq__(self, other: object) -> bool: + if type(self) is not type(other): + return False + return self.length == other.length + + def __hash__(self) -> int: + return hash((type(self), self.length)) + + @classmethod + def synonyms(cls) -> Set[str]: + return {"CharType"} class SQLServerNVarCharMaxType(VarCharType): """SQL Server NVARCHAR(MAX) type.""" - pass + name = "sqlserver_nvarchar_max" + + def __init__(self, dialect=None): + super().__init__(None, dialect) + + @classmethod + def synonyms(cls) -> Set[str]: + return {"VarCharType", "TextType"} -class SQLServerVarBinaryType(VarCharType): + +class SQLServerVarBinaryType(BlobType): """SQL Server VARBINARY type.""" - pass + name = "sqlserver_varbinary" + + length: Optional[int] = None + + def __init__(self, length: Optional[int] = None, dialect=None): + super().__init__(dialect) + self.length = length + + def __eq__(self, other: object) -> bool: + if type(self) is not type(other): + return False + return self.length == other.length -class SQLServerVarBinaryMaxType(VarCharType): + def __hash__(self) -> int: + return hash((type(self), self.length)) + + @classmethod + def synonyms(cls) -> Set[str]: + return {"BlobType"} + + +class SQLServerVarBinaryMaxType(BlobType): """SQL Server VARBINARY(MAX) type.""" - pass + + name = "sqlserver_varbinary_max" + + def __init__(self, dialect=None): + super().__init__(dialect) + + @classmethod + def synonyms(cls) -> Set[str]: + return {"BlobType", "TextType"} class SQLServerXmlType(VarCharType): """SQL Server XML type.""" - pass + + name = "sqlserver_xml" + + def __init__(self, dialect=None): + super().__init__(None, dialect) + + @classmethod + def synonyms(cls) -> Set[str]: + return {"VarCharType", "TextType"} class SQLServerTinyIntType(IntegerType): """SQL Server TINYINT type.""" - pass + name = "sqlserver_tinyint" -class SQLServerBitType(IntegerType): + def __init__(self, dialect=None): + super().__init__(dialect) + + @classmethod + def synonyms(cls) -> Set[str]: + return {"TinyIntType"} + + +class SQLServerBitType(BooleanType): """SQL Server BIT type.""" - pass + name = "sqlserver_bit" + + def __init__(self, dialect=None): + super().__init__(dialect) + + @classmethod + def synonyms(cls) -> Set[str]: + return {"BooleanType"} -class SQLServerImageType(VarCharType): + +class SQLServerImageType(BlobType): """SQL Server IMAGE type (deprecated).""" - pass + + name = "sqlserver_image" + + def __init__(self, dialect=None): + super().__init__(dialect) + + @classmethod + def synonyms(cls) -> Set[str]: + return {"BlobType"} __all__ = [ @@ -59,4 +172,4 @@ class SQLServerImageType(VarCharType): "SQLServerTinyIntType", "SQLServerBitType", "SQLServerImageType", -] \ No newline at end of file +] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/routine.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/routine.py index 25a4913..f30669b 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/routine.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/routine.py @@ -45,7 +45,7 @@ def supports_drop_function(self) -> bool: """DROP FUNCTION is supported since SQL Server 2005.""" return self.version >= _SQL_SERVER_ROUTINE_VERSION # type: ignore[attr-defined] - def _format_sqlserver_object_name(self, name: str, schema: Optional[str] = None) -> str: + def _format_object_name(self, name: str, schema: Optional[str] = None) -> str: """Quote a (possibly schema-qualified) object name with brackets.""" parts = [] if schema: @@ -60,7 +60,7 @@ def _format_sqlserver_object_name(self, name: str, schema: Optional[str] = None) return ".".join(parts) @staticmethod - def _format_sqlserver_params(parameters: List[Any]) -> str: + def _format_params(parameters: List[Any]) -> str: """Normalize procedure/function parameters into a SQL fragment. Accepts either raw parameter strings (``"@x INT"``) or dictionaries @@ -116,11 +116,11 @@ def format_create_procedure_statement( if getattr(expr, "or_alter", False): parts.append("OR ALTER") parts.append("PROCEDURE") - parts.append(self._format_sqlserver_object_name(name, getattr(expr, "schema", None))) + parts.append(self._format_object_name(name, getattr(expr, "schema", None))) parameters = getattr(expr, "parameters", None) or [] if parameters: - parts.append(self._format_sqlserver_params(list(parameters))) + parts.append(self._format_params(list(parameters))) if not getattr(expr, "body", None): raise ValueError("routine body is required") @@ -155,10 +155,10 @@ def format_create_function_statement( if getattr(expr, "or_alter", False): parts.append("OR ALTER") parts.append("FUNCTION") - parts.append(self._format_sqlserver_object_name(name, getattr(expr, "schema", None))) + parts.append(self._format_object_name(name, getattr(expr, "schema", None))) parameters = getattr(expr, "parameters", None) or [] - parts.append(f"({self._format_sqlserver_params(list(parameters))})") + parts.append(f"({self._format_params(list(parameters))})") parts.append(f"RETURNS {expr.returns}") if not getattr(expr, "body", None): raise ValueError("function body is required") @@ -194,5 +194,5 @@ def format_drop_routine_statement( parts = ["DROP", kind] if getattr(expr, "if_exists", False) and self.version >= _SQL_SERVER_DROP_IF_EXISTS_VERSION: # type: ignore[attr-defined] parts.append("IF EXISTS") - parts.append(self._format_sqlserver_object_name(name, getattr(expr, "schema", None))) + parts.append(self._format_object_name(name, getattr(expr, "schema", None))) return " ".join(parts), () diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/types.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/types.py index 8e87dcd..1adefcd 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/types.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/types.py @@ -6,7 +6,7 @@ import re from typing import Tuple -from rhosocial.activerecord.backend.dialect.mixins import DDLTypeMixin +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 ( BigIntType, @@ -32,32 +32,75 @@ class SQLServerTypeSupportMixin(DDLTypeMixin, DDLTypeSupport): + """SQL Server DataType formatting and parsing. + + Implements ``DDLTypeSupport`` so the dialect can render ``DataType`` + expressions to SQL strings and parse raw SQL type strings back into + ``DataType`` instances. + + Formatting dispatches by the type instance's ``name`` through the + naming-convention ``format_data_type_`` methods (see + ``DDLTypeMixin``). SQL Server-specific types carry ``sqlserver_``-prefixed + names; core types render their real SQL Server SQL. + """ + + # --- SQL Server-specific type formatters (dispatch key = type name) --- + + def format_data_type_sqlserver_nvarchar(self, data_type: "SQLServerNVarCharType") -> Tuple[str, tuple]: + 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]: + 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]: + return "NVARCHAR(MAX)", () + + def format_data_type_sqlserver_varbinary(self, data_type: "SQLServerVarBinaryType") -> Tuple[str, tuple]: + 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]: + return "VARBINARY(MAX)", () + + def format_data_type_sqlserver_xml(self, data_type: "SQLServerXmlType") -> Tuple[str, tuple]: + return "XML", () + + def format_data_type_sqlserver_tinyint(self, data_type: "SQLServerTinyIntType") -> Tuple[str, tuple]: + return "TINYINT", () + + def format_data_type_sqlserver_bit(self, data_type: "SQLServerBitType") -> Tuple[str, tuple]: + return "BIT", () + + def format_data_type_sqlserver_image(self, data_type: "SQLServerImageType") -> Tuple[str, tuple]: + return "IMAGE", () + + # --- Core types (pure names) rendered to real SQL Server SQL --- - @DDLTypeMixin.handles(IntegerType) def format_data_type_integer(self, data_type: IntegerType) -> Tuple[str, tuple]: return "INT", () - @DDLTypeMixin.handles(BigIntType) + def format_data_type_int(self, data_type: IntegerType) -> Tuple[str, tuple]: + return "INT", () + def format_data_type_bigint(self, data_type: BigIntType) -> Tuple[str, tuple]: return "BIGINT", () - @DDLTypeMixin.handles(SmallIntType) def format_data_type_smallint(self, data_type: SmallIntType) -> Tuple[str, tuple]: return "SMALLINT", () - @DDLTypeMixin.handles(FloatType) + def format_data_type_tinyint(self, data_type: DataType) -> Tuple[str, tuple]: + return "TINYINT", () + def format_data_type_float(self, data_type: FloatType) -> Tuple[str, tuple]: return (f"FLOAT({data_type.precision})" if data_type.precision is not None else "FLOAT"), () - @DDLTypeMixin.handles(RealType) def format_data_type_real(self, data_type: RealType) -> Tuple[str, tuple]: return "REAL", () - @DDLTypeMixin.handles(DoubleType) def format_data_type_double(self, data_type: DoubleType) -> Tuple[str, tuple]: return "FLOAT(53)", () - @DDLTypeMixin.handles(DecimalType) def format_data_type_decimal(self, data_type: DecimalType) -> Tuple[str, tuple]: if data_type.precision is not None and data_type.scale is not None: return f"DECIMAL({data_type.precision}, {data_type.scale})", () @@ -65,102 +108,133 @@ def format_data_type_decimal(self, data_type: DecimalType) -> Tuple[str, tuple]: return f"DECIMAL({data_type.precision})", () return "DECIMAL", () - @DDLTypeMixin.handles(BooleanType) def format_data_type_boolean(self, data_type: BooleanType) -> Tuple[str, tuple]: return "BIT", () - @DDLTypeMixin.handles(VarCharType) 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)"), () - @DDLTypeMixin.handles(CharType) 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)"), () - @DDLTypeMixin.handles(TextType) def format_data_type_text(self, data_type: TextType) -> Tuple[str, tuple]: return "NVARCHAR(MAX)", () - @DDLTypeMixin.handles(DateTimeType) def format_data_type_datetime(self, data_type: DateTimeType) -> Tuple[str, tuple]: return "DATETIME2", () - @DDLTypeMixin.handles(DateType) def format_data_type_date(self, data_type: DateType) -> Tuple[str, tuple]: return "DATE", () - @DDLTypeMixin.handles(TimeType) def format_data_type_time(self, data_type: TimeType) -> Tuple[str, tuple]: return "TIME", () - @DDLTypeMixin.handles(TimestampType) def format_data_type_timestamp(self, data_type: TimestampType) -> Tuple[str, tuple]: return "DATETIME2", () - @DDLTypeMixin.handles(JsonType) def format_data_type_json(self, data_type: JsonType) -> Tuple[str, tuple]: return "NVARCHAR(MAX)", () - @DDLTypeMixin.handles(BlobType) def format_data_type_blob(self, data_type: BlobType) -> Tuple[str, tuple]: return "VARBINARY(MAX)", () - # --- Parsing --- + def format_data_type_custom(self, data_type: CustomType) -> Tuple[str, tuple]: + return data_type.raw, () + + # ------------------------------------------------------------------ + # DDLTypeSupport — parsing + # ------------------------------------------------------------------ + + _SQLSERVER_INTEGER_TYPES = re.compile( + r"^(?:INT|INTEGER|BIGINT|SMALLINT|TINYINT)\b", + re.IGNORECASE, + ) + _SQLSERVER_FLOAT_TYPES = re.compile( + r"^(?:FLOAT|REAL)\b", + re.IGNORECASE, + ) + _SQLSERVER_DECIMAL_TYPES = re.compile( + r"^(?:DECIMAL|NUMERIC|MONEY|SMALLMONEY)\b", + re.IGNORECASE, + ) + _SQLSERVER_STRING_TYPES = re.compile( + r"^(?:CHAR|VARCHAR|NVARCHAR|NCHAR|TEXT|NTEXT)\b", + re.IGNORECASE, + ) + _SQLSERVER_DATE_TYPES = re.compile( + r"^(?:DATE|DATETIME|DATETIME2|SMALLDATETIME|TIMESTAMP)\b", + re.IGNORECASE, + ) + _SQLSERVER_TIME_TYPES = re.compile( + r"^(?:TIME)\b", + re.IGNORECASE, + ) + _SQLSERVER_BIT_TYPES = re.compile( + r"^(?:BIT)\b", + re.IGNORECASE, + ) + _SQLSERVER_BLOB_TYPES = re.compile( + r"^(?:VARBINARY|IMAGE)\b", + re.IGNORECASE, + ) def parse_type(self, raw: str) -> DataType: stripped = raw.strip() upper = stripped.upper() - int_pattern = re.compile(r"^(?:INT|INTEGER|BIGINT|SMALLINT|TINYINT)\b", re.IGNORECASE) - float_pattern = re.compile(r"^(?:FLOAT|REAL)\b", re.IGNORECASE) - decimal_pattern = re.compile(r"^(?:DECIMAL|NUMERIC|MONEY|SMALLMONEY)\b", re.IGNORECASE) - string_pattern = re.compile(r"^(?:CHAR|VARCHAR|NVARCHAR|NCHAR|TEXT|NTEXT)\b", re.IGNORECASE) - date_pattern = re.compile(r"^(?:DATE|DATETIME|DATETIME2|SMALLDATETIME|TIMESTAMP)\b", re.IGNORECASE) - time_pattern = re.compile(r"^(?:TIME)\b", re.IGNORECASE) - bit_pattern = re.compile(r"^(?:BIT)\b", re.IGNORECASE) - blob_pattern = re.compile(r"^(?:VARBINARY|IMAGE)\b", re.IGNORECASE) + # BIT type + if self._SQLSERVER_BIT_TYPES.match(upper): + from ..expression.types import SQLServerBitType + return SQLServerBitType(dialect=self) - if int_pattern.match(upper): + # Integer family + if self._SQLSERVER_INTEGER_TYPES.match(upper): if upper.startswith("BIGINT"): - return BigIntType() + return BigIntType(dialect=self) if upper.startswith("SMALLINT"): - return SmallIntType() - return IntegerType() - - if float_pattern.match(upper): + return SmallIntType(dialect=self) + if upper.startswith("TINYINT"): + from ..expression.types import SQLServerTinyIntType + return SQLServerTinyIntType(dialect=self) + return IntegerType(dialect=self) + + # Float family + if self._SQLSERVER_FLOAT_TYPES.match(upper): if upper.startswith("REAL"): - return RealType() - return FloatType() + return RealType(dialect=self) + nums = re.findall(r"\d+", stripped) + precision = int(nums[0]) if nums else None + return FloatType(precision, self) - if decimal_pattern.match(upper): + # Decimal family + if self._SQLSERVER_DECIMAL_TYPES.match(upper): nums = re.findall(r"\d+", stripped) if len(nums) >= 2: - return DecimalType(int(nums[0]), int(nums[1])) + return DecimalType(int(nums[0]), int(nums[1]), self) if len(nums) == 1: - return DecimalType(int(nums[0])) - return DecimalType() + return DecimalType(int(nums[0]), self) + return DecimalType(dialect=self) - if string_pattern.match(upper): + # String family + if self._SQLSERVER_STRING_TYPES.match(upper): if "MAX" in upper or "TEXT" in upper or "NTEXT" in upper: - return TextType() + 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(length or 255) - return (CharType(length) if length_match else VarCharType(255)) - - if date_pattern.match(upper): - return DateTimeType() - - if time_pattern.match(upper): - return TimeType() + return VarCharType(length or 255, self) + return (CharType(length, self) if length_match else VarCharType(255, self)) - if bit_pattern.match(upper): - return BooleanType() + # Date/time family + if self._SQLSERVER_DATE_TYPES.match(upper): + return DateTimeType(dialect=self) - if blob_pattern.match(upper): - from rhosocial.activerecord.backend.expression.types import BlobType + if self._SQLSERVER_TIME_TYPES.match(upper): + return TimeType(dialect=self) - return BlobType() + # Blob family + if self._SQLSERVER_BLOB_TYPES.match(upper): + from ..expression.types import SQLServerVarBinaryType + return SQLServerVarBinaryType(dialect=self) - return CustomType(stripped) \ No newline at end of file + return CustomType(stripped, self) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_alter_table_if_exists_backend.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_alter_table_if_exists_backend.py index 8a6b47a..3ee4a76 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_alter_table_if_exists_backend.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_alter_table_if_exists_backend.py @@ -33,7 +33,7 @@ def test_supports_switches(self, dialect): class TestSQLServerAddColumn: def test_add_column_omits_column_literal(self, dialect): - action = AddColumn(dialect, ColumnDefinition("content", TextType())) + action = AddColumn(dialect, ColumnDefinition(dialect, "content", TextType())) sql, params = action.to_sql() assert sql.startswith("ADD [content]") assert "ADD COLUMN" not in sql @@ -42,7 +42,7 @@ def test_add_column_omits_column_literal(self, dialect): def test_add_column_if_not_exists_raises(self, dialect): action = AddColumn( dialect, - ColumnDefinition("content", TextType()), + ColumnDefinition(dialect, "content", TextType()), if_not_exists=True, ) with pytest.raises(UnsupportedFeatureError): From 8e4f10dc2804fc76cd498098356f0e6b6d660fca Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 11 Sep 2026 11:02:30 +0800 Subject: [PATCH 02/58] refactor: enforce (self, expr) signature on format_* methods - Remove extra filter_predicate param from format_function_call - Update format_json_table_expression to accept single expr param --- .../backend/impl/sqlserver/dialect.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index 817d9b9..6092f85 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -1205,14 +1205,7 @@ def format_array_expression(self, _expr: "ArrayExpression") -> Tuple[str, Tuple] """Format array expression - not supported.""" raise UnsupportedFeatureError(self.name, "Array operations", _SUGGESTION_ARRAY_TYPES) - def format_json_table_expression( - self, - _json_col_sql: str, - _path: str, - _columns: List[Dict[str, Any]], - _alias: Optional[str], - _params: tuple - ) -> Tuple[str, Tuple]: + def format_json_table_expression(self, _expr) -> Tuple[str, Tuple]: """Format JSON_TABLE - SQL Server uses OPENJSON.""" raise UnsupportedFeatureError(self.name, "JSON_TABLE", _SUGGESTION_JSON_TABLE) @@ -1803,9 +1796,7 @@ def format_alter_table_statement(self, expr: "AlterTableExpression") -> Tuple[st return " ".join(parts), tuple(all_params) - def format_function_call( - self, expr, filter_predicate=None - ) -> Tuple[str, tuple]: + def format_function_call(self, expr) -> Tuple[str, tuple]: """Format a function call, mapping MySQL/generic function names to their SQL Server equivalents. @@ -1824,8 +1815,8 @@ def format_function_call( return self.apply_alias(sql, (), expr) if name and name.upper() in self._FUNCTION_NAME_EQUIVALENTS: renamed_expr = self._clone_with_func_name(expr, self._FUNCTION_NAME_EQUIVALENTS[name.upper()]) - return super().format_function_call(renamed_expr, filter_predicate) - return super().format_function_call(expr, filter_predicate) + return super().format_function_call(renamed_expr) + return super().format_function_call(expr) def _clone_with_func_name(self, expr, new_name: str): """Return a shallow copy of ``expr`` with ``func_name`` replaced. From 6793dbe154415902e05d61b1eac5c58699b9eb91 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 11 Sep 2026 13:28:51 +0800 Subject: [PATCH 03/58] =?UTF-8?q?fix:=20Rename=20JoinExpression=E2=86=92Jo?= =?UTF-8?q?inClause,=20fix=20format=5Fgrouping=5Fexpression=20method?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updated import in dialect.py from JoinExpression to JoinClause - Renamed format_grouping_expression to format_grouping_clause with correct signature (self, expr) instead of (self, operation, _expressions) - Updated example code import in examples/query/join.py - Updated test_expressions.py imports and class name - Updated test_dialect_version_branches.py to use new method name and signature --- .../activerecord/backend/impl/sqlserver/dialect.py | 12 ++++++++---- .../backend/impl/sqlserver/examples/query/join.py | 4 ++-- .../feature/backend/test_dialect_version_branches.py | 9 +++++---- .../feature/backend/test_expressions.py | 10 +++++----- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index 6092f85..4324f69 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -121,7 +121,7 @@ LimitOffsetClause, ForUpdateClause, QualifyClause, - JoinExpression, + JoinClause, ) from rhosocial.activerecord.backend.expression.statements import ( ExplainExpression, @@ -1221,10 +1221,14 @@ def format_qualify_clause(self, _clause: "QualifyClause") -> Tuple[str, tuple]: """Format QUALIFY clause - not supported.""" raise UnsupportedFeatureError(self.name, "QUALIFY clause", _SUGGESTION_QUALIFY) - def format_grouping_expression( - self, operation: str, _expressions: List["bases.BaseExpression"] + def format_grouping_clause( + self, expr: "bases.BaseExpression" ) -> Tuple[str, tuple]: - """Format grouping expression (ROLLUP, CUBE, GROUPING SETS).""" + """Format grouping clause (ROLLUP, CUBE, GROUPING SETS).""" + from rhosocial.activerecord.backend.expression.query_parts import GroupingClause + if not isinstance(expr, GroupingClause): + raise TypeError(f"Expected GroupingClause, got {type(expr)}") + operation = expr.operation if operation.upper() == "ROLLUP": return "WITH ROLLUP", () elif operation.upper() == "CUBE": diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/query/join.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/query/join.py index 2f5078e..fe3bf69 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/query/join.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/query/join.py @@ -120,11 +120,11 @@ QueryExpression, TableExpression, Column, - JoinExpression, + JoinClause, ) from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate -join_expr = JoinExpression( +join_expr = JoinClause( dialect=dialect, left_table=TableExpression(dialect, 'join_users', alias='u'), right_table=TableExpression(dialect, 'join_orders', alias='o'), 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 e5b0edf..1d1096a 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 @@ -19,6 +19,7 @@ SQL_SERVER_2022, SQLServerDialect, ) +from rhosocial.activerecord.backend.expression.query_parts import GroupingClause VERSIONS = { "2008": (10, 0, 0), @@ -379,11 +380,11 @@ def test_lateral_renders_as_apply(self): class TestGroupingAndUpsertBasics: def test_grouping_operations_use_tsql_syntax(self): d = SQLServerDialect((16, 0, 0)) - assert d.format_grouping_expression("ROLLUP", []) == ("WITH ROLLUP", ()) - assert d.format_grouping_expression("CUBE", []) == ("WITH CUBE", ()) - assert d.format_grouping_expression("GROUPING SETS", []) == ("GROUPING SETS", ()) + assert d.format_grouping_clause(GroupingClause(d, "ROLLUP", [])) == ("WITH ROLLUP", ()) + assert d.format_grouping_clause(GroupingClause(d, "CUBE", [])) == ("WITH CUBE", ()) + assert d.format_grouping_clause(GroupingClause(d, "GROUPING SETS", [])) == ("GROUPING SETS", ()) with pytest.raises(UnsupportedFeatureError): - d.format_grouping_expression("PIVOT", []) + d.format_grouping_clause(GroupingClause(d, "PIVOT", [])) def test_upsert_targets_merge_syntax(self): for version in VERSIONS.values(): diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_expressions.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_expressions.py index b865b32..663835d 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_expressions.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_expressions.py @@ -176,7 +176,7 @@ def test_composite_index(self, dialect): assert "[created_at]" in sql -class TestJoinExpression: +class TestJoinClause: """Test cases for JOIN expression formatting.""" @pytest.fixture @@ -189,11 +189,11 @@ def test_inner_join(self, dialect): QueryExpression, TableExpression, Column, - JoinExpression, + JoinClause, ) from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate - join = JoinExpression( + join = JoinClause( dialect=dialect, left_table=TableExpression(dialect, "users", alias="u"), right_table=TableExpression(dialect, "orders", alias="o"), @@ -223,11 +223,11 @@ def test_left_join(self, dialect): QueryExpression, TableExpression, Column, - JoinExpression, + JoinClause, ) from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate - join = JoinExpression( + join = JoinClause( dialect=dialect, left_table=TableExpression(dialect, "users", alias="u"), right_table=TableExpression(dialect, "profiles", alias="p"), From 72cce3e505a3629a870e95482e335c9918179dda Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 11 Sep 2026 16:56:29 +0800 Subject: [PATCH 04/58] ci: upgrade to ODBC Driver 18 for SQL Server 2025 jobs Driver 17 is one generation too old for SQL Server 2025's protocol/TLS defaults; parallel test workers failed connections at suite start. --- .github/workflows/test.yml | 72 +++++++++++++++++++------------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 87e009a..feef634 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -45,12 +45,12 @@ jobs: with: python-version: "3.9" - - name: Install ODBC Driver 17 for SQL Server + - name: Install ODBC Driver 18 for SQL Server run: | curl https://packages.microsoft.com/keys/microsoft.asc | sudo apt-key add - curl https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/prod.list | sudo tee /etc/apt/sources.list.d/mssql-release.list sudo apt-get update - sudo ACCEPT_EULA=Y apt-get install -y msodbcsql17 unixodbc-dev + sudo ACCEPT_EULA=Y apt-get install -y msodbcsql18 unixodbc-dev - name: Install dependencies run: | @@ -68,7 +68,7 @@ jobs: import pyodbc connection_string = ( - 'DRIVER={ODBC Driver 17 for SQL Server};' + 'DRIVER={ODBC Driver 18 for SQL Server};' 'SERVER=localhost,1434;' 'DATABASE=master;' 'UID=sa;' @@ -95,7 +95,7 @@ jobs: database: test_db username: sa password: Password123! - driver: "ODBC Driver 17 for SQL Server" + driver: "ODBC Driver 18 for SQL Server" encrypt: false trust_server_certificate: true autocommit: true @@ -134,12 +134,12 @@ jobs: with: python-version: "3.9" - - name: Install ODBC Driver 17 for SQL Server + - name: Install ODBC Driver 18 for SQL Server run: | curl https://packages.microsoft.com/keys/microsoft.asc | sudo apt-key add - curl https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/prod.list | sudo tee /etc/apt/sources.list.d/mssql-release.list sudo apt-get update - sudo ACCEPT_EULA=Y apt-get install -y msodbcsql17 unixodbc-dev + sudo ACCEPT_EULA=Y apt-get install -y msodbcsql18 unixodbc-dev - name: Install dependencies run: | @@ -157,7 +157,7 @@ jobs: import pyodbc connection_string = ( - 'DRIVER={ODBC Driver 17 for SQL Server};' + 'DRIVER={ODBC Driver 18 for SQL Server};' 'SERVER=localhost,1442;' 'DATABASE=master;' 'UID=sa;' @@ -184,7 +184,7 @@ jobs: database: test_db username: sa password: Password123! - driver: "ODBC Driver 17 for SQL Server" + driver: "ODBC Driver 18 for SQL Server" encrypt: false trust_server_certificate: true autocommit: true @@ -223,12 +223,12 @@ jobs: with: python-version: "3.10" - - name: Install ODBC Driver 17 for SQL Server + - name: Install ODBC Driver 18 for SQL Server run: | curl https://packages.microsoft.com/keys/microsoft.asc | sudo apt-key add - curl https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/prod.list | sudo tee /etc/apt/sources.list.d/mssql-release.list sudo apt-get update - sudo ACCEPT_EULA=Y apt-get install -y msodbcsql17 unixodbc-dev + sudo ACCEPT_EULA=Y apt-get install -y msodbcsql18 unixodbc-dev - name: Install dependencies run: | @@ -246,7 +246,7 @@ jobs: import pyodbc connection_string = ( - 'DRIVER={ODBC Driver 17 for SQL Server};' + 'DRIVER={ODBC Driver 18 for SQL Server};' 'SERVER=localhost,1435;' 'DATABASE=master;' 'UID=sa;' @@ -273,7 +273,7 @@ jobs: database: test_db username: sa password: Password123! - driver: "ODBC Driver 17 for SQL Server" + driver: "ODBC Driver 18 for SQL Server" encrypt: false trust_server_certificate: true autocommit: true @@ -312,12 +312,12 @@ jobs: with: python-version: "3.11" - - name: Install ODBC Driver 17 for SQL Server + - name: Install ODBC Driver 18 for SQL Server run: | curl https://packages.microsoft.com/keys/microsoft.asc | sudo apt-key add - curl https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/prod.list | sudo tee /etc/apt/sources.list.d/mssql-release.list sudo apt-get update - sudo ACCEPT_EULA=Y apt-get install -y msodbcsql17 unixodbc-dev + sudo ACCEPT_EULA=Y apt-get install -y msodbcsql18 unixodbc-dev - name: Install dependencies run: | @@ -335,7 +335,7 @@ jobs: import pyodbc connection_string = ( - 'DRIVER={ODBC Driver 17 for SQL Server};' + 'DRIVER={ODBC Driver 18 for SQL Server};' 'SERVER=localhost,1436;' 'DATABASE=master;' 'UID=sa;' @@ -362,7 +362,7 @@ jobs: database: test_db username: sa password: Password123! - driver: "ODBC Driver 17 for SQL Server" + driver: "ODBC Driver 18 for SQL Server" encrypt: false trust_server_certificate: true autocommit: true @@ -401,12 +401,12 @@ jobs: with: python-version: "3.12" - - name: Install ODBC Driver 17 for SQL Server + - name: Install ODBC Driver 18 for SQL Server run: | curl https://packages.microsoft.com/keys/microsoft.asc | sudo apt-key add - curl https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/prod.list | sudo tee /etc/apt/sources.list.d/mssql-release.list sudo apt-get update - sudo ACCEPT_EULA=Y apt-get install -y msodbcsql17 unixodbc-dev + sudo ACCEPT_EULA=Y apt-get install -y msodbcsql18 unixodbc-dev - name: Install dependencies run: | @@ -424,7 +424,7 @@ jobs: import pyodbc connection_string = ( - 'DRIVER={ODBC Driver 17 for SQL Server};' + 'DRIVER={ODBC Driver 18 for SQL Server};' 'SERVER=localhost,1437;' 'DATABASE=master;' 'UID=sa;' @@ -451,7 +451,7 @@ jobs: database: test_db username: sa password: Password123! - driver: "ODBC Driver 17 for SQL Server" + driver: "ODBC Driver 18 for SQL Server" encrypt: false trust_server_certificate: true autocommit: true @@ -490,12 +490,12 @@ jobs: with: python-version: "3.13" - - name: Install ODBC Driver 17 for SQL Server + - name: Install ODBC Driver 18 for SQL Server run: | curl https://packages.microsoft.com/keys/microsoft.asc | sudo apt-key add - curl https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/prod.list | sudo tee /etc/apt/sources.list.d/mssql-release.list sudo apt-get update - sudo ACCEPT_EULA=Y apt-get install -y msodbcsql17 unixodbc-dev + sudo ACCEPT_EULA=Y apt-get install -y msodbcsql18 unixodbc-dev - name: Install dependencies run: | @@ -513,7 +513,7 @@ jobs: import pyodbc connection_string = ( - 'DRIVER={ODBC Driver 17 for SQL Server};' + 'DRIVER={ODBC Driver 18 for SQL Server};' 'SERVER=localhost,1438;' 'DATABASE=master;' 'UID=sa;' @@ -540,7 +540,7 @@ jobs: database: test_db username: sa password: Password123! - driver: "ODBC Driver 17 for SQL Server" + driver: "ODBC Driver 18 for SQL Server" encrypt: false trust_server_certificate: true autocommit: true @@ -580,12 +580,12 @@ jobs: with: python-version: "3.13t" - - name: Install ODBC Driver 17 for SQL Server + - name: Install ODBC Driver 18 for SQL Server run: | curl https://packages.microsoft.com/keys/microsoft.asc | sudo apt-key add - curl https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/prod.list | sudo tee /etc/apt/sources.list.d/mssql-release.list sudo apt-get update - sudo ACCEPT_EULA=Y apt-get install -y msodbcsql17 unixodbc-dev + sudo ACCEPT_EULA=Y apt-get install -y msodbcsql18 unixodbc-dev - name: Install dependencies run: | @@ -603,7 +603,7 @@ jobs: import pyodbc connection_string = ( - 'DRIVER={ODBC Driver 17 for SQL Server};' + 'DRIVER={ODBC Driver 18 for SQL Server};' 'SERVER=localhost,1439;' 'DATABASE=master;' 'UID=sa;' @@ -630,7 +630,7 @@ jobs: database: test_db username: sa password: Password123! - driver: "ODBC Driver 17 for SQL Server" + driver: "ODBC Driver 18 for SQL Server" encrypt: false trust_server_certificate: true autocommit: true @@ -669,12 +669,12 @@ jobs: with: python-version: "3.14" - - name: Install ODBC Driver 17 for SQL Server + - name: Install ODBC Driver 18 for SQL Server run: | curl https://packages.microsoft.com/keys/microsoft.asc | sudo apt-key add - curl https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/prod.list | sudo tee /etc/apt/sources.list.d/mssql-release.list sudo apt-get update - sudo ACCEPT_EULA=Y apt-get install -y msodbcsql17 unixodbc-dev + sudo ACCEPT_EULA=Y apt-get install -y msodbcsql18 unixodbc-dev - name: Install dependencies run: | @@ -692,7 +692,7 @@ jobs: import pyodbc connection_string = ( - 'DRIVER={ODBC Driver 17 for SQL Server};' + 'DRIVER={ODBC Driver 18 for SQL Server};' 'SERVER=localhost,1440;' 'DATABASE=master;' 'UID=sa;' @@ -719,7 +719,7 @@ jobs: database: test_db username: sa password: Password123! - driver: "ODBC Driver 17 for SQL Server" + driver: "ODBC Driver 18 for SQL Server" encrypt: false trust_server_certificate: true autocommit: true @@ -767,12 +767,12 @@ jobs: with: python-version: "3.14t" - - name: Install ODBC Driver 17 for SQL Server + - name: Install ODBC Driver 18 for SQL Server run: | curl https://packages.microsoft.com/keys/microsoft.asc | sudo apt-key add - curl https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/prod.list | sudo tee /etc/apt/sources.list.d/mssql-release.list sudo apt-get update - sudo ACCEPT_EULA=Y apt-get install -y msodbcsql17 unixodbc-dev + sudo ACCEPT_EULA=Y apt-get install -y msodbcsql18 unixodbc-dev - name: Install dependencies run: | @@ -790,7 +790,7 @@ jobs: import pyodbc connection_string = ( - 'DRIVER={ODBC Driver 17 for SQL Server};' + 'DRIVER={ODBC Driver 18 for SQL Server};' 'SERVER=localhost,1441;' 'DATABASE=master;' 'UID=sa;' @@ -817,7 +817,7 @@ jobs: database: test_db username: sa password: Password123! - driver: "ODBC Driver 17 for SQL Server" + driver: "ODBC Driver 18 for SQL Server" encrypt: false trust_server_certificate: true autocommit: true From b566f96b58c15d770862cafdbb5af1cda3104b3a Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 12 Sep 2026 10:08:01 +0800 Subject: [PATCH 05/58] feat(dialect-protocol): formalize SQL Server type support and suggestion contracts - 9 type classes declare dialect_options explicitly; unified _type_params based equality/hash - 29 supports_data_type_* added (1:1 with format family) - suggested_data_types(): uuid/interval/array/enum/jsonb/timestamptz/timetz -> honest cross-backend suggestions; keys disjoint from supported - Precision validation: decimal 1-38/0-precision, float 1-53, temporal 0-7 - _register_type_formatters() copies supports methods + regex attrs - New test_sqlserver_type_protocol.py (30 tests) --- .../backend/impl/sqlserver/dialect.py | 50 +++- .../examples/schema_diff/add_column.py | 4 +- .../impl/sqlserver/expression/types.py | 66 ++--- .../backend/impl/sqlserver/mixins/types.py | 145 +++++++++- .../backend/test_sqlserver_type_protocol.py | 263 ++++++++++++++++++ 5 files changed, 473 insertions(+), 55 deletions(-) create mode 100644 tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_type_protocol.py diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index 4324f69..471c297 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -1885,21 +1885,63 @@ def format_query_option_clause(self, clause) -> Tuple[str, tuple]: """ return f"OPTION ({', '.join(clause.hints)})", () + # ------------------------------------------------------------------ + # DataType protocol — suggestions + # ------------------------------------------------------------------ + + 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()``. + """ + from rhosocial.activerecord.backend.expression.types import ( + UUIDType, + IntervalType, + ArrayType, + EnumType, + JsonBType, + TimestampTzType, + TimeTzType, + ) + return { + "uuid": UUIDType, + "interval": IntervalType, + "array": ArrayType, + "enum": EnumType, + "jsonb": JsonBType, + "timestamptz": TimestampTzType, + "timetz": TimeTzType, + } + # Lazy import to avoid circular dependency (backend → dialect → mixins.types → backend) def _register_type_formatters(): - """Copy ``format_data_type_*`` / ``parse_type`` methods from the mixin - to the dialect class so that the naming-convention dispatch in - ``DDLTypeMixin.format_data_type()`` finds them. + """Copy ``format_data_type_*`` / ``supports_data_type_*`` / ``parse_type`` + methods from the mixin to the dialect class so that the naming-convention + dispatch in ``DDLTypeMixin.format_data_type()`` and + ``DDLTypeMixin.supports_data_types()`` find them. + + Also copies the ``_SQLSERVER_*`` regex class attributes needed by + ``parse_type`` at runtime. """ from .mixins.types import SQLServerTypeSupportMixin for member_name in dir(SQLServerTypeSupportMixin): - if member_name.startswith("format_data_type_") or member_name == "parse_type": + if (member_name.startswith("format_data_type_") + or member_name.startswith("supports_data_type_") + or member_name == "parse_type"): member = getattr(SQLServerTypeSupportMixin, member_name, None) if callable(member): setattr(SQLServerDialect, member_name, member) + for attr_name in dir(SQLServerTypeSupportMixin): + if attr_name.startswith("_SQLSERVER_"): + val = getattr(SQLServerTypeSupportMixin, attr_name, None) + if val is not None and not callable(val): + setattr(SQLServerDialect, attr_name, val) + def _register_partition_formatters(): from .mixins.partition import SQLServerPartitionMixin diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/schema_diff/add_column.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/schema_diff/add_column.py index 483c0dc..bb96736 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/schema_diff/add_column.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/schema_diff/add_column.py @@ -43,7 +43,7 @@ constraints=[ ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True), ]), - ColumnDefinition("name", VarCharType(100)), + ColumnDefinition("name", VarCharType(length=100)), ] ) sql, params = expr.to_sql() @@ -63,7 +63,7 @@ alter = AlterTableExpression( dialect=dialect, table_name="users", actions=[ - AddColumn(dialect, column=ColumnDefinition("email", VarCharType(255))), + AddColumn(dialect, column=ColumnDefinition("email", VarCharType(length=255))), ], ) sql, params = alter.to_sql() diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/types.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/types.py index 458f7ca..d47d167 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/types.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/types.py @@ -7,7 +7,7 @@ ``format_data_type_`` methods. """ -from typing import Optional, Set +from typing import Any, Dict, Optional, Set from rhosocial.activerecord.backend.expression.types import ( VarCharType, @@ -23,16 +23,9 @@ class SQLServerNVarCharType(VarCharType): name = "sqlserver_nvarchar" - def __init__(self, length: Optional[int] = None, dialect=None): - super().__init__(length, dialect) - - def __eq__(self, other: object) -> bool: - if type(self) is not type(other): - return False - return self.length == other.length - - def __hash__(self) -> int: - return hash((type(self), self.length)) + def __init__(self, dialect=None, length: Optional[int] = None, + dialect_options: Optional[Dict[str, Any]] = None): + super().__init__(dialect, length=length, dialect_options=dialect_options) @classmethod def synonyms(cls) -> Set[str]: @@ -44,16 +37,9 @@ class SQLServerNCharType(CharType): name = "sqlserver_nchar" - def __init__(self, length: Optional[int] = None, dialect=None): - super().__init__(length, dialect) - - def __eq__(self, other: object) -> bool: - if type(self) is not type(other): - return False - return self.length == other.length - - def __hash__(self) -> int: - return hash((type(self), self.length)) + def __init__(self, dialect=None, length: Optional[int] = None, + dialect_options: Optional[Dict[str, Any]] = None): + super().__init__(dialect, length=length, dialect_options=dialect_options) @classmethod def synonyms(cls) -> Set[str]: @@ -65,8 +51,8 @@ class SQLServerNVarCharMaxType(VarCharType): name = "sqlserver_nvarchar_max" - def __init__(self, dialect=None): - super().__init__(None, dialect) + def __init__(self, dialect=None, dialect_options: Optional[Dict[str, Any]] = None): + super().__init__(dialect, length=None, dialect_options=dialect_options) @classmethod def synonyms(cls) -> Set[str]: @@ -80,17 +66,13 @@ class SQLServerVarBinaryType(BlobType): length: Optional[int] = None - def __init__(self, length: Optional[int] = None, dialect=None): - super().__init__(dialect) + def __init__(self, dialect=None, length: Optional[int] = None, + dialect_options: Optional[Dict[str, Any]] = None): + super().__init__(dialect, dialect_options=dialect_options) self.length = length - def __eq__(self, other: object) -> bool: - if type(self) is not type(other): - return False - return self.length == other.length - - def __hash__(self) -> int: - return hash((type(self), self.length)) + def _type_params(self) -> tuple: + return (self.length,) @classmethod def synonyms(cls) -> Set[str]: @@ -102,8 +84,8 @@ class SQLServerVarBinaryMaxType(BlobType): name = "sqlserver_varbinary_max" - def __init__(self, dialect=None): - super().__init__(dialect) + def __init__(self, dialect=None, dialect_options: Optional[Dict[str, Any]] = None): + super().__init__(dialect, dialect_options=dialect_options) @classmethod def synonyms(cls) -> Set[str]: @@ -115,8 +97,8 @@ class SQLServerXmlType(VarCharType): name = "sqlserver_xml" - def __init__(self, dialect=None): - super().__init__(None, dialect) + def __init__(self, dialect=None, dialect_options: Optional[Dict[str, Any]] = None): + super().__init__(dialect, length=None, dialect_options=dialect_options) @classmethod def synonyms(cls) -> Set[str]: @@ -128,8 +110,8 @@ class SQLServerTinyIntType(IntegerType): name = "sqlserver_tinyint" - def __init__(self, dialect=None): - super().__init__(dialect) + def __init__(self, dialect=None, dialect_options: Optional[Dict[str, Any]] = None): + super().__init__(dialect, dialect_options=dialect_options) @classmethod def synonyms(cls) -> Set[str]: @@ -141,8 +123,8 @@ class SQLServerBitType(BooleanType): name = "sqlserver_bit" - def __init__(self, dialect=None): - super().__init__(dialect) + def __init__(self, dialect=None, dialect_options: Optional[Dict[str, Any]] = None): + super().__init__(dialect, dialect_options=dialect_options) @classmethod def synonyms(cls) -> Set[str]: @@ -154,8 +136,8 @@ class SQLServerImageType(BlobType): name = "sqlserver_image" - def __init__(self, dialect=None): - super().__init__(dialect) + def __init__(self, dialect=None, dialect_options: Optional[Dict[str, Any]] = None): + super().__init__(dialect, dialect_options=dialect_options) @classmethod def synonyms(cls) -> Set[str]: diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/types.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/types.py index 1adefcd..c2d60f5 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/types.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/types.py @@ -93,7 +93,14 @@ def format_data_type_tinyint(self, data_type: DataType) -> Tuple[str, tuple]: return "TINYINT", () def format_data_type_float(self, data_type: FloatType) -> Tuple[str, tuple]: - return (f"FLOAT({data_type.precision})" if data_type.precision is not None else "FLOAT"), () + if data_type.precision is not None: + if not (1 <= data_type.precision <= 53): + raise ValueError( + f"SQL Server FLOAT precision must be 1-53, " + f"got {data_type.precision}" + ) + return f"FLOAT({data_type.precision})", () + return "FLOAT", () def format_data_type_real(self, data_type: RealType) -> Tuple[str, tuple]: return "REAL", () @@ -102,6 +109,18 @@ def format_data_type_double(self, data_type: DoubleType) -> Tuple[str, tuple]: return "FLOAT(53)", () def format_data_type_decimal(self, data_type: DecimalType) -> Tuple[str, tuple]: + if data_type.precision is not None: + if not (1 <= data_type.precision <= 38): + raise ValueError( + f"SQL Server DECIMAL precision must be 1-38, " + f"got {data_type.precision}" + ) + if data_type.scale is not None: + if data_type.precision is not None and data_type.scale > data_type.precision: + raise ValueError( + f"SQL Server DECIMAL scale ({data_type.scale}) " + f"must not exceed precision ({data_type.precision})" + ) if data_type.precision is not None and data_type.scale is not None: return f"DECIMAL({data_type.precision}, {data_type.scale})", () if data_type.precision is not None: @@ -121,15 +140,36 @@ def format_data_type_text(self, data_type: TextType) -> Tuple[str, tuple]: return "NVARCHAR(MAX)", () def format_data_type_datetime(self, data_type: DateTimeType) -> Tuple[str, tuple]: + if data_type.precision is not None: + if not (0 <= data_type.precision <= 7): + raise ValueError( + f"SQL Server DATETIME2 fractional seconds precision " + f"must be 0-7, got {data_type.precision}" + ) + return f"DATETIME2({data_type.precision})", () return "DATETIME2", () def format_data_type_date(self, data_type: DateType) -> Tuple[str, tuple]: return "DATE", () def format_data_type_time(self, data_type: TimeType) -> Tuple[str, tuple]: + if data_type.precision is not None: + if not (0 <= data_type.precision <= 7): + raise ValueError( + f"SQL Server TIME fractional seconds precision " + f"must be 0-7, got {data_type.precision}" + ) + return f"TIME({data_type.precision})", () return "TIME", () def format_data_type_timestamp(self, data_type: TimestampType) -> Tuple[str, tuple]: + if data_type.precision is not None: + if not (0 <= data_type.precision <= 7): + raise ValueError( + f"SQL Server DATETIME2 fractional seconds precision " + f"must be 0-7, got {data_type.precision}" + ) + return f"DATETIME2({data_type.precision})", () return "DATETIME2", () def format_data_type_json(self, data_type: JsonType) -> Tuple[str, tuple]: @@ -141,6 +181,97 @@ def format_data_type_blob(self, data_type: BlobType) -> Tuple[str, tuple]: def format_data_type_custom(self, data_type: CustomType) -> Tuple[str, tuple]: return data_type.raw, () + # ------------------------------------------------------------------ + # supports_data_type_ — 1:1 with format_data_type_ + # ------------------------------------------------------------------ + + def supports_data_type_sqlserver_nvarchar(self) -> bool: + return True + + def supports_data_type_sqlserver_nchar(self) -> bool: + return True + + def supports_data_type_sqlserver_nvarchar_max(self) -> bool: + return True + + def supports_data_type_sqlserver_varbinary(self) -> bool: + return True + + def supports_data_type_sqlserver_varbinary_max(self) -> bool: + return True + + def supports_data_type_sqlserver_xml(self) -> bool: + return True + + def supports_data_type_sqlserver_tinyint(self) -> bool: + return True + + def supports_data_type_sqlserver_bit(self) -> bool: + return True + + def supports_data_type_sqlserver_image(self) -> bool: + return True + + 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 + + def supports_data_type_smallint(self) -> bool: + return True + + def supports_data_type_tinyint(self) -> bool: + return True + + def supports_data_type_float(self) -> bool: + return True + + def supports_data_type_real(self) -> bool: + return True + + def supports_data_type_double(self) -> bool: + return True + + def supports_data_type_decimal(self) -> bool: + return True + + def supports_data_type_boolean(self) -> bool: + return True + + def supports_data_type_varchar(self) -> bool: + return True + + def supports_data_type_char(self) -> bool: + return True + + def supports_data_type_text(self) -> bool: + return True + + def supports_data_type_datetime(self) -> bool: + return True + + def supports_data_type_date(self) -> bool: + return True + + def supports_data_type_time(self) -> bool: + return True + + def supports_data_type_timestamp(self) -> bool: + return True + + def supports_data_type_json(self) -> bool: + return True + + def supports_data_type_blob(self) -> bool: + return True + + def supports_data_type_custom(self) -> bool: + return True + # ------------------------------------------------------------------ # DDLTypeSupport — parsing # ------------------------------------------------------------------ @@ -204,15 +335,15 @@ def parse_type(self, raw: str) -> DataType: return RealType(dialect=self) nums = re.findall(r"\d+", stripped) precision = int(nums[0]) if nums else None - return FloatType(precision, self) + return FloatType(dialect=self, precision=precision) # Decimal family if self._SQLSERVER_DECIMAL_TYPES.match(upper): nums = re.findall(r"\d+", stripped) if len(nums) >= 2: - return DecimalType(int(nums[0]), int(nums[1]), self) + return DecimalType(dialect=self, precision=int(nums[0]), scale=int(nums[1])) if len(nums) == 1: - return DecimalType(int(nums[0]), self) + return DecimalType(dialect=self, precision=int(nums[0])) return DecimalType(dialect=self) # String family @@ -222,8 +353,8 @@ def parse_type(self, raw: str) -> DataType: 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(length or 255, self) - return (CharType(length, self) if length_match else VarCharType(255, self)) + 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 if self._SQLSERVER_DATE_TYPES.match(upper): @@ -237,4 +368,4 @@ def parse_type(self, raw: str) -> DataType: from ..expression.types import SQLServerVarBinaryType return SQLServerVarBinaryType(dialect=self) - return CustomType(stripped, self) + return CustomType(dialect=self, raw=stripped) 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 new file mode 100644 index 0000000..6d29ad6 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_type_protocol.py @@ -0,0 +1,263 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_type_protocol.py +"""Tests for the SQL Server backend data-type protocol compliance. + +Covers: +- format/supports 1:1 correspondence +- supports_data_types() includes sqlserver_* + core entries +- suggested_data_types() returns classes, keys disjoint from supported +- dialect_options forwarding and equality +- Precision validation +""" + +import pytest +from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect +from rhosocial.activerecord.backend.impl.sqlserver.expression.types import ( + SQLServerNVarCharType, + SQLServerNCharType, + SQLServerNVarCharMaxType, + SQLServerVarBinaryType, + SQLServerVarBinaryMaxType, + SQLServerXmlType, + SQLServerTinyIntType, + SQLServerBitType, + SQLServerImageType, +) +from rhosocial.activerecord.backend.expression.types import ( + IntegerType, + BigIntType, + SmallIntType, + FloatType, + RealType, + DoubleType, + DecimalType, + BooleanType, + VarCharType, + CharType, + TextType, + DateTimeType, + DateType, + TimeType, + TimestampType, + JsonType, + BlobType, + CustomType, +) + +SQL_SERVER_2022 = (16, 0, 0) + + +class TestFormatSupportsCorrespondence: + """Every format_data_type_ must have a matching supports_data_type_.""" + + @pytest.fixture + def dialect(self): + return SQLServerDialect(SQL_SERVER_2022) + + def test_all_format_methods_have_supports_counterpart(self, dialect): + import re + format_names = set() + for attr in dir(type(dialect)): + m = re.match(r"^format_data_type_([a-z][a-z0-9_]*)$", attr) + if m: + format_names.add(m.group(1)) + + supports_names = set() + for attr in dir(type(dialect)): + m = re.match(r"^supports_data_type_([a-z][a-z0-9_]*)$", attr) + if m: + supports_names.add(m.group(1)) + + missing = format_names - supports_names + assert not missing, ( + f"format_data_type_* without supports_data_type_*: {missing}" + ) + + def test_all_supports_methods_have_format_counterpart(self, dialect): + import re + format_names = set() + for attr in dir(type(dialect)): + m = re.match(r"^format_data_type_([a-z][a-z0-9_]*)$", attr) + if m: + format_names.add(m.group(1)) + + supports_names = set() + for attr in dir(type(dialect)): + m = re.match(r"^supports_data_type_([a-z][a-z0-9_]*)$", attr) + if m: + supports_names.add(m.group(1)) + + extra = supports_names - format_names + assert not extra, ( + f"supports_data_type_* without format_data_type_*: {extra}" + ) + + +class TestSupportsDataTypes: + """supports_data_types() returns sqlserver_* + core entries.""" + + @pytest.fixture + def dialect(self): + return SQLServerDialect(SQL_SERVER_2022) + + def test_returns_dict(self, dialect): + result = dialect.supports_data_types() + assert isinstance(result, dict) + + def test_includes_core_types(self, dialect): + supported = dialect.supports_data_types() + for name in ["integer", "int", "bigint", "smallint", "tinyint", + "varchar", "char", "text", "boolean", "date", + "datetime", "time", "timestamp", "float", "real", + "double", "decimal", "json", "blob", "custom"]: + assert name in supported, f"Core type {name!r} missing from supports_data_types()" + + def test_includes_sqlserver_types(self, dialect): + supported = dialect.supports_data_types() + for name in ["sqlserver_nvarchar", "sqlserver_nchar", + "sqlserver_nvarchar_max", "sqlserver_varbinary", + "sqlserver_varbinary_max", "sqlserver_xml", + "sqlserver_tinyint", "sqlserver_bit", + "sqlserver_image"]: + assert name in supported, f"SQL Server type {name!r} missing from supports_data_types()" + + def test_values_are_type_classes(self, dialect): + supported = dialect.supports_data_types() + for name, cls in supported.items(): + assert isinstance(cls, type), ( + f"supports_data_types()[{name!r}] = {cls!r} is not a type class" + ) + + +class TestSuggestedDataTypes: + """suggested_data_types() returns classes, keys disjoint from supported.""" + + @pytest.fixture + def dialect(self): + return SQLServerDialect(SQL_SERVER_2022) + + def test_returns_dict(self, dialect): + result = dialect.suggested_data_types() + assert isinstance(result, dict) + + 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" + ) + + def test_keys_disjoint_from_supported(self, dialect): + supported = set(dialect.supports_data_types().keys()) + suggested = set(dialect.suggested_data_types().keys()) + overlap = supported & suggested + assert not overlap, ( + f"Keys appear in both supports and suggested: {overlap}" + ) + + 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"]: + assert name in suggested, ( + f"Expected suggestion for {name!r} in suggested_data_types()" + ) + + +class TestDialectOptionsForwarding: + """dialect_options are forwarded through __init__ and participate in equality.""" + + def test_nvarchar_forwarding(self): + t1 = SQLServerNVarCharType(length=100, dialect_options={"foo": "bar"}) + assert t1.dialect_options == {"foo": "bar"} + + def test_nvarchar_equality_with_options(self): + t1 = SQLServerNVarCharType(length=100, dialect_options={"foo": "bar"}) + t2 = SQLServerNVarCharType(length=100, dialect_options={"foo": "bar"}) + t3 = SQLServerNVarCharType(length=100, dialect_options={"foo": "baz"}) + assert t1 == t2 + assert t1 != t3 + + def test_nvarchar_hash_ignores_options(self): + t1 = SQLServerNVarCharType(length=100, dialect_options={"foo": "bar"}) + t2 = SQLServerNVarCharType(length=100, dialect_options={"foo": "baz"}) + assert hash(t1) == hash(t2) + + def test_varbinary_type_params(self): + t = SQLServerVarBinaryType(length=512) + assert t._type_params() == (512,) + + def test_varbinary_equality(self): + t1 = SQLServerVarBinaryType(length=512) + t2 = SQLServerVarBinaryType(length=512) + t3 = SQLServerVarBinaryType(length=1024) + assert t1 == t2 + assert t1 != t3 + + def test_decimal_type_params(self): + t = DecimalType(precision=10, scale=2) + assert t._type_params() == (10, 2) + + def test_float_type_params(self): + t = FloatType(precision=24) + assert t._type_params() == (24,) + + def test_datetime_type_params(self): + t = DateTimeType(precision=3) + assert t._type_params() == (3,) + + +class TestPrecisionValidation: + """Precision parameters are validated per SQL Server rules.""" + + @pytest.fixture + def dialect(self): + return SQLServerDialect(SQL_SERVER_2022) + + def test_decimal_precision_too_high(self, dialect): + with pytest.raises(ValueError, match="precision must be 1-38"): + dialect.format_data_type(DecimalType(precision=39, scale=0)) + + def test_decimal_scale_exceeds_precision(self, dialect): + with pytest.raises(ValueError, match="scale.*must not exceed precision"): + dialect.format_data_type(DecimalType(precision=5, scale=10)) + + def test_decimal_valid(self, dialect): + sql, _ = dialect.format_data_type(DecimalType(precision=38, scale=38)) + assert sql == "DECIMAL(38, 38)" + + def test_float_precision_too_high(self, dialect): + with pytest.raises(ValueError, match="precision must be 1-53"): + dialect.format_data_type(FloatType(precision=54)) + + def test_float_precision_zero(self, dialect): + with pytest.raises(ValueError, match="precision must be 1-53"): + dialect.format_data_type(FloatType(precision=0)) + + def test_float_valid(self, dialect): + sql, _ = dialect.format_data_type(FloatType(precision=53)) + assert sql == "FLOAT(53)" + + def test_datetime_precision_too_high(self, dialect): + with pytest.raises(ValueError, match="precision.*must be 0-7"): + dialect.format_data_type(DateTimeType(precision=8)) + + def test_datetime_precision_negative(self, dialect): + with pytest.raises(ValueError, match="precision.*must be 0-7"): + dialect.format_data_type(DateTimeType(precision=-1)) + + def test_datetime_valid(self, dialect): + sql, _ = dialect.format_data_type(DateTimeType(precision=7)) + assert sql == "DATETIME2(7)" + + def test_time_precision_too_high(self, dialect): + with pytest.raises(ValueError, match="precision.*must be 0-7"): + dialect.format_data_type(TimeType(precision=8)) + + def test_time_valid(self, dialect): + sql, _ = dialect.format_data_type(TimeType(precision=3)) + assert sql == "TIME(3)" + + def test_timestamp_precision_validation(self, dialect): + with pytest.raises(ValueError, match="precision.*must be 0-7"): + dialect.format_data_type(TimestampType(precision=8)) From 079b4b70fe86bcd520403590e1263729989e19ea Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 12 Sep 2026 16:36:34 +0800 Subject: [PATCH 06/58] fix(test): adapt all tests to dialect-first parameter-inline convention Update ColumnDefinition, DataType subclasses, and CreateTableExpression to use dialect-first constructor signatures: - ColumnDefinition(dialect, name, type, ...) - IntegerType(dialect), VarCharType(dialect, length=...), etc. This aligns tests with the parameter-inline refactor in core library where expressions bind the dialect at construction time. Fixes CI failures in: - test_auto_increment_ddl (ColumnDefinition/DataType signatures) - test_data_type_parsing (DataType dialect-first) - test_dialect_comprehensive (CreateTableExpression) - test_dialect_version_branches (DDL rendering snapshots) - test_create_table_like (ColumnDefinition signatures) - test_niladic_functions (DataType dialect-first) - test_p2_coverage_features (DataType dialect-first) - test_schema_diff, test_sqlserver_partition (providers/fixtures) - test_connection_management, test_parallel_crud (providers/fixtures) --- .../examples/schema_diff/add_column.py | 6 +- tests/providers/fixtures/basic.py | 224 +++++++++--------- tests/providers/fixtures/events.py | 24 +- tests/providers/fixtures/mixins.py | 42 ++-- tests/providers/fixtures/query.py | 180 +++++++------- .../backend/test_auto_increment_ddl.py | 18 +- .../feature/backend/test_create_table_like.py | 10 +- .../feature/backend/test_niladic_functions.py | 4 +- .../backend/test_p2_coverage_features.py | 4 +- .../feature/backend/test_schema_diff.py | 4 +- .../backend/test_sqlserver_partition.py | 10 +- 11 files changed, 263 insertions(+), 263 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/schema_diff/add_column.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/schema_diff/add_column.py index bb96736..bf2f93e 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/schema_diff/add_column.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/schema_diff/add_column.py @@ -39,11 +39,11 @@ # Baseline table: ID, NAME expr = CreateTableExpression( dialect=dialect, table="users", columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True), ]), - ColumnDefinition("name", VarCharType(length=100)), + ColumnDefinition(dialect, "name", VarCharType(dialect, length=100)), ] ) sql, params = expr.to_sql() @@ -63,7 +63,7 @@ alter = AlterTableExpression( dialect=dialect, table_name="users", actions=[ - AddColumn(dialect, column=ColumnDefinition("email", VarCharType(length=255))), + AddColumn(dialect, column=ColumnDefinition(dialect, "email", VarCharType(dialect, length=255))), ], ) sql, params = alter.to_sql() diff --git a/tests/providers/fixtures/basic.py b/tests/providers/fixtures/basic.py index c6454cf..6900a58 100644 --- a/tests/providers/fixtures/basic.py +++ b/tests/providers/fixtures/basic.py @@ -55,23 +55,23 @@ def create_users_table(dialect, table_name: str = "users") -> CreateTableExpress table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition("username", VarCharType(255), + ColumnDefinition(dialect, "username", VarCharType(dialect, length=255), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.UNIQUE)]), - ColumnDefinition("email", VarCharType(255), + ColumnDefinition(dialect, "email", VarCharType(dialect, length=255), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.UNIQUE)]), - ColumnDefinition("age", IntegerType()), - ColumnDefinition("balance", FloatType(), + ColumnDefinition(dialect, "age", IntegerType(dialect)), + ColumnDefinition(dialect, "balance", FloatType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0.0)]), - ColumnDefinition("is_active", BooleanType(), + ColumnDefinition(dialect, "is_active", BooleanType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), - ColumnDefinition("created_at", TextType()), - ColumnDefinition("updated_at", TextType()), + ColumnDefinition(dialect, "created_at", TextType(dialect)), + ColumnDefinition(dialect, "updated_at", TextType(dialect)), ], ) @@ -82,29 +82,29 @@ def create_type_cases_table(dialect, table_name: str = "type_cases") -> CreateTa table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", CharType(36), + ColumnDefinition(dialect, "id", CharType(dialect, length=36), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("username", VarCharType(255), + ColumnDefinition(dialect, "username", VarCharType(dialect, length=255), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("email", VarCharType(255), + ColumnDefinition(dialect, "email", VarCharType(dialect, length=255), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("tiny_int", SmallIntType()), - ColumnDefinition("small_int", SmallIntType()), - ColumnDefinition("big_int", BigIntType()), - ColumnDefinition("float_val", FloatType()), - ColumnDefinition("double_val", DoubleType()), - ColumnDefinition("decimal_val", DecimalType(10, 6)), - ColumnDefinition("char_val", CharType(10)), - ColumnDefinition("varchar_val", VarCharType(50)), - ColumnDefinition("text_val", TextType()), - ColumnDefinition("date_val", DateType()), - ColumnDefinition("time_val", TimeType()), - ColumnDefinition("timestamp_val", DateTimeType()), - ColumnDefinition("blob_val", BlobType()), - ColumnDefinition("json_val", JsonType()), - ColumnDefinition("array_val", JsonType()), - ColumnDefinition("is_active", BooleanType()), + ColumnDefinition(dialect, "tiny_int", SmallIntType(dialect)), + ColumnDefinition(dialect, "small_int", SmallIntType(dialect)), + ColumnDefinition(dialect, "big_int", BigIntType(dialect)), + ColumnDefinition(dialect, "float_val", FloatType(dialect)), + ColumnDefinition(dialect, "double_val", DoubleType(dialect)), + ColumnDefinition(dialect, "decimal_val", DecimalType(dialect, precision=10, scale=6)), + ColumnDefinition(dialect, "char_val", CharType(dialect, length=10)), + ColumnDefinition(dialect, "varchar_val", VarCharType(dialect, length=50)), + ColumnDefinition(dialect, "text_val", TextType(dialect)), + ColumnDefinition(dialect, "date_val", DateType(dialect)), + ColumnDefinition(dialect, "time_val", TimeType(dialect)), + ColumnDefinition(dialect, "timestamp_val", DateTimeType(dialect)), + ColumnDefinition(dialect, "blob_val", BlobType(dialect)), + ColumnDefinition(dialect, "json_val", JsonType(dialect)), + ColumnDefinition(dialect, "array_val", JsonType(dialect)), + ColumnDefinition(dialect, "is_active", BooleanType(dialect)), ], ) @@ -115,28 +115,28 @@ def create_type_tests_table(dialect, table_name: str = "type_tests") -> CreateTa table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", CharType(36), + ColumnDefinition(dialect, "id", CharType(dialect, length=36), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("string_field", VarCharType(255), + ColumnDefinition(dialect, "string_field", VarCharType(dialect, length=255), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="test string")]), - ColumnDefinition("int_field", IntegerType(), + ColumnDefinition(dialect, "int_field", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=42)]), - ColumnDefinition("float_field", FloatType(), + ColumnDefinition(dialect, "float_field", FloatType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=3.14)]), - ColumnDefinition("decimal_field", DoubleType(), + ColumnDefinition(dialect, "decimal_field", DoubleType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=10.99)]), - ColumnDefinition("bool_field", BooleanType(), + ColumnDefinition(dialect, "bool_field", BooleanType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), - ColumnDefinition("datetime_field", TextType(), + ColumnDefinition(dialect, "datetime_field", TextType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("json_field", JsonType()), - ColumnDefinition("nullable_field", VarCharType(255)), + ColumnDefinition(dialect, "json_field", JsonType(dialect)), + ColumnDefinition(dialect, "nullable_field", VarCharType(dialect, length=255)), ], ) @@ -147,20 +147,20 @@ def create_validated_field_users_table(dialect, table_name: str = "validated_fie table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition("username", VarCharType(255), + ColumnDefinition(dialect, "username", VarCharType(dialect, length=255), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("email", VarCharType(255), + ColumnDefinition(dialect, "email", VarCharType(dialect, length=255), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("age", IntegerType()), - ColumnDefinition("balance", DecimalType(precision=10, scale=2)), - ColumnDefinition("credit_score", IntegerType(), + ColumnDefinition(dialect, "age", IntegerType(dialect)), + ColumnDefinition(dialect, "balance", DecimalType(dialect, precision=10, scale=2)), + ColumnDefinition(dialect, "credit_score", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("status", VarCharType(50), + ColumnDefinition(dialect, "status", VarCharType(dialect, length=50), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="active")]), - ColumnDefinition("is_active", BooleanType(), + ColumnDefinition(dialect, "is_active", BooleanType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), ], @@ -173,13 +173,13 @@ def create_validated_users_table(dialect, table_name: str = "validated_users") - table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition("username", VarCharType(50), + ColumnDefinition(dialect, "username", VarCharType(dialect, length=50), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("email", VarCharType(255), + ColumnDefinition(dialect, "email", VarCharType(dialect, length=255), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("age", IntegerType()), + ColumnDefinition(dialect, "age", IntegerType(dialect)), ], ) @@ -192,17 +192,17 @@ def create_pydantic_validated_models_table( table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition("code", VarCharType(32)), - ColumnDefinition("quantity", IntegerType()), - ColumnDefinition("step_count", IntegerType()), - ColumnDefinition("price", DecimalType(precision=10, scale=2)), - ColumnDefinition("start_at", DateTimeType()), - ColumnDefinition("end_at", DateTimeType()), - ColumnDefinition("status", VarCharType(32)), - ColumnDefinition("normalized_name", VarCharType(50)), - ColumnDefinition("created_token", VarCharType(255)), + ColumnDefinition(dialect, "code", VarCharType(dialect, length=32)), + ColumnDefinition(dialect, "quantity", IntegerType(dialect)), + ColumnDefinition(dialect, "step_count", IntegerType(dialect)), + ColumnDefinition(dialect, "price", DecimalType(dialect, precision=10, scale=2)), + ColumnDefinition(dialect, "start_at", DateTimeType(dialect)), + ColumnDefinition(dialect, "end_at", DateTimeType(dialect)), + ColumnDefinition(dialect, "status", VarCharType(dialect, length=32)), + ColumnDefinition(dialect, "normalized_name", VarCharType(dialect, length=50)), + ColumnDefinition(dialect, "created_token", VarCharType(dialect, length=255)), ], ) @@ -213,13 +213,13 @@ def create_bulk_users_table(dialect, table_name: str = "bulk_users") -> CreateTa table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition("name", VarCharType(255), + ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("age", IntegerType(), + ColumnDefinition(dialect, "age", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0)]), - ColumnDefinition("email", VarCharType(255), + ColumnDefinition(dialect, "email", VarCharType(dialect, length=255), constraints=[ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="")]), ], ) @@ -231,18 +231,18 @@ def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpress table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition("author", IntegerType(), + ColumnDefinition(dialect, "author", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("title", VarCharType(255), + ColumnDefinition(dialect, "title", VarCharType(dialect, length=255), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("content", TextType()), - ColumnDefinition("published_at", DateTimeType(precision=6)), - ColumnDefinition("published", BooleanType(), + ColumnDefinition(dialect, "content", TextType(dialect)), + ColumnDefinition(dialect, "published_at", DateTimeType(dialect, precision=6)), + ColumnDefinition(dialect, "published", BooleanType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0)]), - ColumnDefinition("created_at", DateTimeType(precision=6)), - ColumnDefinition("updated_at", DateTimeType(precision=6)), + ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), + ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], indexes=[IndexDefinition(name="idx_author", columns=["author"])], table_constraints=[ @@ -258,18 +258,18 @@ def create_comments_table(dialect, table_name: str = "comments") -> CreateTableE table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition("post_ref", IntegerType(), + ColumnDefinition(dialect, "post_ref", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("author", IntegerType(), + ColumnDefinition(dialect, "author", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("text", TextType(), + ColumnDefinition(dialect, "text", TextType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("created_at", DateTimeType(precision=6), + ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("updated_at", DateTimeType(precision=6)), - ColumnDefinition("approved", BooleanType(), + ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), + ColumnDefinition(dialect, "approved", BooleanType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0)]), ], indexes=[ @@ -293,13 +293,13 @@ def create_column_mapping_items_table( table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition("name", VarCharType(255), + ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("item_total", IntegerType(), + ColumnDefinition(dialect, "item_total", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("remarks", IntegerType()), + ColumnDefinition(dialect, "remarks", IntegerType(dialect)), ], ) @@ -312,14 +312,14 @@ def create_mixed_annotation_items_table( table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition("name", VarCharType(255), + ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("tags", TextType()), - ColumnDefinition("meta", TextType()), - ColumnDefinition("description", TextType()), - ColumnDefinition("status", TextType()), + ColumnDefinition(dialect, "tags", TextType(dialect)), + ColumnDefinition(dialect, "meta", TextType(dialect)), + ColumnDefinition(dialect, "description", TextType(dialect)), + ColumnDefinition(dialect, "status", TextType(dialect)), ], ) @@ -332,17 +332,17 @@ def create_type_adapter_tests_table( table=table_name, if_not_exists=True, columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition("name", VarCharType(255), + ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("optional_name", VarCharType(255)), - ColumnDefinition("optional_age", IntegerType()), - ColumnDefinition("last_login", TextType()), - ColumnDefinition("is_premium", BooleanType()), - ColumnDefinition("unsupported_union", VarCharType(255)), - ColumnDefinition("custom_bool", VarCharType(3)), - ColumnDefinition("optional_custom_bool", VarCharType(3)), + ColumnDefinition(dialect, "optional_name", VarCharType(dialect, length=255)), + ColumnDefinition(dialect, "optional_age", IntegerType(dialect)), + ColumnDefinition(dialect, "last_login", TextType(dialect)), + ColumnDefinition(dialect, "is_premium", BooleanType(dialect)), + ColumnDefinition(dialect, "unsupported_union", VarCharType(dialect, length=255)), + ColumnDefinition(dialect, "custom_bool", VarCharType(dialect, length=3)), + ColumnDefinition(dialect, "optional_custom_bool", VarCharType(dialect, length=3)), ], ) @@ -355,14 +355,14 @@ def create_composite_pk_order_items_table( table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("order_id", IntegerType(), + ColumnDefinition(dialect, "order_id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("product_id", IntegerType(), + ColumnDefinition(dialect, "product_id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("quantity", IntegerType(), + ColumnDefinition(dialect, "quantity", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), - ColumnDefinition("unit_price", DecimalType(precision=10, scale=2), + ColumnDefinition(dialect, "unit_price", DecimalType(dialect, precision=10, scale=2), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), ], table_constraints=[ @@ -378,13 +378,13 @@ def create_store_inventory_table(dialect, table_name: str = "store_inventory") - table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("store_id", IntegerType(), + ColumnDefinition(dialect, "store_id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("product_id", IntegerType(), + ColumnDefinition(dialect, "product_id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("batch_id", VarCharType(64), + ColumnDefinition(dialect, "batch_id", VarCharType(dialect, length=64), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("stock", IntegerType(), + ColumnDefinition(dialect, "stock", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0)]), ], @@ -401,12 +401,12 @@ def create_orders_table(dialect, table_name: str = "orders") -> CreateTableExpre table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition("total", DecimalType(precision=10, scale=2), + ColumnDefinition(dialect, "total", DecimalType(dialect, precision=10, scale=2), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("created_at", TextType()), - ColumnDefinition("updated_at", TextType()), + ColumnDefinition(dialect, "created_at", TextType(dialect)), + ColumnDefinition(dialect, "updated_at", TextType(dialect)), ], ) @@ -417,13 +417,13 @@ def create_product_table(dialect, table_name: str = "product") -> CreateTableExp table=table_name, if_not_exists=True, columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition("name", TextType(), + ColumnDefinition(dialect, "name", TextType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("price", FloatType(), + ColumnDefinition(dialect, "price", FloatType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("quantity", IntegerType(), + ColumnDefinition(dialect, "quantity", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), ], ) diff --git a/tests/providers/fixtures/events.py b/tests/providers/fixtures/events.py index 3bdac8c..7c94529 100644 --- a/tests/providers/fixtures/events.py +++ b/tests/providers/fixtures/events.py @@ -34,19 +34,19 @@ def create_event_tests_table(dialect, table_name: str = "event_tests") -> Create table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition("name", VarCharType(255), + ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("status", VarCharType(50), + ColumnDefinition(dialect, "status", VarCharType(dialect, length=50), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="draft")]), - ColumnDefinition("revision", IntegerType(), + ColumnDefinition(dialect, "revision", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), - ColumnDefinition("content", TextType()), - ColumnDefinition("created_at", DateTimeType(precision=6)), - ColumnDefinition("updated_at", DateTimeType(precision=6)), + ColumnDefinition(dialect, "content", TextType(dialect)), + ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), + ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], ) @@ -59,16 +59,16 @@ def create_event_tracking_models_table( table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition("title", VarCharType(255), + ColumnDefinition(dialect, "title", VarCharType(dialect, length=255), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("content", TextType(), + ColumnDefinition(dialect, "content", TextType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("view_count", IntegerType(), + ColumnDefinition(dialect, "view_count", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0)]), - ColumnDefinition("last_viewed_at", DateTimeType(precision=6), + ColumnDefinition(dialect, "last_viewed_at", DateTimeType(dialect, precision=6), constraints=[ColumnConstraint(ColumnConstraintType.NULL)]), ], ) diff --git a/tests/providers/fixtures/mixins.py b/tests/providers/fixtures/mixins.py index 0e14c53..c4df8ef 100644 --- a/tests/providers/fixtures/mixins.py +++ b/tests/providers/fixtures/mixins.py @@ -36,21 +36,21 @@ def create_combined_articles_table(dialect, table_name: str = "combined_articles table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition("title", VarCharType(255), + ColumnDefinition(dialect, "title", VarCharType(dialect, length=255), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("content", TextType(), + ColumnDefinition(dialect, "content", TextType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("status", VarCharType(50), + ColumnDefinition(dialect, "status", VarCharType(dialect, length=50), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="draft")]), - ColumnDefinition("created_at", DateTimeType(precision=6)), - ColumnDefinition("updated_at", DateTimeType(precision=6)), - ColumnDefinition("version", IntegerType(), + ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), + ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), + ColumnDefinition(dialect, "version", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), - ColumnDefinition("deleted_at", DateTimeType(precision=6), + ColumnDefinition(dialect, "deleted_at", DateTimeType(dialect, precision=6), constraints=[ColumnConstraint(ColumnConstraintType.NULL)]), ], ) @@ -62,14 +62,14 @@ def create_tasks_table(dialect, table_name: str = "tasks") -> CreateTableExpress table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition("title", VarCharType(255), + ColumnDefinition(dialect, "title", VarCharType(dialect, length=255), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("is_completed", BooleanType(), + ColumnDefinition(dialect, "is_completed", BooleanType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0)]), - ColumnDefinition("deleted_at", TextType(), + ColumnDefinition(dialect, "deleted_at", TextType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NULL)]), ], ) @@ -81,14 +81,14 @@ def create_timestamped_posts_table(dialect, table_name: str = "timestamped_posts table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition("title", VarCharType(255), + ColumnDefinition(dialect, "title", VarCharType(dialect, length=255), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("content", TextType(), + ColumnDefinition(dialect, "content", TextType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("created_at", DateTimeType(precision=6)), - ColumnDefinition("updated_at", DateTimeType(precision=6)), + ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), + ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], ) @@ -99,14 +99,14 @@ def create_versioned_products_table(dialect, table_name: str = "versioned_produc table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition("name", VarCharType(255), + ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("price", DecimalType(precision=10, scale=2), + ColumnDefinition(dialect, "price", DecimalType(dialect, precision=10, scale=2), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0.0)]), - ColumnDefinition("version", IntegerType(), + ColumnDefinition(dialect, "version", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), ], diff --git a/tests/providers/fixtures/query.py b/tests/providers/fixtures/query.py index a28abcb..ca4319f 100644 --- a/tests/providers/fixtures/query.py +++ b/tests/providers/fixtures/query.py @@ -45,23 +45,23 @@ def create_users_table(dialect, table_name: str = "users") -> CreateTableExpress table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition("username", VarCharType(191), + ColumnDefinition(dialect, "username", VarCharType(dialect, length=191), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.UNIQUE)]), - ColumnDefinition("email", VarCharType(191), + ColumnDefinition(dialect, "email", VarCharType(dialect, length=191), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.UNIQUE)]), - ColumnDefinition("age", IntegerType()), - ColumnDefinition("balance", DoubleType(), + ColumnDefinition(dialect, "age", IntegerType(dialect)), + ColumnDefinition(dialect, "balance", DoubleType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0.0)]), - ColumnDefinition("is_active", BooleanType(), + ColumnDefinition(dialect, "is_active", BooleanType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), - ColumnDefinition("created_at", DateTimeType(precision=6)), - ColumnDefinition("updated_at", DateTimeType(precision=6)), + ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), + ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], ) @@ -72,18 +72,18 @@ def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpress table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition("user_id", IntegerType(), + ColumnDefinition(dialect, "user_id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("title", VarCharType(255), + ColumnDefinition(dialect, "title", VarCharType(dialect, length=255), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("content", TextType()), - ColumnDefinition("status", VarCharType(50), + ColumnDefinition(dialect, "content", TextType(dialect)), + ColumnDefinition(dialect, "status", VarCharType(dialect, length=50), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="published")]), - ColumnDefinition("created_at", DateTimeType(precision=6)), - ColumnDefinition("updated_at", DateTimeType(precision=6)), + ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), + ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], indexes=[ IndexDefinition(name="idx_user_id", columns=["user_id"]), @@ -102,17 +102,17 @@ def create_comments_table(dialect, table_name: str = "comments") -> CreateTableE table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition("user_id", IntegerType(), + ColumnDefinition(dialect, "user_id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("post_id", IntegerType(), + ColumnDefinition(dialect, "post_id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("content", TextType()), - ColumnDefinition("is_hidden", BooleanType(), + ColumnDefinition(dialect, "content", TextType(dialect)), + ColumnDefinition(dialect, "is_hidden", BooleanType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0)]), - ColumnDefinition("created_at", DateTimeType(precision=6)), - ColumnDefinition("updated_at", DateTimeType(precision=6)), + ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), + ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], indexes=[ IndexDefinition(name="idx_user_id", columns=["user_id"]), @@ -133,20 +133,20 @@ def create_orders_table(dialect, table_name: str = "orders") -> CreateTableExpre table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition("user_id", IntegerType(), + ColumnDefinition(dialect, "user_id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("order_number", VarCharType(255), + ColumnDefinition(dialect, "order_number", VarCharType(dialect, length=255), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("total_amount", DecimalType(precision=10, scale=2), + ColumnDefinition(dialect, "total_amount", DecimalType(dialect, precision=10, scale=2), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0.0)]), - ColumnDefinition("status", VarCharType(50), + ColumnDefinition(dialect, "status", VarCharType(dialect, length=50), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="pending")]), - ColumnDefinition("created_at", DateTimeType(precision=6)), - ColumnDefinition("updated_at", DateTimeType(precision=6)), + ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), + ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], indexes=[IndexDefinition(name="idx_user_id", columns=["user_id"])], table_constraints=[ @@ -161,22 +161,22 @@ def create_order_items_table(dialect, table_name: str = "order_items") -> Create table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition("order_id", IntegerType(), + ColumnDefinition(dialect, "order_id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("product_name", VarCharType(255), + ColumnDefinition(dialect, "product_name", VarCharType(dialect, length=255), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("quantity", IntegerType(), + ColumnDefinition(dialect, "quantity", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), - ColumnDefinition("unit_price", DecimalType(precision=10, scale=2), + ColumnDefinition(dialect, "unit_price", DecimalType(dialect, precision=10, scale=2), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("subtotal", DecimalType(precision=10, scale=2), + ColumnDefinition(dialect, "subtotal", DecimalType(dialect, precision=10, scale=2), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0.0)]), - ColumnDefinition("created_at", DateTimeType(precision=6)), - ColumnDefinition("updated_at", DateTimeType(precision=6)), + ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), + ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], indexes=[IndexDefinition(name="idx_order_id", columns=["order_id"])], table_constraints=[ @@ -192,14 +192,14 @@ def create_profiles_table(dialect, table_name: str = "profiles") -> CreateTableE table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition("user_id", IntegerType(), + ColumnDefinition(dialect, "user_id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("bio", TextType()), - ColumnDefinition("avatar_url", VarCharType(512)), - ColumnDefinition("created_at", DateTimeType(precision=6)), - ColumnDefinition("updated_at", DateTimeType(precision=6)), + ColumnDefinition(dialect, "bio", TextType(dialect)), + ColumnDefinition(dialect, "avatar_url", VarCharType(dialect, length=512)), + ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), + ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], table_constraints=[ ForeignKeyConstraint(columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"]), @@ -213,22 +213,22 @@ def create_json_users_table(dialect, table_name: str = "json_users") -> CreateTa table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition("username", VarCharType(255), + ColumnDefinition(dialect, "username", VarCharType(dialect, length=255), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("email", VarCharType(255), + ColumnDefinition(dialect, "email", VarCharType(dialect, length=255), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("age", IntegerType()), - ColumnDefinition("created_at", DateTimeType(precision=6)), - ColumnDefinition("updated_at", DateTimeType(precision=6)), - ColumnDefinition("settings", JsonType()), - ColumnDefinition("tags", JsonType()), - ColumnDefinition("profile", JsonType()), - ColumnDefinition("roles", JsonType()), - ColumnDefinition("scores", JsonType()), - ColumnDefinition("subscription", JsonType()), - ColumnDefinition("preferences", JsonType()), + ColumnDefinition(dialect, "age", IntegerType(dialect)), + ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), + ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), + ColumnDefinition(dialect, "settings", JsonType(dialect)), + ColumnDefinition(dialect, "tags", JsonType(dialect)), + ColumnDefinition(dialect, "profile", JsonType(dialect)), + ColumnDefinition(dialect, "roles", JsonType(dialect)), + ColumnDefinition(dialect, "scores", JsonType(dialect)), + ColumnDefinition(dialect, "subscription", JsonType(dialect)), + ColumnDefinition(dialect, "preferences", JsonType(dialect)), ], ) @@ -239,17 +239,17 @@ def create_nodes_table(dialect, table_name: str = "nodes") -> CreateTableExpress table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition("name", VarCharType(255), + ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("parent_id", IntegerType(), + ColumnDefinition(dialect, "parent_id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NULL)]), - ColumnDefinition("value", DecimalType(precision=10, scale=2), + ColumnDefinition(dialect, "value", DecimalType(dialect, precision=10, scale=2), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0.0)]), - ColumnDefinition("created_at", DateTimeType(precision=6)), - ColumnDefinition("updated_at", DateTimeType(precision=6)), + ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), + ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], indexes=[IndexDefinition(name="idx_parent_id", columns=["parent_id"])], table_constraints=[ @@ -265,12 +265,12 @@ def create_searchable_items_table(dialect, table_name: str = "searchable_items") table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition("name", VarCharType(255)), - ColumnDefinition("tags", TextType()), - ColumnDefinition("created_at", DateTimeType(precision=6)), - ColumnDefinition("updated_at", DateTimeType(precision=6)), + ColumnDefinition(dialect, "name", VarCharType(dialect, length=255)), + ColumnDefinition(dialect, "tags", TextType(dialect)), + ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), + ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], ) @@ -281,31 +281,31 @@ def create_extended_orders_table(dialect, table_name: str = "extended_orders") - table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition("user_id", IntegerType(), + ColumnDefinition(dialect, "user_id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("order_number", VarCharType(255), + ColumnDefinition(dialect, "order_number", VarCharType(dialect, length=255), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("total_amount", DecimalType(precision=10, scale=2), + ColumnDefinition(dialect, "total_amount", DecimalType(dialect, precision=10, scale=2), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0.0)]), - ColumnDefinition("status", VarCharType(50), + ColumnDefinition(dialect, "status", VarCharType(dialect, length=50), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="pending")]), - ColumnDefinition("priority", VarCharType(50), + ColumnDefinition(dialect, "priority", VarCharType(dialect, length=50), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="medium")]), - ColumnDefinition("region", VarCharType(50), + ColumnDefinition(dialect, "region", VarCharType(dialect, length=50), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="default")]), - ColumnDefinition("category", VarCharType(255)), - ColumnDefinition("product", VarCharType(255)), - ColumnDefinition("department", VarCharType(255)), - ColumnDefinition("year", VarCharType(10)), - ColumnDefinition("quarter", VarCharType(10)), - ColumnDefinition("created_at", DateTimeType(precision=6)), - ColumnDefinition("updated_at", DateTimeType(precision=6)), + ColumnDefinition(dialect, "category", VarCharType(dialect, length=255)), + ColumnDefinition(dialect, "product", VarCharType(dialect, length=255)), + ColumnDefinition(dialect, "department", VarCharType(dialect, length=255)), + ColumnDefinition(dialect, "year", VarCharType(dialect, length=10)), + ColumnDefinition(dialect, "quarter", VarCharType(dialect, length=10)), + ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), + ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], indexes=[ IndexDefinition(name="idx_user_id", columns=["user_id"]), @@ -328,21 +328,21 @@ def create_extended_order_items_table( table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), - ColumnDefinition("order_id", IntegerType(), + ColumnDefinition(dialect, "order_id", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("product_name", VarCharType(255), + ColumnDefinition(dialect, "product_name", VarCharType(dialect, length=255), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("quantity", IntegerType(), + ColumnDefinition(dialect, "quantity", IntegerType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), - ColumnDefinition("price", DecimalType(precision=10, scale=2), + ColumnDefinition(dialect, "price", DecimalType(dialect, precision=10, scale=2), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("category", VarCharType(255)), - ColumnDefinition("region", VarCharType(50)), - ColumnDefinition("created_at", DateTimeType(precision=6)), - ColumnDefinition("updated_at", DateTimeType(precision=6)), + ColumnDefinition(dialect, "category", VarCharType(dialect, length=255)), + ColumnDefinition(dialect, "region", VarCharType(dialect, length=50)), + ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), + ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], indexes=[IndexDefinition(name="idx_order_id", columns=["order_id"])], table_constraints=[ diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_auto_increment_ddl.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_auto_increment_ddl.py index 2a97af5..8250d94 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_auto_increment_ddl.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_auto_increment_ddl.py @@ -33,14 +33,14 @@ def _build_table(dialect): return CreateTableExpression( dialect=dialect, table="test_tbl", columns=[ - ColumnDefinition("id", IntegerType(), constraints=_pk()), - ColumnDefinition("name", TextType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=_pk()), + ColumnDefinition(dialect, "name", TextType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("flag", BooleanType(), + ColumnDefinition(dialect, "flag", BooleanType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=True)]), - ColumnDefinition("code", VarCharType(16)), - ColumnDefinition("created_at", TimestampType()), + ColumnDefinition(dialect, "code", VarCharType(dialect, length=16)), + ColumnDefinition(dialect, "created_at", TimestampType(dialect)), ], ) @@ -60,8 +60,8 @@ def test_boolean_default_true(self, dialect): expr = CreateTableExpression( dialect=dialect, table="bool_test", columns=[ - ColumnDefinition("id", IntegerType(), constraints=_pk()), - ColumnDefinition("flag", BooleanType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=_pk()), + ColumnDefinition(dialect, "flag", BooleanType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=True)]), ], @@ -73,8 +73,8 @@ def test_timestamp_type(self, dialect): expr = CreateTableExpression( dialect=dialect, table="ts_test", columns=[ - ColumnDefinition("id", IntegerType(), constraints=_pk()), - ColumnDefinition("created_at", TimestampType()), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=_pk()), + ColumnDefinition(dialect, "created_at", TimestampType(dialect)), ], ) sql, _ = dialect.format_create_table_statement(expr) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_create_table_like.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_create_table_like.py index 84c7551..4d688a5 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_create_table_like.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_create_table_like.py @@ -71,10 +71,10 @@ def test_like_ignores_columns_raises(self): """Test that LIKE syntax raises even when columns are present.""" dialect = SQLServerDialect() columns = [ - ColumnDefinition("id", IntegerType(), constraints=[ + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY) ]), - ColumnDefinition("name", VarCharType(255)) + ColumnDefinition(dialect, "name", VarCharType(dialect, length=255)) ] create_expr = CreateTableExpression( dialect=dialect, @@ -103,7 +103,7 @@ def test_temporary_uses_hash_prefix(self): """Test that a temporary table renders a #-prefixed table name.""" dialect = SQLServerDialect() columns = [ - ColumnDefinition("id", IntegerType(), constraints=[ + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY) ]), ] @@ -124,10 +124,10 @@ def test_fallback_to_base_when_no_like(self): """Test that base implementation is used when LIKE is not specified.""" dialect = SQLServerDialect() columns = [ - ColumnDefinition("id", IntegerType(), constraints=[ + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY) ]), - ColumnDefinition("name", VarCharType(255), constraints=[ + ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), constraints=[ ColumnConstraint(ColumnConstraintType.NOT_NULL) ]) ] diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_niladic_functions.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_niladic_functions.py index 70103b4..b35c71f 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_niladic_functions.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_niladic_functions.py @@ -162,11 +162,11 @@ def test_ddl_default_sysdatetime(self, sqlserver_backend): dialect=dialect, table=table_name, columns=[ - ColumnDefinition('id', IntegerType(), constraints=[ + ColumnDefinition(dialect, 'id', IntegerType(dialect), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), ]), - ColumnDefinition('ts', TimestampType(6), constraints=[ + ColumnDefinition(dialect, 'ts', TimestampType(dialect, precision=6), constraints=[ ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=FunctionCall(dialect, 'SYSDATETIME')), ]), diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p2_coverage_features.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p2_coverage_features.py index c256310..64e1305 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p2_coverage_features.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p2_coverage_features.py @@ -78,7 +78,7 @@ def _create_table(self, d, memory_optimized=True, durability="SCHEMA_ONLY", ) from rhosocial.activerecord.backend.expression.types import IntegerType - col = ColumnDefinition("id", IntegerType()) + col = ColumnDefinition(dialect, "id", IntegerType(dialect)) col.constraints.append(ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)) indexes = [] if hash_indexes: @@ -200,7 +200,7 @@ def test_create_table_with_hash_index_missing_bucket_count(self, dialect): ) from rhosocial.activerecord.backend.expression.types import IntegerType - col = ColumnDefinition("id", IntegerType()) + col = ColumnDefinition(dialect, "id", IntegerType(dialect)) col.constraints.append(ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)) ct = CreateTableExpression( dialect, diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_schema_diff.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_schema_diff.py index fbe6e33..cd02fe8 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_schema_diff.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_schema_diff.py @@ -91,9 +91,9 @@ def test_removed_column(self): def test_modified_column_type(self): old_col = self._make_column("name", "NVARCHAR(100)", 1, - parsed_dt=VarCharType(100)) + parsed_dt=VarCharType(length=100)) new_col = self._make_column("name", "NVARCHAR(200)", 1, - parsed_dt=VarCharType(200)) + parsed_dt=VarCharType(length=200)) old_snap = self._make_snapshot("SQLServerDialect", {"users": [old_col]}) new_snap = self._make_snapshot("SQLServerDialect", {"users": [new_col]}) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_partition.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_partition.py index 4fcc915..4f0ca73 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_partition.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_partition.py @@ -227,9 +227,9 @@ class TestSQLServerCreateTableWithPartition: def test_create_table_with_partition(self): d = SQLServerDialect(SQL_SERVER_2022) pk = ColumnConstraint(ColumnConstraintType.PRIMARY_KEY) - col_def = ColumnDefinition("id", IntegerType(), constraints=[pk]) - col_def2 = ColumnDefinition("name", VarCharType(100)) - col_def3 = ColumnDefinition("create_date", DateType()) + col_def = ColumnDefinition(d, "id", IntegerType(d), constraints=[pk]) + col_def2 = ColumnDefinition(d, "name", VarCharType(d, length=100)) + col_def3 = ColumnDefinition(d, "create_date", DateType(d)) col = Column(d, "create_date") partition = SqlServerPartitionClause( @@ -251,7 +251,7 @@ def test_create_table_without_partition(self): """Unchanged behavior when no partition is specified.""" d = SQLServerDialect(SQL_SERVER_2022) pk = ColumnConstraint(ColumnConstraintType.PRIMARY_KEY) - col_def = ColumnDefinition("id", IntegerType(), constraints=[pk]) + col_def = ColumnDefinition(d, "id", IntegerType(d), constraints=[pk]) stmt = CreateTableExpression( d, "users", columns=[col_def], @@ -265,7 +265,7 @@ def test_create_table_with_partition_on_old_version(self): """Should fail on SQL Server 2005 which doesn't support partitioning.""" d = SQLServerDialect(SQL_SERVER_2005) pk = ColumnConstraint(ColumnConstraintType.PRIMARY_KEY) - col_def = ColumnDefinition("id", IntegerType(), constraints=[pk]) + col_def = ColumnDefinition(d, "id", IntegerType(d), constraints=[pk]) col = Column(d, "id") partition = PartitionClause( d, PartitionStrategy.RANGE, [col], From cd3016862740e59162881c2b9fb3d36035014272 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 12 Sep 2026 18:45:48 +0800 Subject: [PATCH 07/58] fix(ci): prevent xdist worker deadlock on READ_COMMITTED_SNAPSHOT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: _prepare_read_committed_snapshot was a session-scoped autouse fixture that executed ALTER DATABASE ... SET READ_COMMITTED_SNAPSHOT ON. This requires an exclusive database lock. With pytest-xdist -n 8, each of the 8 workers runs its own session and executes this fixture simultaneously — all trying to acquire the exclusive lock at once, causing a deadlock where every worker waits for the others. Fix: 1. Add tests/enable_read_committed_snapshot.py — a standalone script that enables READ_COMMITTED_SNAPSHOT using pyodbc, meant to run BEFORE pytest in the CI workflow step. 2. In the workflow, insert 'python tests/enable_read_committed_snapshot.py' before every 'python -m pytest' invocation (10 jobs). 3. In conftest.py, change the fixture to verify-only mode: it checks whether the option is already on (it should be, from step 1), and only attempts ALTER DATABASE as a lightweight fallback with a short 3s timeout and 2 retries (instead of 5s × 5 retries). --- .github/workflows/test.yml | 9 +++++ tests/conftest.py | 47 ++++++++++++------------- tests/enable_read_committed_snapshot.py | 26 ++++++++++++++ 3 files changed, 57 insertions(+), 25 deletions(-) create mode 100644 tests/enable_read_committed_snapshot.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index feef634..a57e7f3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -105,6 +105,7 @@ jobs: - name: Run tests run: | export PYTHONPATH=src:tests + python tests/enable_read_committed_snapshot.py || true python -m pytest tests/ -n 8 --dist=loadgroup --tb=short -x test-py39-sqlserver2017: @@ -194,6 +195,7 @@ jobs: - name: Run tests run: | export PYTHONPATH=src:tests + python tests/enable_read_committed_snapshot.py || true python -m pytest tests/ -n 8 --dist=loadgroup --tb=short -x test-py310-sqlserver2019: @@ -283,6 +285,7 @@ jobs: - name: Run tests run: | export PYTHONPATH=src:tests + python tests/enable_read_committed_snapshot.py || true python -m pytest tests/ -n 8 --dist=loadgroup --tb=short -x test-py311-sqlserver2019: @@ -372,6 +375,7 @@ jobs: - name: Run tests run: | export PYTHONPATH=src:tests + python tests/enable_read_committed_snapshot.py || true python -m pytest tests/ -n 8 --dist=loadgroup --tb=short -x test-py312-sqlserver2025: @@ -461,6 +465,7 @@ jobs: - name: Run tests run: | export PYTHONPATH=src:tests + python tests/enable_read_committed_snapshot.py || true python -m pytest tests/ -n 8 --dist=loadgroup --tb=short -x test-py313-sqlserver2022: @@ -550,6 +555,7 @@ jobs: - name: Run tests run: | export PYTHONPATH=src:tests + python tests/enable_read_committed_snapshot.py || true python -m pytest tests/ -n 8 --dist=loadgroup --tb=short -x test-py313t-sqlserver2019: @@ -640,6 +646,7 @@ jobs: - name: Run tests run: | export PYTHONPATH=src:tests + python tests/enable_read_committed_snapshot.py || true python -m pytest tests/ -n 8 --dist=loadgroup --tb=short -x test-py314-sqlserver2025: @@ -729,6 +736,7 @@ jobs: - name: Run tests with coverage run: | export PYTHONPATH=src:tests + python tests/enable_read_committed_snapshot.py || true python -m pytest tests/ --tb=short -n 8 --dist=loadgroup --cov=src/rhosocial --cov-branch --cov-report=xml - name: Upload coverage to Codecov @@ -827,4 +835,5 @@ jobs: - name: Run tests run: | export PYTHONPATH=src:tests + python tests/enable_read_committed_snapshot.py || true python -m pytest tests/ -n 8 --dist=loadgroup --tb=short -x diff --git a/tests/conftest.py b/tests/conftest.py index f912277..3fe232b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,19 +32,14 @@ def _load_scenario_map(): @pytest.fixture(scope="session", autouse=True) def _prepare_read_committed_snapshot(): - """Enable READ_COMMITTED_SNAPSHOT on every scenario database up front. + """Verify READ_COMMITTED_SNAPSHOT is enabled on every scenario database. - SQL Server's default READ COMMITTED takes shared locks, so a transaction - that updates a row and then reads a full table blocks (deadlocks) on - other transactions' uncommitted rows. MySQL/InnoDB instead uses MVCC - consistent reads. This session-level fixture enables READ_COMMITTED_SNAPSHOT - for every configured database in the session as a whole, before any - test (including concurrent-pool/worker tests) runs and while no other - connections are open against those databases. + The actual ALTER DATABASE is performed by the CI workflow step + 'Enable READ_COMMITTED_SNAPSHOT' *before* pytest runs, so there is + no risk of xdist workers deadlocking on the exclusive database lock. - Enabling this option requires an exclusive database lock, so it must run - early with a lock timeout instead of blocking forever inside a test module - conftest (which runs after many tests have opened connections). + This fixture only checks and warns if the option is not yet on + (e.g. when running locally without the workflow). """ from rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig @@ -65,27 +60,29 @@ def _prepare_read_committed_snapshot(): except Exception: continue try: - already_on = False cursor = backend._connection.cursor() cursor.execute( "SELECT is_read_committed_snapshot_on FROM sys.databases WHERE name = ?", (db_name,), ) row = cursor.fetchone() - if row and (row[0] if isinstance(row, (list, tuple)) else getattr(row, "is_read_committed_snapshot_on", False)): - already_on = True + is_on = row[0] if row and isinstance(row, (list, tuple)) else ( + getattr(row, "is_read_committed_snapshot_on", False) if row else False + ) cursor.close() - if already_on: - continue - for _attempt in range(5): - try: - backend.execute("SET LOCK_TIMEOUT 5000") - backend.execute( - f"ALTER DATABASE [{db_name}] SET READ_COMMITTED_SNAPSHOT ON" - ) - break - except Exception: - time.sleep(1) + if not is_on: + # Not yet enabled — try to enable (single-process / local run). + # With xdist, only the first worker to acquire the lock succeeds; + # others will get a timeout and we silently continue. + for _attempt in range(2): + try: + backend.execute("SET LOCK_TIMEOUT 3000") + backend.execute( + f"ALTER DATABASE [{db_name}] SET READ_COMMITTED_SNAPSHOT ON" + ) + break + except Exception: + time.sleep(0.5) except Exception: pass finally: diff --git a/tests/enable_read_committed_snapshot.py b/tests/enable_read_committed_snapshot.py new file mode 100644 index 0000000..5f93d92 --- /dev/null +++ b/tests/enable_read_committed_snapshot.py @@ -0,0 +1,26 @@ +"""Pre-enable READ_COMMITTED_SNAPSHOT before pytest-xdist workers start.""" +import yaml, os, pyodbc, sys +cfg_path = os.environ.get("SQLSERVER_SCENARIOS_CONFIG_PATH", "") +if not cfg_path or not os.path.exists(cfg_path): + sys.exit(0) +with open(cfg_path) as f: + scenarios = yaml.safe_load(f).get("scenarios", {}) +for name, cfg in scenarios.items(): + port = cfg.get("port", 1433) + db = cfg.get("database", "test_db") + pwd = cfg.get("password", "Password123!") + driver = cfg.get("driver", "ODBC Driver 18 for SQL Server") + conn_str = ( + f"DRIVER={{{driver}}};SERVER=localhost,{port};DATABASE=master;" + f"UID=sa;PWD={pwd};TrustServerCertificate=yes;Encrypt=no;" + ) + try: + conn = pyodbc.connect(conn_str, autocommit=True) + cur = conn.cursor() + cur.execute("SET LOCK_TIMEOUT 10000") + cur.execute(f"ALTER DATABASE [{db}] SET READ_COMMITTED_SNAPSHOT ON") + cur.close() + conn.close() + print(f"READ_COMMITTED_SNAPSHOT enabled for {db} on port {port}") + except Exception as e: + print(f"Note: {db} on port {port}: {e}") From 49522c58fc586d437004012dad07a996d830fb34 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 12 Sep 2026 20:57:57 +0800 Subject: [PATCH 08/58] fix(fixtures): add dialect to ColumnConstraint in all SQL Server fixtures ColumnConstraint.__init__ requires dialect as first argument (parameter-inline convention), but SQL Server fixtures were calling ColumnConstraint(ColumnConstraintType.XXX) without dialect. Fixed in: basic.py, events.py, mixins.py, query.py --- tests/providers/fixtures/basic.py | 150 ++++++++++++++--------------- tests/providers/fixtures/events.py | 24 ++--- tests/providers/fixtures/mixins.py | 44 ++++----- tests/providers/fixtures/query.py | 122 +++++++++++------------ 4 files changed, 170 insertions(+), 170 deletions(-) diff --git a/tests/providers/fixtures/basic.py b/tests/providers/fixtures/basic.py index 6900a58..8d8f009 100644 --- a/tests/providers/fixtures/basic.py +++ b/tests/providers/fixtures/basic.py @@ -56,20 +56,20 @@ def create_users_table(dialect, table_name: str = "users") -> CreateTableExpress if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), ColumnDefinition(dialect, "username", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.UNIQUE)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.UNIQUE)]), ColumnDefinition(dialect, "email", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.UNIQUE)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.UNIQUE)]), ColumnDefinition(dialect, "age", IntegerType(dialect)), ColumnDefinition(dialect, "balance", FloatType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), ColumnDefinition(dialect, "is_active", BooleanType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), ColumnDefinition(dialect, "created_at", TextType(dialect)), ColumnDefinition(dialect, "updated_at", TextType(dialect)), ], @@ -83,12 +83,12 @@ def create_type_cases_table(dialect, table_name: str = "type_cases") -> CreateTa if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", CharType(dialect, length=36), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), ColumnDefinition(dialect, "username", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "email", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "tiny_int", SmallIntType(dialect)), ColumnDefinition(dialect, "small_int", SmallIntType(dialect)), ColumnDefinition(dialect, "big_int", BigIntType(dialect)), @@ -116,25 +116,25 @@ def create_type_tests_table(dialect, table_name: str = "type_tests") -> CreateTa if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", CharType(dialect, length=36), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), ColumnDefinition(dialect, "string_field", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="test string")]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="test string")]), ColumnDefinition(dialect, "int_field", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=42)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=42)]), ColumnDefinition(dialect, "float_field", FloatType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=3.14)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=3.14)]), ColumnDefinition(dialect, "decimal_field", DoubleType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=10.99)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=10.99)]), ColumnDefinition(dialect, "bool_field", BooleanType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), ColumnDefinition(dialect, "datetime_field", TextType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "json_field", JsonType(dialect)), ColumnDefinition(dialect, "nullable_field", VarCharType(dialect, length=255)), ], @@ -148,21 +148,21 @@ def create_validated_field_users_table(dialect, table_name: str = "validated_fie if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), ColumnDefinition(dialect, "username", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "email", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "age", IntegerType(dialect)), ColumnDefinition(dialect, "balance", DecimalType(dialect, precision=10, scale=2)), ColumnDefinition(dialect, "credit_score", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "status", VarCharType(dialect, length=50), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="active")]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="active")]), ColumnDefinition(dialect, "is_active", BooleanType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), ], ) @@ -174,11 +174,11 @@ def create_validated_users_table(dialect, table_name: str = "validated_users") - if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), ColumnDefinition(dialect, "username", VarCharType(dialect, length=50), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "email", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "age", IntegerType(dialect)), ], ) @@ -193,7 +193,7 @@ def create_pydantic_validated_models_table( if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), ColumnDefinition(dialect, "code", VarCharType(dialect, length=32)), ColumnDefinition(dialect, "quantity", IntegerType(dialect)), ColumnDefinition(dialect, "step_count", IntegerType(dialect)), @@ -214,13 +214,13 @@ def create_bulk_users_table(dialect, table_name: str = "bulk_users") -> CreateTa if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "age", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), ColumnDefinition(dialect, "email", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="")]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="")]), ], ) @@ -232,15 +232,15 @@ def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpress if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), ColumnDefinition(dialect, "author", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "title", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "content", TextType(dialect)), ColumnDefinition(dialect, "published_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "published", BooleanType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], @@ -259,18 +259,18 @@ def create_comments_table(dialect, table_name: str = "comments") -> CreateTableE if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), ColumnDefinition(dialect, "post_ref", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "author", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "text", TextType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "approved", BooleanType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), ], indexes=[ IndexDefinition(name="idx_post_ref", columns=["post_ref"]), @@ -294,11 +294,11 @@ def create_column_mapping_items_table( if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "item_total", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "remarks", IntegerType(dialect)), ], ) @@ -313,9 +313,9 @@ def create_mixed_annotation_items_table( if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "tags", TextType(dialect)), ColumnDefinition(dialect, "meta", TextType(dialect)), ColumnDefinition(dialect, "description", TextType(dialect)), @@ -333,9 +333,9 @@ def create_type_adapter_tests_table( if_not_exists=True, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "optional_name", VarCharType(dialect, length=255)), ColumnDefinition(dialect, "optional_age", IntegerType(dialect)), ColumnDefinition(dialect, "last_login", TextType(dialect)), @@ -356,14 +356,14 @@ def create_composite_pk_order_items_table( if_not_exists=False, columns=[ ColumnDefinition(dialect, "order_id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "product_id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "quantity", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), ColumnDefinition(dialect, "unit_price", DecimalType(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ], table_constraints=[ TableConstraint(constraint_type=TableConstraintType.PRIMARY_KEY, @@ -379,14 +379,14 @@ def create_store_inventory_table(dialect, table_name: str = "store_inventory") - if_not_exists=False, columns=[ ColumnDefinition(dialect, "store_id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "product_id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "batch_id", VarCharType(dialect, length=64), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "stock", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), ], table_constraints=[ TableConstraint(constraint_type=TableConstraintType.PRIMARY_KEY, @@ -402,9 +402,9 @@ def create_orders_table(dialect, table_name: str = "orders") -> CreateTableExpre if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), ColumnDefinition(dialect, "total", DecimalType(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "created_at", TextType(dialect)), ColumnDefinition(dialect, "updated_at", TextType(dialect)), ], @@ -418,13 +418,13 @@ def create_product_table(dialect, table_name: str = "product") -> CreateTableExp if_not_exists=True, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), ColumnDefinition(dialect, "name", TextType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "price", FloatType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "quantity", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ], ) diff --git a/tests/providers/fixtures/events.py b/tests/providers/fixtures/events.py index 7c94529..c2ed28e 100644 --- a/tests/providers/fixtures/events.py +++ b/tests/providers/fixtures/events.py @@ -35,15 +35,15 @@ def create_event_tests_table(dialect, table_name: str = "event_tests") -> Create if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "status", VarCharType(dialect, length=50), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="draft")]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="draft")]), ColumnDefinition(dialect, "revision", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), ColumnDefinition(dialect, "content", TextType(dialect)), ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), @@ -60,16 +60,16 @@ def create_event_tracking_models_table( if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), ColumnDefinition(dialect, "title", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "content", TextType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "view_count", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), ColumnDefinition(dialect, "last_viewed_at", DateTimeType(dialect, precision=6), - constraints=[ColumnConstraint(ColumnConstraintType.NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NULL)]), ], ) diff --git a/tests/providers/fixtures/mixins.py b/tests/providers/fixtures/mixins.py index c4df8ef..ef1b65a 100644 --- a/tests/providers/fixtures/mixins.py +++ b/tests/providers/fixtures/mixins.py @@ -37,21 +37,21 @@ def create_combined_articles_table(dialect, table_name: str = "combined_articles if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), ColumnDefinition(dialect, "title", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "content", TextType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "status", VarCharType(dialect, length=50), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="draft")]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="draft")]), ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "version", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), ColumnDefinition(dialect, "deleted_at", DateTimeType(dialect, precision=6), - constraints=[ColumnConstraint(ColumnConstraintType.NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NULL)]), ], ) @@ -63,14 +63,14 @@ def create_tasks_table(dialect, table_name: str = "tasks") -> CreateTableExpress if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), ColumnDefinition(dialect, "title", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "is_completed", BooleanType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), ColumnDefinition(dialect, "deleted_at", TextType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NULL)]), ], ) @@ -82,11 +82,11 @@ def create_timestamped_posts_table(dialect, table_name: str = "timestamped_posts if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), ColumnDefinition(dialect, "title", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "content", TextType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], @@ -100,15 +100,15 @@ def create_versioned_products_table(dialect, table_name: str = "versioned_produc if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "price", DecimalType(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), ColumnDefinition(dialect, "version", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), ], ) diff --git a/tests/providers/fixtures/query.py b/tests/providers/fixtures/query.py index ca4319f..9ccdf1d 100644 --- a/tests/providers/fixtures/query.py +++ b/tests/providers/fixtures/query.py @@ -46,20 +46,20 @@ def create_users_table(dialect, table_name: str = "users") -> CreateTableExpress if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), ColumnDefinition(dialect, "username", VarCharType(dialect, length=191), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.UNIQUE)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.UNIQUE)]), ColumnDefinition(dialect, "email", VarCharType(dialect, length=191), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.UNIQUE)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.UNIQUE)]), ColumnDefinition(dialect, "age", IntegerType(dialect)), ColumnDefinition(dialect, "balance", DoubleType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), ColumnDefinition(dialect, "is_active", BooleanType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], @@ -73,15 +73,15 @@ def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpress if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), ColumnDefinition(dialect, "user_id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "title", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "content", TextType(dialect)), ColumnDefinition(dialect, "status", VarCharType(dialect, length=50), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="published")]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="published")]), ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], @@ -103,14 +103,14 @@ def create_comments_table(dialect, table_name: str = "comments") -> CreateTableE if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), ColumnDefinition(dialect, "user_id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "post_id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "content", TextType(dialect)), ColumnDefinition(dialect, "is_hidden", BooleanType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], @@ -134,17 +134,17 @@ def create_orders_table(dialect, table_name: str = "orders") -> CreateTableExpre if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), ColumnDefinition(dialect, "user_id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "order_number", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "total_amount", DecimalType(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), ColumnDefinition(dialect, "status", VarCharType(dialect, length=50), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="pending")]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="pending")]), ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], @@ -162,19 +162,19 @@ def create_order_items_table(dialect, table_name: str = "order_items") -> Create if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), ColumnDefinition(dialect, "order_id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "product_name", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "quantity", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), ColumnDefinition(dialect, "unit_price", DecimalType(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "subtotal", DecimalType(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], @@ -193,9 +193,9 @@ def create_profiles_table(dialect, table_name: str = "profiles") -> CreateTableE if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), ColumnDefinition(dialect, "user_id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "bio", TextType(dialect)), ColumnDefinition(dialect, "avatar_url", VarCharType(dialect, length=512)), ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), @@ -214,11 +214,11 @@ def create_json_users_table(dialect, table_name: str = "json_users") -> CreateTa if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), ColumnDefinition(dialect, "username", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "email", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "age", IntegerType(dialect)), ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), @@ -240,14 +240,14 @@ def create_nodes_table(dialect, table_name: str = "nodes") -> CreateTableExpress if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "parent_id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NULL)]), ColumnDefinition(dialect, "value", DecimalType(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], @@ -266,7 +266,7 @@ def create_searchable_items_table(dialect, table_name: str = "searchable_items") if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), ColumnDefinition(dialect, "name", VarCharType(dialect, length=255)), ColumnDefinition(dialect, "tags", TextType(dialect)), ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), @@ -282,23 +282,23 @@ def create_extended_orders_table(dialect, table_name: str = "extended_orders") - if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), ColumnDefinition(dialect, "user_id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "order_number", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "total_amount", DecimalType(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), ColumnDefinition(dialect, "status", VarCharType(dialect, length=50), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="pending")]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="pending")]), ColumnDefinition(dialect, "priority", VarCharType(dialect, length=50), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="medium")]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="medium")]), ColumnDefinition(dialect, "region", VarCharType(dialect, length=50), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="default")]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="default")]), ColumnDefinition(dialect, "category", VarCharType(dialect, length=255)), ColumnDefinition(dialect, "product", VarCharType(dialect, length=255)), ColumnDefinition(dialect, "department", VarCharType(dialect, length=255)), @@ -329,16 +329,16 @@ def create_extended_order_items_table( if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), ColumnDefinition(dialect, "order_id", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "product_name", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "quantity", IntegerType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), ColumnDefinition(dialect, "price", DecimalType(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "category", VarCharType(dialect, length=255)), ColumnDefinition(dialect, "region", VarCharType(dialect, length=50)), ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), From 90ae0710dcf0bf312519f36128746afc44d838ed Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 12 Sep 2026 22:32:01 +0800 Subject: [PATCH 09/58] fix(dialect): use to_sql() without arguments in _format_column_definition col_def.data_type.to_sql(self) was passing dialect as an argument, but parameter-inline refactored to_sql() to take no arguments (dialect is bound at construction time). Change to to_sql(). Also addresses the locking.py to_sql() -> str question: SQLServerTableHint is a standalone dataclass (not BaseExpression), its to_sql() returning a hint name string is intentional. --- src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index 471c297..51e9cf7 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -1321,7 +1321,7 @@ def _format_column_definition(self, col_def: "ColumnDefinition", ColumnConstrain """Format a column definition for SQL Server.""" from rhosocial.activerecord.backend.expression.types._base import DataType if isinstance(col_def.data_type, DataType): - type_sql, type_params = col_def.data_type.to_sql(self) + type_sql, type_params = col_def.data_type.to_sql() parts = [self.format_identifier(col_def.name), type_sql] params: List[Any] = list(type_params) else: From 0aef8b6cd07f7d09f4e1eb46e2e56045ecf05a34 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 12 Sep 2026 23:12:22 +0800 Subject: [PATCH 10/58] fix(dialect): migrate format_set_operation_expression to (self, expr) Same parameter-inline migration as ClickHouse/Oracle: the method now takes a single expr argument, extracting left/right/operation/all_/ alias/order_by_clause/limit_offset_clause from it. --- .../backend/impl/sqlserver/dialect.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index 51e9cf7..eb82730 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -1734,22 +1734,19 @@ def format_for_update_clause(self, clause: "ForUpdateClause") -> Tuple[str, tupl return " ".join(sql_parts), tuple(all_params) - def format_set_operation_expression( - self, - left: "bases.BaseExpression", - right: "bases.BaseExpression", - operation: str, - alias: str, - all_: bool, - order_by_clause: "OrderByClause" = None, - limit_offset_clause: "LimitOffsetClause" = None, - for_update_clause: "ForUpdateClause" = None, - ) -> Tuple[str, tuple]: + def format_set_operation_expression(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: """Format set operation for SQL Server. SQL Server supports UNION, UNION ALL, INTERSECT, EXCEPT. ORDER BY and OFFSET FETCH apply to the entire set operation result. """ + left, right = expr.left, expr.right + operation = expr.operation + all_ = expr.all_ + alias = expr.alias + order_by_clause = expr.order_by_clause + limit_offset_clause = expr.limit_offset_clause + left_sql, left_params = left.to_sql() right_sql, right_params = right.to_sql() all_str = " ALL" if all_ else "" From 261bb20a161befdf024ff6dc0a9b6b9ad6add8e4 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 12 Sep 2026 23:29:50 +0800 Subject: [PATCH 11/58] fix(test): complete dialect-first migration in SQL Server tests Add the dialect first argument to all remaining ColumnDefinition, ColumnConstraint, and DataType constructor calls across test files: - test_alter_table_if_exists_backend.py - test_auto_increment_ddl.py (_pk helper now takes dialect) - test_create_table_like.py - test_dialect_comprehensive.py - test_dialect_version_branches.py (use local 'd' variable) - test_niladic_functions.py - test_p2_coverage_features.py (_create_table uses its 'd' param) - test_schema_diff.py (add local SQLServerDialect) - test_sqlserver_partition.py (use local 'd' variable) --- .../test_alter_table_if_exists_backend.py | 4 +-- .../backend/test_auto_increment_ddl.py | 20 +++++++------- .../feature/backend/test_create_table_like.py | 8 +++--- .../backend/test_dialect_comprehensive.py | 12 ++++----- .../backend/test_dialect_version_branches.py | 6 ++--- .../feature/backend/test_niladic_functions.py | 26 +++++++++---------- .../backend/test_p2_coverage_features.py | 6 ++--- .../feature/backend/test_schema_diff.py | 9 ++++--- .../backend/test_sqlserver_partition.py | 6 ++--- 9 files changed, 50 insertions(+), 47 deletions(-) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_alter_table_if_exists_backend.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_alter_table_if_exists_backend.py index 3ee4a76..07f4eae 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_alter_table_if_exists_backend.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_alter_table_if_exists_backend.py @@ -33,7 +33,7 @@ def test_supports_switches(self, dialect): class TestSQLServerAddColumn: def test_add_column_omits_column_literal(self, dialect): - action = AddColumn(dialect, ColumnDefinition(dialect, "content", TextType())) + action = AddColumn(dialect, ColumnDefinition(dialect, "content", TextType(dialect))) sql, params = action.to_sql() assert sql.startswith("ADD [content]") assert "ADD COLUMN" not in sql @@ -42,7 +42,7 @@ def test_add_column_omits_column_literal(self, dialect): def test_add_column_if_not_exists_raises(self, dialect): action = AddColumn( dialect, - ColumnDefinition(dialect, "content", TextType()), + ColumnDefinition(dialect, "content", TextType(dialect)), if_not_exists=True, ) with pytest.raises(UnsupportedFeatureError): diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_auto_increment_ddl.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_auto_increment_ddl.py index 8250d94..70acf6a 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_auto_increment_ddl.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_auto_increment_ddl.py @@ -25,20 +25,20 @@ def dialect(): return SQLServerDialect() -def _pk(): - return [ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)] +def _pk(dialect): + return [ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)] def _build_table(dialect): return CreateTableExpression( dialect=dialect, table="test_tbl", columns=[ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=_pk()), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=_pk(dialect)), ColumnDefinition(dialect, "name", TextType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ColumnDefinition(dialect, "flag", BooleanType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=True)]), ColumnDefinition(dialect, "code", VarCharType(dialect, length=16)), ColumnDefinition(dialect, "created_at", TimestampType(dialect)), ], @@ -60,10 +60,10 @@ def test_boolean_default_true(self, dialect): expr = CreateTableExpression( dialect=dialect, table="bool_test", columns=[ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=_pk()), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=_pk(dialect)), ColumnDefinition(dialect, "flag", BooleanType(dialect), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=True)]), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=True)]), ], ) sql, _ = dialect.format_create_table_statement(expr) @@ -73,7 +73,7 @@ def test_timestamp_type(self, dialect): expr = CreateTableExpression( dialect=dialect, table="ts_test", columns=[ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=_pk()), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=_pk(dialect)), ColumnDefinition(dialect, "created_at", TimestampType(dialect)), ], ) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_create_table_like.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_create_table_like.py index 4d688a5..22b368e 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_create_table_like.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_create_table_like.py @@ -72,7 +72,7 @@ def test_like_ignores_columns_raises(self): dialect = SQLServerDialect() columns = [ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY) + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY) ]), ColumnDefinition(dialect, "name", VarCharType(dialect, length=255)) ] @@ -104,7 +104,7 @@ def test_temporary_uses_hash_prefix(self): dialect = SQLServerDialect() columns = [ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY) + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY) ]), ] create_expr = CreateTableExpression( @@ -125,10 +125,10 @@ def test_fallback_to_base_when_no_like(self): dialect = SQLServerDialect() columns = [ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY) + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY) ]), ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL) + ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL) ]) ] create_expr = CreateTableExpression( 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 7939222..bb901c0 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 @@ -769,15 +769,15 @@ def test_create_table_basic(self, dialect): dialect=dialect, table=TableExpression(dialect, "users"), columns=[ - ColumnDefinition( + ColumnDefinition(dialect, name="id", - data_type=IntegerType(), - constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)], + data_type=IntegerType(dialect), + constraints=[ColumnConstraint(dialect, constraint_type=ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)], ), - ColumnDefinition( + ColumnDefinition(dialect, name="name", - data_type=VarCharType(length=255), - constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.NOT_NULL)], + data_type=VarCharType(dialect, length=255), + constraints=[ColumnConstraint(dialect, constraint_type=ColumnConstraintType.NOT_NULL)], ), ], ) 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 1d1096a..1b69545 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 @@ -211,7 +211,7 @@ def test_temporary_table_gets_hash_prefix(self): expr = CreateTableExpression( dialect=d, table=TableExpression(d, "stage_users"), - columns=[ColumnDefinition(name="id", data_type=IntegerType())], + columns=[ColumnDefinition(d, name="id", data_type=IntegerType(d))], temporary=True, ) sql, params = expr.to_sql() @@ -230,7 +230,7 @@ def test_temporary_table_keeps_schema_prefix_order(self): expr = CreateTableExpression( dialect=d, table=TableExpression(d, "t", "dbo"), - columns=[ColumnDefinition(name="id", data_type=IntegerType())], + columns=[ColumnDefinition(d, name="id", data_type=IntegerType(d))], temporary=True, ) assert expr.to_sql()[0] == "CREATE TABLE [dbo].[#t] ([id] INT)" @@ -247,7 +247,7 @@ def test_create_table_like_is_unsupported(self): expr = CreateTableExpression( dialect=d, table=TableExpression(d, "copy"), - columns=[ColumnDefinition(name="id", data_type=IntegerType())], + columns=[ColumnDefinition(d, name="id", data_type=IntegerType(d))], ) expr.dialect_options["like_table"] = "original" with pytest.raises(UnsupportedFeatureError): diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_niladic_functions.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_niladic_functions.py index b35c71f..5119980 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_niladic_functions.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_niladic_functions.py @@ -122,12 +122,12 @@ def test_ddl_default_current_timestamp_niladic(self, sqlserver_backend): dialect=dialect, table=table_name, columns=[ - ColumnDefinition('id', IntegerType(), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnDefinition(dialect, 'id', IntegerType(dialect), constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL, is_auto_increment=True), ]), - ColumnDefinition('ts', TimestampType(), constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, + ColumnDefinition(dialect, 'ts', TimestampType(dialect), constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=current_timestamp(dialect)), ]), ], @@ -163,11 +163,11 @@ def test_ddl_default_sysdatetime(self, sqlserver_backend): table=table_name, columns=[ ColumnDefinition(dialect, 'id', IntegerType(dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL, is_auto_increment=True), ]), ColumnDefinition(dialect, 'ts', TimestampType(dialect, precision=6), constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=FunctionCall(dialect, 'SYSDATETIME')), ]), ], @@ -223,12 +223,12 @@ async def test_async_ddl_default_current_timestamp_niladic(self, async_sqlserver dialect=dialect, table=table_name, columns=[ - ColumnDefinition('id', IntegerType(), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnDefinition(dialect, 'id', IntegerType(dialect), constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL, is_auto_increment=True), ]), - ColumnDefinition('ts', TimestampType(), constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, + ColumnDefinition(dialect, 'ts', TimestampType(dialect), constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=current_timestamp(dialect)), ]), ], diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p2_coverage_features.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p2_coverage_features.py index 64e1305..eeaf18c 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p2_coverage_features.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p2_coverage_features.py @@ -78,8 +78,8 @@ def _create_table(self, d, memory_optimized=True, durability="SCHEMA_ONLY", ) from rhosocial.activerecord.backend.expression.types import IntegerType - col = ColumnDefinition(dialect, "id", IntegerType(dialect)) - col.constraints.append(ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)) + col = ColumnDefinition(d, "id", IntegerType(d)) + col.constraints.append(ColumnConstraint(d, ColumnConstraintType.PRIMARY_KEY)) indexes = [] if hash_indexes: for name, (columns, bucket_count) in hash_indexes.items(): @@ -201,7 +201,7 @@ def test_create_table_with_hash_index_missing_bucket_count(self, dialect): from rhosocial.activerecord.backend.expression.types import IntegerType col = ColumnDefinition(dialect, "id", IntegerType(dialect)) - col.constraints.append(ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)) + col.constraints.append(ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)) ct = CreateTableExpression( dialect, "t", diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_schema_diff.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_schema_diff.py index cd02fe8..72d5a9d 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_schema_diff.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_schema_diff.py @@ -12,6 +12,7 @@ IndexInfo, ForeignKeyInfo, ) from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType +from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect from rhosocial.activerecord.backend.impl.sqlserver.schema import SQLServerSchemaDiffer @@ -51,7 +52,8 @@ def _make_column(self, name: str, data_type: str, ordinal: int, ) def test_no_changes(self): - col = self._make_column("id", "INT", 1, parsed_dt=IntegerType()) + dialect = SQLServerDialect() + col = self._make_column("id", "INT", 1, parsed_dt=IntegerType(dialect)) snap = self._make_snapshot("SQLServerDialect", {"users": [col]}) differ = SQLServerSchemaDiffer() diff = differ.compare(snap, snap) @@ -90,10 +92,11 @@ def test_removed_column(self): assert removed[0].column_name == "name" def test_modified_column_type(self): + dialect = SQLServerDialect() old_col = self._make_column("name", "NVARCHAR(100)", 1, - parsed_dt=VarCharType(length=100)) + parsed_dt=VarCharType(dialect, length=100)) new_col = self._make_column("name", "NVARCHAR(200)", 1, - parsed_dt=VarCharType(length=200)) + parsed_dt=VarCharType(dialect, length=200)) old_snap = self._make_snapshot("SQLServerDialect", {"users": [old_col]}) new_snap = self._make_snapshot("SQLServerDialect", {"users": [new_col]}) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_partition.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_partition.py index 4f0ca73..dc97a4b 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_partition.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_partition.py @@ -226,7 +226,7 @@ class TestSQLServerCreateTableWithPartition: def test_create_table_with_partition(self): d = SQLServerDialect(SQL_SERVER_2022) - pk = ColumnConstraint(ColumnConstraintType.PRIMARY_KEY) + pk = ColumnConstraint(d, ColumnConstraintType.PRIMARY_KEY) col_def = ColumnDefinition(d, "id", IntegerType(d), constraints=[pk]) col_def2 = ColumnDefinition(d, "name", VarCharType(d, length=100)) col_def3 = ColumnDefinition(d, "create_date", DateType(d)) @@ -250,7 +250,7 @@ def test_create_table_with_partition(self): def test_create_table_without_partition(self): """Unchanged behavior when no partition is specified.""" d = SQLServerDialect(SQL_SERVER_2022) - pk = ColumnConstraint(ColumnConstraintType.PRIMARY_KEY) + pk = ColumnConstraint(d, ColumnConstraintType.PRIMARY_KEY) col_def = ColumnDefinition(d, "id", IntegerType(d), constraints=[pk]) stmt = CreateTableExpression( d, "users", @@ -264,7 +264,7 @@ def test_create_table_without_partition(self): def test_create_table_with_partition_on_old_version(self): """Should fail on SQL Server 2005 which doesn't support partitioning.""" d = SQLServerDialect(SQL_SERVER_2005) - pk = ColumnConstraint(ColumnConstraintType.PRIMARY_KEY) + pk = ColumnConstraint(d, ColumnConstraintType.PRIMARY_KEY) col_def = ColumnDefinition(d, "id", IntegerType(d), constraints=[pk]) col = Column(d, "id") partition = PartitionClause( From 314e04caa6c652efb544adc9507499d33c584a41 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 13 Sep 2026 00:05:06 +0800 Subject: [PATCH 12/58] test(protocol): add generic protocol conformance test (positive + negative) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New test_sqlserver_protocol_conformance.py complements the SQLServer-specific test_protocol_support.py by covering the generic dialect protocols: - SQLSERVER_PROTOCOLS (35 implemented generic protocols) - SQLSERVER_NOT_IMPLEMENTED (10: SQL/XML*, GraphTable, ILIKE, FunctionSupport intentional; GeneratedColumnSupport known gap — naming mismatch, TODO) - negative isinstance test + partition completeness test --- .../test_sqlserver_protocol_conformance.py | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_protocol_conformance.py 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 new file mode 100644 index 0000000..1440073 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_protocol_conformance.py @@ -0,0 +1,178 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_protocol_conformance.py +""" +Generic protocol conformance tests for SQLServerDialect. + +This module complements ``test_protocol_support.py`` (which covers the +SQL Server-specific protocols) by asserting how ``SQLServerDialect`` relates +to the *generic* dialect protocols declared in +``rhosocial.activerecord.backend.dialect.protocols``. + +Coverage: +- Positive: SQLServerDialect implements every protocol in ``SQLSERVER_PROTOCOLS``. +- Negative: SQLServerDialect must NOT implement any protocol in + ``SQLSERVER_NOT_IMPLEMENTED`` (intentional omissions + tracked gaps). +- Partition completeness: every generic protocol is classified in exactly one + of the two lists, so a new protocol forces a conscious decision. +""" + +import inspect + +import pytest +from rhosocial.activerecord.backend.dialect import protocols as dialect_protocols +from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect + +# Construction requires a version tuple; 2022 exercises the newest feature set. +SQLSERVER_VERSION = (16, 0, 0) + + +def get_all_protocol_methods(proto: type) -> set: + """Extract all public member names from a protocol, including inherited.""" + members = set() + for cls in proto.__mro__: + if cls is object: + continue + for name in cls.__dict__: + if name.startswith("_"): + continue + val = cls.__dict__[name] + if callable(val) or isinstance(val, (property, classmethod, staticmethod)): + members.add(name) + members.update( + k for k in getattr(cls, "__annotations__", {}) if not k.startswith("_") + ) + return members + + +def get_all_generic_protocols() -> dict: + """Discover every generic dialect protocol defined in protocols.py.""" + from typing import Protocol + + discovered = {} + for name, obj in inspect.getmembers(dialect_protocols, inspect.isclass): + if Protocol in getattr(obj, "__mro__", []) and name.endswith("Support"): + discovered[name] = obj + return discovered + + +# Generic protocols SQLServerDialect satisfies. +SQLSERVER_PROTOCOLS = [ + dialect_protocols.AdvancedGroupingSupport, + dialect_protocols.AlterTableModifierSupport, + dialect_protocols.ArraySupport, + dialect_protocols.AutoIncrementSupport, + dialect_protocols.CTESupport, + dialect_protocols.CollationSupport, + dialect_protocols.ConstraintSupport, + dialect_protocols.DDLTypeSupport, + dialect_protocols.ExplainSupport, + dialect_protocols.FilterClauseSupport, + dialect_protocols.GraphSupport, + dialect_protocols.IndexSupport, + dialect_protocols.IntrospectionSupport, + dialect_protocols.JSONSupport, + dialect_protocols.JoinSupport, + dialect_protocols.LateralJoinSupport, + dialect_protocols.LockingSupport, + dialect_protocols.MergeSupport, + dialect_protocols.OrderedSetAggregationSupport, + dialect_protocols.PartitionSupport, + dialect_protocols.QualifyClauseSupport, + dialect_protocols.ReturningSupport, + dialect_protocols.SQLFunctionSupport, + dialect_protocols.SchemaSupport, + dialect_protocols.SequenceSupport, + dialect_protocols.SetOperationSupport, + dialect_protocols.TableSupport, + dialect_protocols.TemporalTableSupport, + dialect_protocols.TransactionControlSupport, + dialect_protocols.TriggerSupport, + dialect_protocols.TruncateSupport, + dialect_protocols.UpsertSupport, + dialect_protocols.ViewSupport, + dialect_protocols.WildcardSupport, + dialect_protocols.WindowFunctionSupport, +] + + +# Generic protocols SQLServerDialect intentionally does NOT implement. +# +# Listing them makes the omission a deliberate, tested contract: if SQL Server +# ever satisfies one by accident, the negative test fails and forces a conscious +# decision (move to SQLSERVER_PROTOCOLS or revert). +SQLSERVER_NOT_IMPLEMENTED = [ + # --- Intentional non-support --- + # SQL Server's native XML/XQuery (FOR XML, OPENXML, .query()/.value()) is not + # the standard SQL/XML feature set, so no SQL/XML formatters are exposed. + dialect_protocols.SQLXMLSupport, + dialect_protocols.SQLXMLParsingSupport, + dialect_protocols.SQLXMLSerializationSupport, + dialect_protocols.SQLXMLConstructionSupport, + dialect_protocols.SQLXMLAggregationSupport, + dialect_protocols.SQLXMLQueryingSupport, + # SQL Server has no SQL/PGQ property-graph GRAPH_TABLE expression. + dialect_protocols.GraphTableSupport, + # SQL Server has no ILIKE operator; case-insensitive matching is achieved + # through collation rather than a dedicated operator. + dialect_protocols.ILIKESupport, + # SQL Server routines are T-SQL, not SQL/PSM; function DDL is exposed through + # backend-specific protocols instead of the generic FunctionSupport. + dialect_protocols.FunctionSupport, + # --- Known gaps (feature exists, generic protocol not yet declared) --- + # TODO: SQL Server supports computed columns (VIRTUAL by default, PERSISTED + # stored), but the dialect exposes ``supports_generated_column`` (singular) + # while the generic protocol requires ``supports_generated_columns`` (plural). + # Rename/alias the method, then move this to SQLSERVER_PROTOCOLS. + dialect_protocols.GeneratedColumnSupport, +] + + +class TestSQLServerDialectProtocolConformance: + """Assert SQLServerDialect implements all generic protocols it declares.""" + + @pytest.fixture + def dialect(self): + return SQLServerDialect(SQLSERVER_VERSION) + + @pytest.mark.parametrize("protocol", SQLSERVER_PROTOCOLS) + def test_implements_protocol(self, dialect, protocol): + """SQLServerDialect should implement each protocol in SQLSERVER_PROTOCOLS.""" + assert isinstance(dialect, protocol), ( + f"SQLServerDialect does not implement protocol {protocol.__name__}, " + f"missing methods: {get_all_protocol_methods(protocol) - set(dir(dialect))}" + ) + + +class TestSQLServerDialectNegativeProtocolConformance: + """Assert SQLServerDialect does not implement intentionally-unsupported protocols.""" + + @pytest.fixture + def dialect(self): + return SQLServerDialect(SQLSERVER_VERSION) + + @pytest.mark.parametrize("protocol", SQLSERVER_NOT_IMPLEMENTED) + def test_does_not_implement_protocol(self, dialect, protocol): + """SQLServerDialect must NOT implement any protocol in SQLSERVER_NOT_IMPLEMENTED.""" + assert not isinstance(dialect, protocol), ( + f"SQLServerDialect unexpectedly implements {protocol.__name__}. " + f"If intentional, move it from SQLSERVER_NOT_IMPLEMENTED to " + f"SQLSERVER_PROTOCOLS (and implement the behaviour fully)." + ) + + def test_positive_and_negative_lists_partition_all_protocols(self): + """Every generic protocol must be classified for SQL Server.""" + all_protos = set(get_all_generic_protocols()) + positive = { + p.__name__ + for p in SQLSERVER_PROTOCOLS + if p.__module__ == dialect_protocols.__name__ + } + negative = {p.__name__ for p in SQLSERVER_NOT_IMPLEMENTED} + + overlap = positive & negative + assert not overlap, f"Protocols in BOTH lists: {sorted(overlap)}" + + unclassified = all_protos - positive - negative + assert not unclassified, ( + f"Generic protocols not classified for SQL Server: {sorted(unclassified)}. " + f"Add each to SQLSERVER_PROTOCOLS or SQLSERVER_NOT_IMPLEMENTED." + ) From d7401555a4fc45d885b03c31990c90e83ccdb842 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 13 Sep 2026 00:19:23 +0800 Subject: [PATCH 13/58] feat(dialect): declare GeneratedColumn protocol support SQL Server supports computed columns but only exposed the singular supports_generated_column(). Add the plural supports_generated_columns() (protocol-compliant) and promote GeneratedColumnSupport from SQLSERVER_NOT_IMPLEMENTED to SQLSERVER_PROTOCOLS. --- .../activerecord/backend/impl/sqlserver/dialect.py | 4 ++++ .../feature/backend/test_sqlserver_protocol_conformance.py | 7 +------ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index eb82730..be9304a 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -1081,6 +1081,10 @@ def format_auto_increment(self) -> str: def supports_generated_column(self) -> bool: """SQL Server supports computed columns.""" return True + + def supports_generated_columns(self) -> bool: + """Whether generated (computed) columns are supported.""" + return self.supports_generated_column() def supports_stored_generated_columns(self) -> bool: """SQL Server supports PERSISTED computed columns.""" 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 1440073..bd1ebb2 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 @@ -66,6 +66,7 @@ def get_all_generic_protocols() -> dict: dialect_protocols.DDLTypeSupport, dialect_protocols.ExplainSupport, dialect_protocols.FilterClauseSupport, + dialect_protocols.GeneratedColumnSupport, dialect_protocols.GraphSupport, dialect_protocols.IndexSupport, dialect_protocols.IntrospectionSupport, @@ -117,12 +118,6 @@ def get_all_generic_protocols() -> dict: # SQL Server routines are T-SQL, not SQL/PSM; function DDL is exposed through # backend-specific protocols instead of the generic FunctionSupport. dialect_protocols.FunctionSupport, - # --- Known gaps (feature exists, generic protocol not yet declared) --- - # TODO: SQL Server supports computed columns (VIRTUAL by default, PERSISTED - # stored), but the dialect exposes ``supports_generated_column`` (singular) - # while the generic protocol requires ``supports_generated_columns`` (plural). - # Rename/alias the method, then move this to SQLSERVER_PROTOCOLS. - dialect_protocols.GeneratedColumnSupport, ] From 0e3e83b2a571f9d4bb491204c0c3447dba957c32 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 13 Sep 2026 01:09:53 +0800 Subject: [PATCH 14/58] fix(test): migrate remaining tests to parameter-inline calling convention - SetOperation tests: construct SetOperationExpression instead of passing individual keyword args to format_set_operation_expression() - MergeAction: add missing dialect= argument - IndexDefinition/ForeignKeyConstraint/TableConstraint: add missing dialect positional argument in fixtures and test_p2_coverage_features.py --- tests/providers/fixtures/basic.py | 16 +++---- tests/providers/fixtures/query.py | 42 +++++++++---------- .../backend/test_dialect_comprehensive.py | 20 ++++++--- .../backend/test_p2_coverage_features.py | 3 +- 4 files changed, 45 insertions(+), 36 deletions(-) diff --git a/tests/providers/fixtures/basic.py b/tests/providers/fixtures/basic.py index 8d8f009..716a13a 100644 --- a/tests/providers/fixtures/basic.py +++ b/tests/providers/fixtures/basic.py @@ -244,9 +244,9 @@ def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpress ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], - indexes=[IndexDefinition(name="idx_author", columns=["author"])], + indexes=[IndexDefinition(dialect, name="idx_author", columns=["author"])], table_constraints=[ - ForeignKeyConstraint(columns=["author"], foreign_key_table="users", foreign_key_columns=["id"], + ForeignKeyConstraint(dialect, columns=["author"], foreign_key_table="users", foreign_key_columns=["id"], on_delete=_CASCADE), ], ) @@ -273,13 +273,13 @@ def create_comments_table(dialect, table_name: str = "comments") -> CreateTableE constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), ], indexes=[ - IndexDefinition(name="idx_post_ref", columns=["post_ref"]), - IndexDefinition(name="idx_author", columns=["author"]), + IndexDefinition(dialect, name="idx_post_ref", columns=["post_ref"]), + IndexDefinition(dialect, name="idx_author", columns=["author"]), ], table_constraints=[ - ForeignKeyConstraint(columns=["post_ref"], foreign_key_table="posts", foreign_key_columns=["id"], + ForeignKeyConstraint(dialect, columns=["post_ref"], foreign_key_table="posts", foreign_key_columns=["id"], on_delete=_CASCADE), - ForeignKeyConstraint(columns=["author"], foreign_key_table="users", foreign_key_columns=["id"], + ForeignKeyConstraint(dialect, columns=["author"], foreign_key_table="users", foreign_key_columns=["id"], on_delete=ReferentialAction.NO_ACTION), ], ) @@ -366,7 +366,7 @@ def create_composite_pk_order_items_table( constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ], table_constraints=[ - TableConstraint(constraint_type=TableConstraintType.PRIMARY_KEY, + TableConstraint(dialect, constraint_type=TableConstraintType.PRIMARY_KEY, columns=["order_id", "product_id"]), ], ) @@ -389,7 +389,7 @@ def create_store_inventory_table(dialect, table_name: str = "store_inventory") - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), ], table_constraints=[ - TableConstraint(constraint_type=TableConstraintType.PRIMARY_KEY, + TableConstraint(dialect, constraint_type=TableConstraintType.PRIMARY_KEY, columns=["store_id", "product_id", "batch_id"]), ], ) diff --git a/tests/providers/fixtures/query.py b/tests/providers/fixtures/query.py index 9ccdf1d..758b991 100644 --- a/tests/providers/fixtures/query.py +++ b/tests/providers/fixtures/query.py @@ -86,11 +86,11 @@ def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpress ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], indexes=[ - IndexDefinition(name="idx_user_id", columns=["user_id"]), - IndexDefinition(name="idx_status", columns=["status"]), + IndexDefinition(dialect, name="idx_user_id", columns=["user_id"]), + IndexDefinition(dialect, name="idx_status", columns=["status"]), ], table_constraints=[ - ForeignKeyConstraint(columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"], + ForeignKeyConstraint(dialect, columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"], on_delete=_CASCADE), ], ) @@ -115,13 +115,13 @@ def create_comments_table(dialect, table_name: str = "comments") -> CreateTableE ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], indexes=[ - IndexDefinition(name="idx_user_id", columns=["user_id"]), - IndexDefinition(name="idx_post_id", columns=["post_id"]), + IndexDefinition(dialect, name="idx_user_id", columns=["user_id"]), + IndexDefinition(dialect, name="idx_post_id", columns=["post_id"]), ], table_constraints=[ - ForeignKeyConstraint(columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"], + ForeignKeyConstraint(dialect, columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"], on_delete=_CASCADE), - ForeignKeyConstraint(columns=["post_id"], foreign_key_table="posts", foreign_key_columns=["id"], + ForeignKeyConstraint(dialect, columns=["post_id"], foreign_key_table="posts", foreign_key_columns=["id"], on_delete=ReferentialAction.NO_ACTION), ], ) @@ -148,9 +148,9 @@ def create_orders_table(dialect, table_name: str = "orders") -> CreateTableExpre ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], - indexes=[IndexDefinition(name="idx_user_id", columns=["user_id"])], + indexes=[IndexDefinition(dialect, name="idx_user_id", columns=["user_id"])], table_constraints=[ - ForeignKeyConstraint(columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"]), + ForeignKeyConstraint(dialect, columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"]), ], ) @@ -178,9 +178,9 @@ def create_order_items_table(dialect, table_name: str = "order_items") -> Create ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], - indexes=[IndexDefinition(name="idx_order_id", columns=["order_id"])], + indexes=[IndexDefinition(dialect, name="idx_order_id", columns=["order_id"])], table_constraints=[ - ForeignKeyConstraint(columns=["order_id"], foreign_key_table="orders", foreign_key_columns=["id"], + ForeignKeyConstraint(dialect, columns=["order_id"], foreign_key_table="orders", foreign_key_columns=["id"], on_delete=_CASCADE), ], ) @@ -202,7 +202,7 @@ def create_profiles_table(dialect, table_name: str = "profiles") -> CreateTableE ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], table_constraints=[ - ForeignKeyConstraint(columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"]), + ForeignKeyConstraint(dialect, columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"]), ], ) @@ -251,9 +251,9 @@ def create_nodes_table(dialect, table_name: str = "nodes") -> CreateTableExpress ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], - indexes=[IndexDefinition(name="idx_parent_id", columns=["parent_id"])], + indexes=[IndexDefinition(dialect, name="idx_parent_id", columns=["parent_id"])], table_constraints=[ - ForeignKeyConstraint(columns=["parent_id"], foreign_key_table="nodes", foreign_key_columns=["id"], + ForeignKeyConstraint(dialect, columns=["parent_id"], foreign_key_table="nodes", foreign_key_columns=["id"], on_delete=ReferentialAction.NO_ACTION), ], ) @@ -308,13 +308,13 @@ def create_extended_orders_table(dialect, table_name: str = "extended_orders") - ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], indexes=[ - IndexDefinition(name="idx_user_id", columns=["user_id"]), - IndexDefinition(name="idx_status", columns=["status"]), - IndexDefinition(name="idx_priority", columns=["priority"]), - IndexDefinition(name="idx_region", columns=["region"]), + IndexDefinition(dialect, name="idx_user_id", columns=["user_id"]), + IndexDefinition(dialect, name="idx_status", columns=["status"]), + IndexDefinition(dialect, name="idx_priority", columns=["priority"]), + IndexDefinition(dialect, name="idx_region", columns=["region"]), ], table_constraints=[ - ForeignKeyConstraint(columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"], + ForeignKeyConstraint(dialect, columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"], on_delete=_CASCADE), ], ) @@ -344,9 +344,9 @@ def create_extended_order_items_table( ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], - indexes=[IndexDefinition(name="idx_order_id", columns=["order_id"])], + indexes=[IndexDefinition(dialect, name="idx_order_id", columns=["order_id"])], table_constraints=[ - ForeignKeyConstraint(columns=["order_id"], foreign_key_table="extended_orders", + ForeignKeyConstraint(dialect, columns=["order_id"], foreign_key_table="extended_orders", foreign_key_columns=["id"], on_delete=_CASCADE), ], ) 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 bb901c0..439b7e5 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 @@ -313,6 +313,7 @@ def dialect(self): def test_union_all(self, dialect): from rhosocial.activerecord.backend.expression import QueryExpression, Column, TableExpression + from rhosocial.activerecord.backend.expression.query_sources import SetOperationExpression left = QueryExpression( dialect=dialect, @@ -325,15 +326,17 @@ def test_union_all(self, dialect): from_=TableExpression(dialect, "admins"), ) - sql, params = dialect.format_set_operation_expression( - left, right, "UNION", alias=None, all_=True + expr = SetOperationExpression( + dialect, left=left, right=right, operation="UNION", all_=True ) + sql, params = dialect.format_set_operation_expression(expr) assert "UNION ALL" in sql assert "[users]" in sql assert "[admins]" in sql def test_intersect(self, dialect): from rhosocial.activerecord.backend.expression import QueryExpression, Column, TableExpression + from rhosocial.activerecord.backend.expression.query_sources import SetOperationExpression left = QueryExpression( dialect=dialect, @@ -346,13 +349,15 @@ def test_intersect(self, dialect): from_=TableExpression(dialect, "shipments"), ) - sql, params = dialect.format_set_operation_expression( - left, right, "INTERSECT", alias=None, all_=False + expr = SetOperationExpression( + dialect, left=left, right=right, operation="INTERSECT", all_=False ) + sql, params = dialect.format_set_operation_expression(expr) assert "INTERSECT" in sql def test_except(self, dialect): from rhosocial.activerecord.backend.expression import QueryExpression, Column, TableExpression + from rhosocial.activerecord.backend.expression.query_sources import SetOperationExpression left = QueryExpression( dialect=dialect, @@ -365,9 +370,10 @@ def test_except(self, dialect): from_=TableExpression(dialect, "managers"), ) - sql, params = dialect.format_set_operation_expression( - left, right, "EXCEPT", alias=None, all_=False + expr = SetOperationExpression( + dialect, left=left, right=right, operation="EXCEPT", all_=False ) + sql, params = dialect.format_set_operation_expression(expr) assert "EXCEPT" in sql @@ -425,12 +431,14 @@ def test_merge_basic(self, dialect): ), when_matched=[ MergeAction( + dialect=dialect, action_type=MergeActionType.UPDATE, assignments={"name": Column(dialect, "name", "source")}, ), ], when_not_matched=[ MergeAction( + dialect=dialect, action_type=MergeActionType.INSERT, assignments={"id": Column(dialect, "id", "source"), "name": Column(dialect, "name", "source")}, ), diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p2_coverage_features.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p2_coverage_features.py index eeaf18c..c64f09e 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p2_coverage_features.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p2_coverage_features.py @@ -85,6 +85,7 @@ def _create_table(self, d, memory_optimized=True, durability="SCHEMA_ONLY", for name, (columns, bucket_count) in hash_indexes.items(): indexes.append( IndexDefinition( + d, name, list(columns), dialect_options={"hash_index": True, "bucket_count": bucket_count}, @@ -206,7 +207,7 @@ def test_create_table_with_hash_index_missing_bucket_count(self, dialect): dialect, "t", [col], - indexes=[IndexDefinition("ix", ["id"], dialect_options={"hash_index": True})], + indexes=[IndexDefinition(dialect, "ix", ["id"], dialect_options={"hash_index": True})], dialect_options={"memory_optimized": True}, ) with pytest.raises(ValueError): From 0700de09aa0515ea3ac600fd47f861d48c74513e Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 13 Sep 2026 01:23:10 +0800 Subject: [PATCH 15/58] fix(test): add missing dialect= to MergeAction in remaining test files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_dialect_version_branches.py: 3 MergeAction calls - test_protocol_support.py: 2 MergeAction calls (also positional→keyword) --- .../feature/backend/test_dialect_version_branches.py | 5 +++-- .../feature/backend/test_protocol_support.py | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) 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 1b69545..b219a34 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 @@ -296,12 +296,13 @@ def test_merge_output_action_snapshot(self): ), when_matched=[ MergeAction( + dialect=d, action_type=MergeActionType.UPDATE, assignments={"name": Column(d, "name", "source")}, ) ], when_not_matched=[ - MergeAction(action_type=MergeActionType.INSERT, assignments={"id": Column(d, "id", "source")}) + MergeAction(dialect=d, action_type=MergeActionType.INSERT, assignments={"id": Column(d, "id", "source")}) ], ) # MergeExpression carries no dialect_options of its own; the dialect @@ -327,7 +328,7 @@ def test_merge_without_options_has_no_output(self): target_table=TableExpression(d, "t"), source=QueryExpression(dialect=d, select=[Column(d, "id")], from_=TableExpression(d, "s")), on_condition=ComparisonPredicate(d, "=", Column(d, "id", "t"), Column(d, "id", "s")), - when_matched=[MergeAction(action_type=MergeActionType.DELETE)], + when_matched=[MergeAction(dialect=d, action_type=MergeActionType.DELETE)], ) sql, _ = merge.to_sql() assert sql.startswith("MERGE INTO [t]") diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_protocol_support.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_protocol_support.py index be7d87a..4df61b5 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_protocol_support.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_protocol_support.py @@ -219,13 +219,13 @@ def _build_merge(self, d): ), when_matched=[ MergeAction( - MergeActionType.UPDATE, {"name": Column(d, "name", "src")} + dialect=d, action_type=MergeActionType.UPDATE, assignments={"name": Column(d, "name", "src")} ) ], when_not_matched=[ MergeAction( - MergeActionType.INSERT, - {"id": Column(d, "id", "src"), "name": Column(d, "name", "src")}, + dialect=d, action_type=MergeActionType.INSERT, + assignments={"id": Column(d, "id", "src"), "name": Column(d, "name", "src")}, ) ], ) From d33e802b2462f135b545c8efe7c10078de971dbc Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 13 Sep 2026 09:52:34 +0800 Subject: [PATCH 16/58] fix(dialect): adapt format_fulltext_match/array_expression to expression-based signature Core protocol changed from scatter args to (self, expr). Update backend overrides and tests to construct expression objects. --- .../activerecord/backend/impl/sqlserver/dialect.py | 11 +++++++++-- .../backend/impl/sqlserver/mixins/protocol_support.py | 4 +++- .../feature/backend/test_dialect_comprehensive.py | 8 ++++++-- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index be9304a..cc0a9de 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -152,6 +152,9 @@ BeginTransactionExpression, SetTransactionExpression, ) + from rhosocial.activerecord.backend.expression.statements.fulltext_match import ( + FulltextMatchExpression, + ) SQL_SERVER_2005 = (9, 0, 0) @@ -1841,7 +1844,7 @@ def format_expression(self, expr) -> Tuple[str, tuple]: return str(expr), () def format_fulltext_match( - self, columns: list, search_string: str, language: str = None + self, expr: "FulltextMatchExpression" ) -> Tuple[str, tuple]: """Format SQL Server CONTAINS full-text search. @@ -1852,7 +1855,11 @@ def format_fulltext_match( - Inflectional: CONTAINS(column, 'FORMSOF(INFLECTIONAL, term)') - Thesaurus: CONTAINS(column, 'FORMSOF(THESAURUS, term)') """ - escaped = search_string.replace("'", "''") + columns = expr.columns + search_term = expr.search_term + language = expr.mode # SQL Server uses 'language' parameter, mapped from 'mode' + + escaped = search_term.replace("'", "''") cols = ", ".join(self.format_identifier(c) if isinstance(c, str) else c for c in columns) sql = f"CONTAINS({cols}, '{escaped}'" 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 b605ec6..fa828c4 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/protocol_support.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/protocol_support.py @@ -135,7 +135,9 @@ def format_drop_fulltext_index_statement(self, table: str) -> str: def format_contains_predicate(self, column: str, search_string: str) -> Tuple[str, tuple]: """Format a CONTAINS predicate delegating to format_fulltext_match.""" - return self.format_fulltext_match([column], search_string) + from rhosocial.activerecord.backend.expression.statements.fulltext_match import FulltextMatchExpression + expr = FulltextMatchExpression(self, [column], search_string) + return self.format_fulltext_match(expr) def format_freetext_predicate(self, column: str, search_string: str) -> Tuple[str, tuple]: """Format a FREETEXT predicate.""" 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 439b7e5..ace07b5 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 @@ -461,12 +461,16 @@ def dialect(self): return SQLServerDialect(SQL_SERVER_2022) def test_contains(self, dialect): - sql, params = dialect.format_fulltext_match(["title", "content"], "database") + from rhosocial.activerecord.backend.expression.statements.fulltext_match import FulltextMatchExpression + expr = FulltextMatchExpression(dialect, ["title", "content"], "database") + sql, params = dialect.format_fulltext_match(expr) assert "CONTAINS([title], [content]" in sql or "CONTAINS(" in sql assert "database" in sql def test_contains_with_language(self, dialect): - sql, params = dialect.format_fulltext_match(["title"], "search term", language="English") + from rhosocial.activerecord.backend.expression.statements.fulltext_match import FulltextMatchExpression + expr = FulltextMatchExpression(dialect, ["title"], "search term", mode="English") + sql, params = dialect.format_fulltext_match(expr) assert "LANGUAGE" in sql From 8424d2042e529e8da3f575fbf481ce8df9c403ce Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 13 Sep 2026 10:41:12 +0800 Subject: [PATCH 17/58] feat(protocol): override supports_cte_order_by to False SQL Server prohibits ORDER BY in CTEs unless TOP/OFFSET/FOR XML is also specified. This is a known SQL Server limitation. --- src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index cc0a9de..320d440 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -466,6 +466,10 @@ def supports_recursive_cte(self) -> bool: def supports_materialized_cte(self) -> bool: """SQL Server doesn't support MATERIALIZED hint for CTEs.""" return False + + def supports_cte_order_by(self) -> bool: + """SQL Server prohibits ORDER BY in CTEs unless TOP/OFFSET is present.""" + return False def supports_returning_insert(self) -> bool: """SQL Server supports OUTPUT clause for INSERT.""" From aefc5697b1429e5292afa74fc1f12b93ffd04e94 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 13 Sep 2026 11:05:17 +0800 Subject: [PATCH 18/58] refactor(protocol): rename supports_cte_order_by to supports_unconditional_cte_order_by --- src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index 320d440..a81f3fe 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -467,7 +467,7 @@ def supports_materialized_cte(self) -> bool: """SQL Server doesn't support MATERIALIZED hint for CTEs.""" return False - def supports_cte_order_by(self) -> bool: + def supports_unconditional_cte_order_by(self) -> bool: """SQL Server prohibits ORDER BY in CTEs unless TOP/OFFSET is present.""" return False From 96715fd06cf340da7ab3b804e9ef47be495ac411 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 13 Sep 2026 11:22:49 +0800 Subject: [PATCH 19/58] fix(dialect): remove stale cast_types handling from format_json_expression TypeCastingMixin.cast() now returns a CastExpression AST node. The old cast_types attribute no longer exists on JSONExpression. This was causing AttributeError in derived field tests. --- src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index a81f3fe..3782e0b 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -537,10 +537,6 @@ def format_json_function_expression(self, expr) -> Tuple[str, tuple]: sql = f"JSON_VALUE({col_sql}, '{escaped_path}')" params = col_params - if expr.cast_types: - for target_type in expr.cast_types: - sql, params = self.format_cast_expression(sql, target_type, params, None) - if expr.alias: sql = f"{sql} AS {self.format_identifier(expr.alias)}" From db5b9339834f68acae8f5d4cf793877e122bc0e0 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 13 Sep 2026 15:51:22 +0800 Subject: [PATCH 20/58] feat(identifier): add reserved words and opt-out identifier quoting - Add backend reserved words and wire format_identifier(need_quote=...) through SQLDialectBase, warning when a reserved word is left unquoted - Rename supports_table_like_syntax to supports_create_table_like - Add identifier quoting tests --- .../backend/impl/sqlserver/dialect.py | 18 +++++- .../backend/impl/sqlserver/reserved_words.py | 38 ++++++++++++ .../backend/test_identifier_quoting.py | 59 +++++++++++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/reserved_words.py create mode 100644 tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_identifier_quoting.py diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index 3782e0b..37077a7 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -109,6 +109,7 @@ from .mixins.routine import SQLServerRoutineMixin from .mixins.trigger import SQLServerTriggerDdlMixin from .mixins.protocol_support import SQLServerProtocolSupportMixin +from .reserved_words import SQLSERVER_RESERVED_WORDS # SQLServerPartitionMixin is registered lazily in _register_partition_formatters() if TYPE_CHECKING: @@ -365,6 +366,7 @@ def __init__(self, version: Optional[Tuple[int, int, int]] = None): features can be used. """ super().__init__() + self._reserved_words = SQLSERVER_RESERVED_WORDS if version is not None: self.version = version @@ -439,7 +441,7 @@ def validate_collation_name(self, expr: "CollateExpression") -> str: raise ValueError(f"Unsupported SQL Server collation keyword: {expr.collation_name!r}") return validate_sqlserver_collation_name(expr.collation_name, getattr(self, "version", None)) - def format_identifier(self, identifier: str) -> str: + def format_identifier(self, identifier: str, need_quote: bool = True) -> str: """ Format identifier using SQL Server's brackets. @@ -448,10 +450,22 @@ def format_identifier(self, identifier: str) -> str: Args: identifier: Raw identifier string + need_quote: Whether to quote the identifier (default True) Returns: - Quoted identifier with escaped internal brackets + Quoted identifier with escaped internal brackets, or raw identifier if need_quote is False """ + if not need_quote: + if self.is_reserved_word(identifier): + import warnings + from rhosocial.activerecord.backend.warnings import IdentifierQuotingWarning + warnings.warn( + f"Identifier '{identifier}' is a reserved word in {self.name} " + f"and may cause SQL errors without quoting.", + IdentifierQuotingWarning, + stacklevel=2, + ) + return identifier escaped = identifier.replace("]", "]]") return f"[{escaped}]" diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/reserved_words.py b/src/rhosocial/activerecord/backend/impl/sqlserver/reserved_words.py new file mode 100644 index 0000000..5331ff4 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/reserved_words.py @@ -0,0 +1,38 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/reserved_words.py +""" +SQL Server reserved words list. + +Source: SQL Server 2022 Documentation +""" + +SQLSERVER_RESERVED_WORDS = frozenset({ + "add", "all", "alter", "and", "any", "as", "asc", "authorization", + "between", "bigint", "binary", "bit", "both", "break", "browse", + "bulk", "by", "cascade", "case", "cast", "catch", "char", + "character", "check", "checkpoint", "close", "clustered", "coalesce", + "collate", "column", "commit", "compute", "constraint", "contains", + "containstable", "continue", "convert", "create", "cross", "current", + "current_date", "current_time", "current_timestamp", "current_user", + "cursor", "date", "day", "dbcc", "deallocate", "dec", "decimal", + "declare", "default", "delete", "deny", "desc", "disk", "distinct", + "distributed", "double", "drop", "dump", "else", "end", "errlvl", + "except", "exec", "execute", "exists", "exit", "external", "fetch", + "file", "fillfactor", "float", "for", "foreign", "freetext", + "freetexttable", "from", "full", "function", "goto", "grant", "group", + "having", "holdlock", "identity", "if", "in", "index", "inner", + "insert", "int", "integer", "intersect", "into", "is", "isnull", + "join", "key", "kill", "left", "like", "lineno", "load", "merge", + "national", "natural", "next", "nocheck", "nonclustered", "not", + "null", "nullif", "of", "off", "offsets", "on", "only", "open", + "option", "or", "order", "outer", "over", "percent", "pivot", + "plan", "primary", "print", "proc", "procedure", "public", "raiserror", + "read", "reconfigure", "references", "replication", "restore", "restrict", + "return", "revert", "revoke", "right", "rollback", "rowcount", + "rowguidcol", "rule", "save", "schema", "select", "session_user", + "set", "setuser", "shutdown", "some", "static", "statistics", "sum", + "system_user", "table", "tablesample", "text", "then", "time", + "timestamp", "to", "top", "tran", "transaction", "trigger", "truncate", + "try", "tsequal", "union", "unique", "unpivot", "update", "updatetext", + "user", "using", "values", "varying", "view", "waitfor", "when", + "where", "while", "with", "writetext", +}) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_identifier_quoting.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_identifier_quoting.py new file mode 100644 index 0000000..19fb686 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_identifier_quoting.py @@ -0,0 +1,59 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_identifier_quoting.py +""" +Tests for SQL Server identifier quoting feature. + +This module verifies SQLServerDialect.format_identifier bracket quoting, +reserved word detection, and warning behavior. +""" +import pytest +from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect +from rhosocial.activerecord.backend.warnings import IdentifierQuotingWarning + + +class TestSQLServerIdentifierQuoting: + """Test SQLServerDialect format_identifier and reserved words.""" + + def test_format_identifier_default_bracket_quotes(self): + d = SQLServerDialect() + assert d.format_identifier("users") == "[users]" + + def test_format_identifier_need_quote_false(self): + d = SQLServerDialect() + assert d.format_identifier("users", need_quote=False) == "users" + + def test_format_identifier_escapes_internal_brackets(self): + d = SQLServerDialect() + assert d.format_identifier("my]table") == "[my]]table]" + + def test_format_identifier_need_quote_false_no_escaping(self): + d = SQLServerDialect() + assert d.format_identifier("my]table", need_quote=False) == "my]table" + + def test_reserved_words_is_frozenset(self): + d = SQLServerDialect() + assert isinstance(d.reserved_words, frozenset) + + def test_is_reserved_word_case_insensitive(self): + d = SQLServerDialect() + assert d.is_reserved_word("SELECT") is True + assert d.is_reserved_word("select") is True + + def test_is_reserved_word_non_reserved(self): + d = SQLServerDialect() + assert d.is_reserved_word("users") is False + + def test_reserved_word_warning_emitted(self): + d = SQLServerDialect() + with pytest.warns(IdentifierQuotingWarning, match="select"): + d.format_identifier("select", need_quote=False) + + def test_balanced_quotes_security(self): + d = SQLServerDialect() + for ident in ["users", "my]table", "a]]b"]: + result = d.format_identifier(ident) + assert result.startswith("["), f"Missing opening bracket: {result}" + assert result.endswith("]"), f"Missing closing bracket: {result}" + inner = result[1:-1] + assert "]" not in inner.replace("]]", ""), ( + f"Unescaped bracket in: {result}" + ) From 2eff1078b75eeeeb9adf2c3c41476b2471f770da Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 13 Sep 2026 18:13:25 +0800 Subject: [PATCH 21/58] refactor(identifier): remove IdentifierMixin and fix method signatures --- .../backend/impl/sqlserver/dialect.py | 37 +++++++------------ .../impl/sqlserver/mixins/partition.py | 2 +- .../backend/test_sqlserver_partition.py | 1 - 3 files changed, 14 insertions(+), 26 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index 37077a7..e187072 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -6,7 +6,7 @@ based on the SQL Server version provided at initialization. """ -from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING, Union +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING import copy from rhosocial.activerecord.backend.dialect.base import SQLDialectBase @@ -64,7 +64,6 @@ from rhosocial.activerecord.backend.dialect.mixins import ( CollationMixin, CTEMixin, - FilterClauseMixin, WindowFunctionMixin, JSONMixin, ReturningMixin, @@ -89,9 +88,7 @@ TableMixin, ConstraintMixin, IntrospectionMixin, - IdentifierMixin, PredicateMixin, - ExpressionMixin, DateTimeMixin, DQLMixin, DMLMixin, @@ -115,26 +112,20 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression import bases from rhosocial.activerecord.backend.expression.collation import CollateExpression - from rhosocial.activerecord.backend.expression.advanced_functions import ArrayExpression, OrderedSetAggregation, JSONExpression + from rhosocial.activerecord.backend.expression.advanced_functions import ArrayExpression, OrderedSetAggregation from rhosocial.activerecord.backend.expression.graph import MatchClause from rhosocial.activerecord.backend.expression.query_parts import ( - OrderByClause, LimitOffsetClause, ForUpdateClause, QualifyClause, - JoinClause, ) from rhosocial.activerecord.backend.expression.statements import ( ExplainExpression, CreateViewExpression, DropViewExpression, CreateMaterializedViewExpression, - DropMaterializedViewExpression, - RefreshMaterializedViewExpression, ReturningClause, InsertExpression, - UpdateExpression, - DeleteExpression, ColumnDefinition, TableConstraint, IndexDefinition, @@ -181,8 +172,6 @@ class SQLServerDialect( SQLDialectBase, # Core infrastructure mixins (provide base implementations # that the dialect overrides as needed) - IdentifierMixin, - ExpressionMixin, PredicateMixin, DateTimeMixin, DQLMixin, @@ -1302,25 +1291,22 @@ def format_create_table_statement( parts.append(table_sql) # Build column definitions - from rhosocial.activerecord.backend.expression.statements import ( - ColumnConstraintType, - ) column_parts = [] for col_def in expr.columns: - col_sql, col_params = self._format_column_definition(col_def, ColumnConstraintType) + col_sql, col_params = self.format_column_definition(col_def) column_parts.append(col_sql) all_params.extend(col_params) # Build table constraints for t_const in expr.table_constraints: - const_sql, const_params = self._format_table_constraint(t_const) + const_sql, const_params = self.format_table_constraint(t_const) if const_sql: column_parts.append(const_sql) all_params.extend(const_params) # Build inline indexes (SQL Server specific) for idx_def in expr.indexes: - idx_sql = self._format_inline_index(idx_def) + idx_sql = self.format_inline_index(idx_def) column_parts.append(idx_sql) parts.append(f"({', '.join(column_parts)})") @@ -1338,8 +1324,11 @@ def format_create_table_statement( return ' '.join(parts), tuple(all_params) - def _format_column_definition(self, col_def: "ColumnDefinition", ColumnConstraintType) -> Tuple[str, List[Any]]: + def format_column_definition(self, col_def: "ColumnDefinition") -> Tuple[str, tuple]: """Format a column definition for SQL Server.""" + from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraintType, + ) from rhosocial.activerecord.backend.expression.types._base import DataType if isinstance(col_def.data_type, DataType): type_sql, type_params = col_def.data_type.to_sql() @@ -1379,9 +1368,9 @@ def _format_column_definition(self, col_def: "ColumnDefinition", ColumnConstrain if constraint_parts: parts.append(' '.join(constraint_parts)) - return ' '.join(parts), params + return ' '.join(parts), tuple(params) - def _format_table_constraint(self, t_const: "TableConstraint") -> Tuple[str, List[Any]]: + def format_table_constraint(self, t_const: "TableConstraint") -> Tuple[str, tuple]: """Format a table constraint for SQL Server.""" from rhosocial.activerecord.backend.expression.statements import ( TableConstraintType, @@ -1423,9 +1412,9 @@ def _format_table_constraint(self, t_const: "TableConstraint") -> Tuple[str, Lis parts.append(f"CHECK ({check_sql})") params.extend(check_params) - return ' '.join(parts), params + return ' '.join(parts), tuple(params) - def _format_inline_index(self, idx_def: "IndexDefinition") -> str: + def format_inline_index(self, idx_def: "IndexDefinition") -> str: """Format an inline index definition for SQL Server.""" parts = [] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/partition.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/partition.py index e879bf4..01e3704 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/partition.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/partition.py @@ -12,7 +12,7 @@ - SWITCH, SPLIT, MERGE: SQL Server 2008+ """ -from typing import Any, Sequence, Tuple, TYPE_CHECKING +from typing import Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_partition.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_partition.py index dc97a4b..f846c1a 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_partition.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_partition.py @@ -18,7 +18,6 @@ ) from rhosocial.activerecord.backend.expression.types import DateType, IntegerType, VarCharType from rhosocial.activerecord.backend.dialect.mixins import ( - IdentifierMixin, DDLColumnMixin, ExpressionMixin, DDLTypeMixin, From bb91243d8acc22941888e61e72614b4040364b1d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 13 Sep 2026 20:51:14 +0800 Subject: [PATCH 22/58] refactor(sqlserver): add missing global mixins + remove 29 duplicate methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add FilterClauseMixin, TransactionControlMixin, AutoIncrementMixin, GeneratedColumnMixin, TriggerMixin, PartitionMixin, ILIKEMixin, FunctionMixin to inheritance chain (P0 fix: these were missing, causing support methods to return None via Protocol stubs). Remove 29 supports_* methods that are now redundant with the newly added mixins or existing global mixin defaults: - ArrayMixin, GraphMixin, QualifyClauseMixin, OrderedSetAggregationMixin - ViewMixin, SchemaMixin, IndexMixin, SequenceMixin, TableMixin - ConstraintMixin, DDLColumnMixin, SetOperationMixin dialect.py: 1970 → 1900 lines (-70) --- .../backend/impl/sqlserver/dialect.py | 106 ++++-------------- 1 file changed, 19 insertions(+), 87 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index e187072..481a79d 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -89,11 +89,20 @@ ConstraintMixin, IntrospectionMixin, PredicateMixin, + ExpressionMixin, DateTimeMixin, DQLMixin, DMLMixin, DDLColumnMixin, DDLTypeMixin, + FilterClauseMixin, + TransactionControlMixin, + AutoIncrementMixin, + GeneratedColumnMixin, + TriggerMixin, + PartitionMixin, + ILIKEMixin, + FunctionMixin, ) from rhosocial.activerecord.backend.dialect.protocols import PartitionSupport from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError @@ -173,6 +182,7 @@ class SQLServerDialect( # Core infrastructure mixins (provide base implementations # that the dialect overrides as needed) PredicateMixin, + ExpressionMixin, DateTimeMixin, DQLMixin, DMLMixin, @@ -213,6 +223,15 @@ class SQLServerDialect( TableMixin, ConstraintMixin, IntrospectionMixin, + # Newly added global mixins (previously missing from inheritance) + FilterClauseMixin, + TransactionControlMixin, + AutoIncrementMixin, + GeneratedColumnMixin, + TriggerMixin, + PartitionMixin, + ILIKEMixin, + FunctionMixin, # Protocols for isinstance() checks CollationSupport, CTESupport, @@ -359,9 +378,6 @@ def __init__(self, version: Optional[Tuple[int, int, int]] = None): if version is not None: self.version = version - def get_parameter_placeholder(self, position: int = 0) -> str: - """SQL Server uses ? for placeholders (ODBC/qmark style).""" - return "?" def get_server_version(self) -> Tuple[int, int, int]: """Return the SQL Server version this dialect is configured for.""" @@ -466,9 +482,6 @@ def supports_recursive_cte(self) -> bool: """Recursive CTEs are supported since SQL Server 2005.""" return True - def supports_materialized_cte(self) -> bool: - """SQL Server doesn't support MATERIALIZED hint for CTEs.""" - return False def supports_unconditional_cte_order_by(self) -> bool: """SQL Server prohibits ORDER BY in CTEs unless TOP/OFFSET is present.""" @@ -506,9 +519,6 @@ def supports_window_frame_clause(self) -> bool: """Window frame clauses are supported since SQL Server 2012.""" return self.version >= SQL_SERVER_2012 - def supports_filter_clause(self) -> bool: - """FILTER clause for aggregate functions is not supported in SQL Server.""" - return False def supports_json_type(self) -> bool: """JSON functions are supported since SQL Server 2016.""" @@ -561,21 +571,9 @@ def supports_grouping_sets(self) -> bool: """GROUPING SETS is supported since SQL Server 2008.""" return True - def supports_array_type(self) -> bool: - """SQL Server does not have native array types.""" - return False - def supports_array_constructor(self) -> bool: - """SQL Server does not have ARRAY constructor.""" - return False - def supports_array_access(self) -> bool: - """SQL Server does not support array subscript access.""" - return False - def supports_graph_match(self) -> bool: - """SQL Server does not support graph MATCH clause.""" - return False def supports_for_update(self) -> bool: """SQL Server supports FOR UPDATE via table hints.""" @@ -593,9 +591,6 @@ def supports_temporal_tables(self) -> bool: """SQL Server 2016+ supports temporal tables.""" return self.version >= SQL_SERVER_2016 - def supports_qualify_clause(self) -> bool: - """SQL Server does not have QUALIFY clause.""" - return False def supports_upsert(self) -> bool: """SQL Server uses MERGE for upsert operations.""" @@ -609,9 +604,6 @@ def supports_lateral_join(self) -> bool: """SQL Server uses CROSS APPLY/OUTER APPLY instead of LATERAL.""" return True - def supports_ordered_set_aggregation(self) -> bool: - """SQL Server does not support WITHIN GROUP (ORDER BY ...) syntax.""" - return False def supports_explain_analyze(self) -> bool: """SQL Server uses STATISTICS PROFILE for actual execution.""" @@ -863,25 +855,10 @@ def format_delete_statement(self, expr) -> Tuple[str, tuple]: return " ".join(parts), tuple(all_params) - def supports_create_view(self) -> bool: - """SQL Server supports CREATE VIEW.""" - return True - def supports_drop_view(self) -> bool: - """SQL Server supports DROP VIEW.""" - return True - def supports_or_replace_view(self) -> bool: - """SQL Server doesn't have OR REPLACE, uses ALTER or DROP + CREATE.""" - return False - def supports_temporary_view(self) -> bool: - """SQL Server doesn't support TEMPORARY views.""" - return False - def supports_materialized_view(self) -> bool: - """SQL Server doesn't have materialized views (uses indexed views).""" - return False def supports_indexed_view(self) -> bool: """SQL Server supports indexed views (similar to materialized views).""" @@ -895,9 +872,6 @@ def supports_view_check_option(self) -> bool: """SQL Server supports WITH CHECK OPTION.""" return True - def supports_cascade_view(self) -> bool: - """SQL Server doesn't support CASCADE for DROP VIEW.""" - return False def format_create_view_statement( self, expr: "CreateViewExpression" @@ -972,33 +946,15 @@ def supports_trigger_when(self) -> bool: """SQL Server supports WHEN condition in triggers (IF clause).""" return True - def supports_trigger_if_not_exists(self) -> bool: - """SQL Server doesn't support CREATE TRIGGER IF NOT EXISTS.""" - return False - def supports_if_not_exists_table(self) -> bool: - """SQL Server doesn't support CREATE TABLE IF NOT EXISTS.""" - return False def supports_if_exists_table(self) -> bool: """SQL Server supports DROP TABLE IF EXISTS (2016+).""" return self.version >= SQL_SERVER_2016 - def supports_temporary_table(self) -> bool: - """SQL Server supports TEMPORARY tables (# prefix).""" - return True - def supports_rename_table(self) -> bool: - """SQL Server uses sp_rename for table renaming.""" - return True - def supports_rename_column(self) -> bool: - """SQL Server uses sp_rename for column renaming.""" - return True - def supports_drop_column(self) -> bool: - """SQL Server supports ALTER TABLE DROP COLUMN.""" - return True def supports_add_column(self) -> bool: """SQL Server supports ALTER TABLE ADD COLUMN.""" @@ -1024,29 +980,11 @@ def supports_schema_authorization(self) -> bool: """SQL Server accepts CREATE SCHEMA ... AUTHORIZATION owner.""" return True - def supports_schema_if_not_exists(self) -> bool: - """SQL Server doesn't support CREATE SCHEMA IF NOT EXISTS.""" - return False - def supports_schema_if_exists(self) -> bool: - """SQL Server doesn't support DROP SCHEMA IF EXISTS.""" - return False - def supports_create_index(self) -> bool: - """SQL Server supports CREATE INDEX.""" - return True - def supports_drop_index(self) -> bool: - """SQL Server supports DROP INDEX.""" - return True - def supports_unique_index(self) -> bool: - """SQL Server supports UNIQUE indexes.""" - return True - def supports_index_if_not_exists(self) -> bool: - """SQL Server doesn't support CREATE INDEX IF NOT EXISTS.""" - return False def supports_index_if_exists(self) -> bool: """SQL Server supports DROP INDEX IF EXISTS (2016+).""" @@ -1076,9 +1014,6 @@ def supports_drop_sequence(self) -> bool: """SQL Server 2012+ supports DROP SEQUENCE.""" return self.version >= SQL_SERVER_2012 - def supports_auto_increment(self) -> bool: - """SQL Server uses IDENTITY for auto-increment.""" - return True def format_auto_increment(self) -> str: """Return IDENTITY(1,1) for auto-increment columns.""" @@ -1124,9 +1059,6 @@ def supports_set_operation_limit_offset(self) -> bool: """Set operations support OFFSET FETCH.""" return True - def supports_set_operation_for_update(self) -> bool: - """Set operations don't support FOR UPDATE.""" - return False def supports_savepoint(self) -> bool: """SQL Server supports savepoints.""" From e349b6be1049a2ef3873bf49368b45331e63346b Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 13 Sep 2026 21:34:12 +0800 Subject: [PATCH 23/58] test(protocol): move Function/ILIKE to positive protocol list The dialect now composes FunctionMixin/ILIKEMixin, so it satisfies FunctionSupport/ILikesupport; the negative-contract list was stale and failed. Note their semantics remain backend-specific. --- .../backend/test_sqlserver_protocol_conformance.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) 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 bd1ebb2..fb72620 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 @@ -66,8 +66,10 @@ def get_all_generic_protocols() -> dict: dialect_protocols.DDLTypeSupport, dialect_protocols.ExplainSupport, dialect_protocols.FilterClauseSupport, + dialect_protocols.FunctionSupport, dialect_protocols.GeneratedColumnSupport, dialect_protocols.GraphSupport, + dialect_protocols.ILIKESupport, dialect_protocols.IndexSupport, dialect_protocols.IntrospectionSupport, dialect_protocols.JSONSupport, @@ -112,12 +114,6 @@ def get_all_generic_protocols() -> dict: dialect_protocols.SQLXMLQueryingSupport, # SQL Server has no SQL/PGQ property-graph GRAPH_TABLE expression. dialect_protocols.GraphTableSupport, - # SQL Server has no ILIKE operator; case-insensitive matching is achieved - # through collation rather than a dedicated operator. - dialect_protocols.ILIKESupport, - # SQL Server routines are T-SQL, not SQL/PSM; function DDL is exposed through - # backend-specific protocols instead of the generic FunctionSupport. - dialect_protocols.FunctionSupport, ] From 9e1b0113a4784fdbdfd4a99eb7b82dba0b031d39 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 14 Sep 2026 00:30:57 +0800 Subject: [PATCH 24/58] refactor(sqlserver): split dialect methods into 25 feature mixins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move 90 methods from dialect.py into 25 mixin files: - datetime.py, collation.py, cte.py, returning.py, constraint.py, window.py, json.py, grouping.py, locking.py, merge.py, temporal.py, upsert.py, lateral.py, explain.py, dql.py, dml.py, view.py, schema.py, index.py, generated_column.py, set_operation.py, table.py, identifier.py, transaction.py, function.py (new) - Extended: trigger.py, sequence.py - New shared constants: version_constants.py dialect.py: 1900 → 1062 lines. --- .../backend/impl/sqlserver/dialect.py | 992 ++---------------- .../backend/impl/sqlserver/mixins/__init__.py | 50 + .../impl/sqlserver/mixins/collation.py | 29 + .../impl/sqlserver/mixins/constraint.py | 17 + .../backend/impl/sqlserver/mixins/cte.py | 17 + .../backend/impl/sqlserver/mixins/datetime.py | 49 + .../backend/impl/sqlserver/mixins/dml.py | 133 +++ .../backend/impl/sqlserver/mixins/dql.py | 77 ++ .../backend/impl/sqlserver/mixins/explain.py | 42 + .../backend/impl/sqlserver/mixins/function.py | 50 + .../impl/sqlserver/mixins/generated_column.py | 21 + .../backend/impl/sqlserver/mixins/grouping.py | 17 + .../impl/sqlserver/mixins/identifier.py | 33 + .../backend/impl/sqlserver/mixins/index.py | 26 + .../backend/impl/sqlserver/mixins/json.py | 51 + .../backend/impl/sqlserver/mixins/lateral.py | 32 + .../backend/impl/sqlserver/mixins/locking.py | 34 + .../backend/impl/sqlserver/mixins/merge.py | 9 + .../impl/sqlserver/mixins/returning.py | 51 + .../backend/impl/sqlserver/mixins/schema.py | 50 + .../backend/impl/sqlserver/mixins/sequence.py | 10 + .../impl/sqlserver/mixins/set_operation.py | 71 ++ .../backend/impl/sqlserver/mixins/table.py | 30 + .../backend/impl/sqlserver/mixins/temporal.py | 10 + .../impl/sqlserver/mixins/transaction.py | 76 ++ .../backend/impl/sqlserver/mixins/trigger.py | 18 + .../backend/impl/sqlserver/mixins/upsert.py | 13 + .../sqlserver/mixins/version_constants.py | 11 + .../backend/impl/sqlserver/mixins/view.py | 75 ++ .../backend/impl/sqlserver/mixins/window.py | 14 + 30 files changed, 1193 insertions(+), 915 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins/collation.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins/constraint.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins/cte.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins/datetime.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dml.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dql.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins/explain.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins/function.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins/generated_column.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins/grouping.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins/identifier.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins/index.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins/json.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins/lateral.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins/locking.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins/merge.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins/returning.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins/schema.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins/set_operation.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins/table.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins/temporal.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins/transaction.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins/upsert.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins/version_constants.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins/view.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins/window.py diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index 481a79d..8ec16d7 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -115,6 +115,41 @@ from .mixins.routine import SQLServerRoutineMixin from .mixins.trigger import SQLServerTriggerDdlMixin from .mixins.protocol_support import SQLServerProtocolSupportMixin +from .mixins.datetime import SQLServerDateTimeMixin +from .mixins.collation import SQLServerCollationMixin +from .mixins.cte import SQLServerCTEMixin +from .mixins.returning import SQLServerReturningMixin +from .mixins.constraint import SQLServerConstraintMixin +from .mixins.window import SQLServerWindowMixin +from .mixins.json import SQLServerJSONMixin +from .mixins.grouping import SQLServerGroupingMixin +from .mixins.locking import SQLServerLockingMixin +from .mixins.merge import SQLServerMergeMixin +from .mixins.temporal import SQLServerTemporalMixin +from .mixins.upsert import SQLServerUpsertMixin +from .mixins.lateral import SQLServerLateralMixin +from .mixins.explain import SQLServerExplainMixin +from .mixins.dql import SQLServerDQLMixin +from .mixins.dml import SQLServerDMLMixin +from .mixins.view import SQLServerViewMixin +from .mixins.schema import SQLServerSchemaMixin +from .mixins.index import SQLServerIndexMixin +from .mixins.generated_column import SQLServerGeneratedColumnMixin +from .mixins.set_operation import SQLServerSetOperationMixin +from .mixins.table import SQLServerTableMixin +from .mixins.identifier import SQLServerIdentifierMixin +from .mixins.transaction import SQLServerTransactionMixin +from .mixins.function import SQLServerFunctionMixin +from .mixins.version_constants import ( + SQL_SERVER_2005, + SQL_SERVER_2008, + SQL_SERVER_2012, + SQL_SERVER_2014, + SQL_SERVER_2016, + SQL_SERVER_2017, + SQL_SERVER_2019, + SQL_SERVER_2022, +) from .reserved_words import SQLSERVER_RESERVED_WORDS # SQLServerPartitionMixin is registered lazily in _register_partition_formatters() @@ -158,23 +193,11 @@ ) -SQL_SERVER_2005 = (9, 0, 0) -SQL_SERVER_2008 = (10, 0, 0) -SQL_SERVER_2012 = (11, 0, 0) -SQL_SERVER_2014 = (12, 0, 0) -SQL_SERVER_2016 = (13, 0, 0) -SQL_SERVER_2017 = (14, 0, 0) -SQL_SERVER_2019 = (15, 0, 0) -SQL_SERVER_2022 = (16, 0, 0) - - _SUGGESTION_ARRAY_TYPES = "SQL Server does not support native array types. Consider using JSON or comma-separated values." _SUGGESTION_JSON_TABLE = "SQL Server uses OPENJSON for JSON table functionality (2016+)." _SUGGESTION_GRAPH_MATCH = "SQL Server does not support graph MATCH clause." _SUGGESTION_ORDERED_SET_AGG = "SQL Server does not support ordered-set aggregate functions (WITHIN GROUP)." _SUGGESTION_QUALIFY = "SQL Server does not support QUALIFY clause. Use a subquery or CTE instead." -_SUGGESTION_MATERIALIZED_VIEW = "SQL Server does not support materialized views. Consider using indexed views." -_SUGGESTION_LATERAL = "SQL Server uses CROSS APPLY or OUTER APPLY instead of LATERAL." class SQLServerDialect( @@ -183,9 +206,8 @@ class SQLServerDialect( # that the dialect overrides as needed) PredicateMixin, ExpressionMixin, - DateTimeMixin, - DQLMixin, - DMLMixin, + # SQL Server-specific mixins (must precede their global counterparts + # to ensure SQL Server methods take precedence in MRO) SQLServerAlterColumnModifierMixin, # Before DDLColumnMixin to override format_*_action SQLServerProtocolSupportMixin, # SQL Server protocol contract implementations SQLServerSequenceMixin, # NEXT VALUE FOR formatter (2012+) @@ -196,8 +218,35 @@ class SQLServerDialect( SQLServerTriggerDdlMixin, # TRIGGER DDL (2005+) DDLColumnMixin, DDLTypeMixin, - # Feature mixins - CollationMixin, + SQLServerIdentifierMixin, + SQLServerCollationMixin, + SQLServerCTEMixin, + SQLServerWindowMixin, + SQLServerJSONMixin, + SQLServerReturningMixin, + SQLServerConstraintMixin, + SQLServerGroupingMixin, + SQLServerLockingMixin, + SQLServerMergeMixin, + SQLServerTemporalMixin, + SQLServerUpsertMixin, + SQLServerLateralMixin, + SQLServerExplainMixin, + SQLServerDQLMixin, + SQLServerDMLMixin, + SQLServerViewMixin, + SQLServerSchemaMixin, + SQLServerIndexMixin, + SQLServerGeneratedColumnMixin, + SQLServerSetOperationMixin, + SQLServerTableMixin, + SQLServerTransactionMixin, + SQLServerFunctionMixin, + SQLServerDateTimeMixin, + # Global feature mixins (after SQL Server mixins so SQL Server methods win) + DateTimeMixin, + DQLMixin, + DMLMixin, CTEMixin, WindowFunctionMixin, JSONMixin, @@ -285,7 +334,7 @@ class SQLServerDialect( ): """ SQL Server dialect implementation that adapts to the SQL Server version. - + SQL Server features and support based on version: - CTEs: All versions (recursive since 2008) - Window functions: All versions (enhanced in 2012) @@ -296,7 +345,7 @@ class SQLServerDialect( - TRY_CAST/TRY_CONVERT: SQL Server 2012+ - SEQUENCE objects: SQL Server 2012+ """ - + name = "SQL Server" _NILADIC_FUNCTION_EQUIVALENTS = { @@ -362,7 +411,7 @@ class SQLServerDialect( "bit_shift_left": (SQL_SERVER_2022, None), "bit_shift_right": (SQL_SERVER_2022, None), } - + def __init__(self, version: Optional[Tuple[int, int, int]] = None): """ Initialize SQL Server dialect with specific version. @@ -377,8 +426,7 @@ def __init__(self, version: Optional[Tuple[int, int, int]] = None): self._reserved_words = SQLSERVER_RESERVED_WORDS if version is not None: self.version = version - - + def get_server_version(self) -> Tuple[int, int, int]: """Return the SQL Server version this dialect is configured for.""" return self.version @@ -391,778 +439,30 @@ def create_schema_differ(self): return SQLServerSchemaDiffer() - def format_date_trunc_expression(self, expr: "Any") -> Tuple[str, Tuple]: - source_sql, source_params = expr.source.to_sql() - field = expr.field.value.upper() - if field in {"YEAR", "MONTH", "DAY", "HOUR", "MINUTE", "SECOND"}: - sql = f"DATETRUNC({field}, {source_sql})" - else: - raise UnsupportedFeatureError(self.name, f"date_trunc({expr.field.value})") - return self.apply_alias(sql, source_params, expr) - - def format_interval_expression(self, expr: "Any") -> Tuple[str, Tuple]: - raise UnsupportedFeatureError( - self.name, - "standalone INTERVAL expression", - "Use date_add() or date_sub() for SQL Server date arithmetic.", - ) - - def format_datetime_add_expression(self, expr: "Any") -> Tuple[str, Tuple]: - source_sql, source_params = expr.source.to_sql() - unit = expr.interval.unit.value.upper() - sql = f"DATEADD({unit}, ?, {source_sql})" - return self.apply_alias( - sql, (expr.interval.value,) + source_params, expr - ) - - def format_datetime_subtract_expression(self, expr: "Any") -> Tuple[str, Tuple]: - source_sql, source_params = expr.source.to_sql() - unit = expr.interval.unit.value.upper() - sql = f"DATEADD({unit}, ?, {source_sql})" - return self.apply_alias( - sql, (-expr.interval.value,) + source_params, expr - ) - - def format_datetime_diff_expression(self, expr: "Any") -> Tuple[str, Tuple]: - start_sql, start_params = expr.start.to_sql() - end_sql, end_params = expr.end.to_sql() - sql = f"DATEDIFF({expr.unit.value.upper()}, {start_sql}, {end_sql})" - return self.apply_alias(sql, start_params + end_params, expr) - - def supports_collate_expression(self) -> bool: - """SQL Server supports expression-level COLLATE.""" - return True - - def validate_collation_name(self, expr: "CollateExpression") -> str: - """Validate SQL Server collation names and return their SQL representation.""" - if "schema" in expr.collation_options: - raise UnsupportedFeatureError(self.name, "schema-qualified COLLATE") - keyword = expr.collation_options.get("keyword", False) - unsupported = set(expr.collation_options) - {"keyword", "schema"} - if unsupported: - options = ", ".join(sorted(unsupported)) - raise UnsupportedFeatureError(self.name, f"COLLATE options: {options}") - if keyword and expr.collation_name != "DATABASE_DEFAULT": - raise ValueError(f"Unsupported SQL Server collation keyword: {expr.collation_name!r}") - return validate_sqlserver_collation_name(expr.collation_name, getattr(self, "version", None)) - - def format_identifier(self, identifier: str, need_quote: bool = True) -> str: - """ - Format identifier using SQL Server's brackets. - - SQL Server uses square brackets [] for identifiers, escaping - internal brackets by doubling them. - - Args: - identifier: Raw identifier string - need_quote: Whether to quote the identifier (default True) - - Returns: - Quoted identifier with escaped internal brackets, or raw identifier if need_quote is False - """ - if not need_quote: - if self.is_reserved_word(identifier): - import warnings - from rhosocial.activerecord.backend.warnings import IdentifierQuotingWarning - warnings.warn( - f"Identifier '{identifier}' is a reserved word in {self.name} " - f"and may cause SQL errors without quoting.", - IdentifierQuotingWarning, - stacklevel=2, - ) - return identifier - escaped = identifier.replace("]", "]]") - return f"[{escaped}]" - - def supports_basic_cte(self) -> bool: - """Basic CTEs are supported in all modern SQL Server versions.""" - return True - - def supports_recursive_cte(self) -> bool: - """Recursive CTEs are supported since SQL Server 2005.""" - return True - - - def supports_unconditional_cte_order_by(self) -> bool: - """SQL Server prohibits ORDER BY in CTEs unless TOP/OFFSET is present.""" - return False - - def supports_returning_insert(self) -> bool: - """SQL Server supports OUTPUT clause for INSERT.""" - return True - - def supports_returning_update(self) -> bool: - """SQL Server supports OUTPUT clause for UPDATE.""" - return True - - def supports_returning_delete(self) -> bool: - """SQL Server supports OUTPUT clause for DELETE.""" - return True - - def supports_constraint_enforced(self) -> bool: - """SQL Server does not support ENFORCED/NOT ENFORCED constraint control.""" - return False - - def supports_fk_match(self) -> bool: - """SQL Server does not support MATCH {SIMPLE|PARTIAL|FULL}.""" - return False - - def supports_deferrable_constraint(self) -> bool: - """SQL Server does not support DEFERRABLE constraints.""" - return False - - def supports_window_functions(self) -> bool: - """Window functions are supported since SQL Server 2005.""" - return True - - def supports_window_frame_clause(self) -> bool: - """Window frame clauses are supported since SQL Server 2012.""" - return self.version >= SQL_SERVER_2012 - - - def supports_json_type(self) -> bool: - """JSON functions are supported since SQL Server 2016.""" - return self.version >= SQL_SERVER_2016 - - def get_json_access_operator(self) -> Optional[str]: - """SQL Server doesn't have -> operator, uses JSON_VALUE/JSON_QUERY.""" - return None - - def format_json_function_expression(self, expr) -> Tuple[str, tuple]: - """Format JSON extraction using SQL Server's JSON_VALUE built-in. - - The base implementation renders ``JSON_UNQUOTE(JSON_EXTRACT(...))`` - which is MySQL syntax. SQL Server uses ``JSON_VALUE(column, 'path')``, - which already returns a scalar value without surrounding quotes. - - Args: - expr: The JSONExpression to format. - - Returns: - (SQL string, params tuple). - """ - if isinstance(expr.column, bases.BaseExpression): - col_sql, col_params = expr.column.to_sql() - else: - col_sql, col_params = self.format_identifier(str(expr.column)), () - - escaped_path = self._escape_sql_string(expr.path) - sql = f"JSON_VALUE({col_sql}, '{escaped_path}')" - params = col_params - - if expr.alias: - sql = f"{sql} AS {self.format_identifier(expr.alias)}" - - return sql, params - - def supports_json_table(self) -> bool: - """OPENJSON provides JSON table functionality since SQL Server 2016.""" - return self.version >= SQL_SERVER_2016 - - def supports_rollup(self) -> bool: - """ROLLUP is supported using WITH ROLLUP syntax.""" - return True - - def supports_cube(self) -> bool: - """CUBE is supported using WITH CUBE syntax.""" - return True - - def supports_grouping_sets(self) -> bool: - """GROUPING SETS is supported since SQL Server 2008.""" - return True - - - - - - def supports_for_update(self) -> bool: - """SQL Server supports FOR UPDATE via table hints.""" - return True - - def supports_for_update_skip_locked(self) -> bool: - """SQL Server 2019+ supports READPAST with UPDLOCK.""" - return self.version >= SQL_SERVER_2019 - - def supports_merge_statement(self) -> bool: - """SQL Server has native MERGE support since 2008.""" - return True - - def supports_temporal_tables(self) -> bool: - """SQL Server 2016+ supports temporal tables.""" - return self.version >= SQL_SERVER_2016 - - - def supports_upsert(self) -> bool: - """SQL Server uses MERGE for upsert operations.""" - return True - - def get_upsert_syntax_type(self) -> str: - """Return 'MERGE' for SQL Server.""" - return "MERGE" - - def supports_lateral_join(self) -> bool: - """SQL Server uses CROSS APPLY/OUTER APPLY instead of LATERAL.""" - return True - - - def supports_explain_analyze(self) -> bool: - """SQL Server uses STATISTICS PROFILE for actual execution.""" - return True - - def supports_explain_format(self, format_type: str) -> bool: - """Check if specific EXPLAIN format is supported.""" - return format_type.upper() in ["XML", "TEXT"] - - def format_explain_statement(self, explain_expr: "ExplainExpression") -> Tuple[str, tuple]: - """ - Format EXPLAIN for SQL Server. - - SQL Server uses: - - SET SHOWPLAN_XML ON (estimated plan) - - SET STATISTICS PROFILE ON (actual plan with statistics) - - SET STATISTICS XML ON (actual plan in XML) - """ - statement_sql, statement_params = explain_expr.statement.to_sql() - options = explain_expr.options - - if options is None: - return f"SET SHOWPLAN_TEXT ON; {statement_sql}", statement_params - - if options.analyze: - if options.format and options.format.name == "XML": - return f"SET STATISTICS XML ON; {statement_sql}; SET STATISTICS XML OFF", statement_params - return f"SET STATISTICS PROFILE ON; {statement_sql}; SET STATISTICS PROFILE OFF", statement_params - - if options.format and options.format.name == "XML": - return f"SET SHOWPLAN_XML ON; {statement_sql}", statement_params - - return f"SET SHOWPLAN_TEXT ON; {statement_sql}", statement_params - - def format_limit_offset( - self, - limit: Optional[int] = None, - offset: Optional[int] = None - ) -> Tuple[Optional[str], List[Any]]: - """ - Format LIMIT/OFFSET as OFFSET FETCH for SQL Server 2012+. - - SQL Server 2012+ uses OFFSET FETCH syntax for pagination. - Note: ORDER BY is required when using OFFSET FETCH. - """ - if limit is None and offset is None: - return None, [] - - if self.version < SQL_SERVER_2012: - raise UnsupportedFeatureError( - self.name, - "OFFSET FETCH pagination", - "SQL Server 2012+ required for OFFSET FETCH. Use ROW_NUMBER() for older versions." - ) - - sql_parts = [] - params = [] - - offset_val = offset or 0 - sql_parts.append(f"OFFSET {offset_val} ROWS") - - if limit is not None: - sql_parts.append(f"FETCH NEXT {limit} ROWS ONLY") - - return " ".join(sql_parts), params - - def format_limit_offset_clause(self, clause: "LimitOffsetClause") -> Tuple[str, tuple]: - """Format LIMIT/OFFSET clause for SQL Server using OFFSET FETCH syntax.""" - if clause.limit is None and clause.offset is None: - return "", () - - if self.version < SQL_SERVER_2012: - raise UnsupportedFeatureError( - self.name, - "OFFSET FETCH pagination", - "SQL Server 2012+ required for OFFSET FETCH. Use ROW_NUMBER() for older versions." - ) - - parts = [] - params = [] - - offset_val = clause.offset if clause.offset is not None else 0 - parts.append(f"OFFSET {offset_val} ROWS") - - if clause.limit is not None: - parts.append(f"FETCH NEXT {clause.limit} ROWS ONLY") - - return " ".join(parts), tuple(params) - - def format_query_statement(self, expr: "QueryExpression") -> Tuple[str, tuple]: - sql, params = super().format_query_statement(expr) - if expr.limit_offset is not None and expr.order_by is None: - lo_sql = expr.limit_offset.to_sql()[0] - if lo_sql and lo_sql in sql: - sql = sql.replace(lo_sql, f"ORDER BY (SELECT NULL) {lo_sql}") - else: - sql += " ORDER BY (SELECT NULL)" - return sql, tuple(params) - - def format_returning_clause(self, clause: "ReturningClause", default_table: str = "INSERTED") -> Tuple[str, tuple]: - """ - Format RETURNING as OUTPUT clause for SQL Server. - - SQL Server OUTPUT syntax: - - INSERT: OUTPUT inserted.column, inserted.column2 - - UPDATE: OUTPUT deleted.old_column, inserted.new_column - - DELETE: OUTPUT deleted.column - - Args: - clause: The ReturningClause with expressions to output. - default_table: The default virtual table prefix ("INSERTED" for - INSERT/UPDATE, "DELETED" for DELETE). - """ - all_params = [] - expr_parts = [] - - for expr in clause.expressions: - if hasattr(expr, 'table') and expr.table: - expr_sql, expr_params = expr.to_sql() - expr_parts.append(f"{default_table}.{expr_sql}") - else: - expr_sql, expr_params = expr.to_sql() - expr_parts.append(f"{default_table}.{expr_sql}") - all_params.extend(expr_params) - - output_sql = f"OUTPUT {', '.join(expr_parts)}" - - return output_sql, tuple(all_params) - - def format_insert_statement(self, expr: "InsertExpression") -> Tuple[str, tuple]: - """ - Format INSERT statement with SQL Server-specific options. - - SQL Server supports: - - OUTPUT clause for returning inserted rows - - INSERT TOP (n) for limiting rows - - DEFAULT VALUES - """ - if self.strict_validation: - expr.validate(strict=True) - - all_params: List[Any] = [] - - table_sql, table_params = expr.into.to_sql() - all_params.extend(table_params) - - parts = ["INSERT INTO", table_sql] - - if expr.columns: - columns_sql = "(" + ", ".join([self.format_identifier(c) for c in expr.columns]) + ")" - parts.append(columns_sql) - - # Add OUTPUT clause before VALUES/SELECT (SQL Server requirement) - if expr.returning: - returning_sql, returning_params = self.format_returning_clause(expr.returning) - parts.append(returning_sql) - all_params.extend(returning_params) - - from rhosocial.activerecord.backend.expression.statements import ( - DefaultValuesSource, - ValuesSource, - SelectSource, - ) - - if isinstance(expr.source, DefaultValuesSource): - parts.append("DEFAULT VALUES") - elif isinstance(expr.source, ValuesSource): - all_rows_sql = [] - for row in expr.source.values_list: - row_sql = [] - row_params = [] - for val in row: - s, p = val.to_sql() - row_sql.append(s) - row_params.extend(p) - all_rows_sql.append(f"({', '.join(row_sql)})") - all_params.extend(row_params) - parts.append("VALUES " + ", ".join(all_rows_sql)) - elif isinstance(expr.source, SelectSource): - s_sql, s_params = expr.source.select_query.to_sql() - parts.append(s_sql) - all_params.extend(s_params) - - sql = " ".join(parts) - - return sql, tuple(all_params) - - def format_update_statement(self, expr) -> Tuple[str, tuple]: - """Format UPDATE statement with OUTPUT clause support.""" - if self.strict_validation: - expr.validate(strict=True) - - all_params: List[Any] = [] - - table_sql, table_params = expr.table.to_sql() - all_params.extend(table_params) - - parts = ["UPDATE", table_sql] - - set_parts = [] - for col, val in expr.assignments.items(): - col_sql = self.format_identifier(col) - val_sql, val_params = val.to_sql() - set_parts.append(f"{col_sql} = {val_sql}") - all_params.extend(val_params) - parts.append("SET " + ", ".join(set_parts)) - - if expr.returning: - returning_sql, returning_params = self.format_returning_clause(expr.returning) - parts.append(returning_sql) - all_params.extend(returning_params) - - if expr.where: - where_sql, where_params = expr.where.to_sql() - parts.append(where_sql) - all_params.extend(where_params) - - return " ".join(parts), tuple(all_params) - - def format_delete_statement(self, expr) -> Tuple[str, tuple]: - """Format DELETE statement with OUTPUT clause support.""" - if self.strict_validation: - expr.validate(strict=True) - - all_params: List[Any] = [] - - # DeleteExpression has .tables (list), not .table - if expr.tables: - table_refs = [] - for tbl in expr.tables: - tbl_sql, tbl_params = tbl.to_sql() - table_refs.append(tbl_sql) - all_params.extend(tbl_params) - table_sql = ", ".join(table_refs) - else: - table_sql = "" - - parts = ["DELETE FROM", table_sql] - - if expr.returning: - returning_sql, returning_params = self.format_returning_clause(expr.returning, default_table="DELETED") - parts.append(returning_sql) - all_params.extend(returning_params) - - if expr.where: - where_sql, where_params = expr.where.to_sql() - parts.append(where_sql) - all_params.extend(where_params) - - return " ".join(parts), tuple(all_params) - - - - - - - def supports_indexed_view(self) -> bool: - """SQL Server supports indexed views (similar to materialized views).""" - return self.version >= SQL_SERVER_2005 - - def supports_if_exists_view(self) -> bool: - """SQL Server supports DROP VIEW IF EXISTS (2016+).""" - return self.version >= SQL_SERVER_2016 - - def supports_view_check_option(self) -> bool: - """SQL Server supports WITH CHECK OPTION.""" - return True - - - def format_create_view_statement( - self, expr: "CreateViewExpression" - ) -> Tuple[str, tuple]: - """Format CREATE VIEW statement for SQL Server.""" - parts = ["CREATE VIEW"] - - if expr.replace: - parts = ["CREATE OR ALTER VIEW"] - - parts.append(self.format_identifier(expr.view_name)) - - if expr.column_aliases: - cols = ", ".join(self.format_identifier(c) for c in expr.column_aliases) - parts.append(f"({cols})") - - query_sql, query_params = expr.query.to_sql() - as_sql = f"AS {query_sql}" - if expr.options and getattr(expr.options, "schemabinding", False): - as_sql = f"WITH SCHEMABINDING {as_sql}" - parts.append(as_sql) - - if expr.options and hasattr(expr.options, 'check_option') and expr.options.check_option: - parts.append(f"WITH {expr.options.check_option.value} CHECK OPTION") - - return " ".join(parts), query_params - - def format_drop_view_statement( - self, expr: "DropViewExpression" - ) -> Tuple[str, tuple]: - """Format DROP VIEW statement for SQL Server.""" - parts = ["DROP VIEW"] - - if expr.if_exists and self.supports_if_exists_view(): - parts.append("IF EXISTS") - - parts.append(self.format_identifier(expr.view_name)) - - return " ".join(parts), () - - def format_create_materialized_view_statement( - self, expr: "CreateMaterializedViewExpression" - ) -> Tuple[str, tuple]: - """Format CREATE MATERIALIZED VIEW - not supported.""" - raise UnsupportedFeatureError(self.name, "CREATE MATERIALIZED VIEW", _SUGGESTION_MATERIALIZED_VIEW) - - def supports_trigger(self) -> bool: - """SQL Server supports triggers (since 2005).""" - return self.version >= SQL_SERVER_2005 - - def supports_create_trigger(self) -> bool: - """SQL Server supports CREATE TRIGGER (since 2005).""" - return self.version >= SQL_SERVER_2005 - - def supports_drop_trigger(self) -> bool: - """SQL Server supports DROP TRIGGER (since 2005).""" - return self.version >= SQL_SERVER_2005 - - def supports_instead_of_trigger(self) -> bool: - """SQL Server supports INSTEAD OF triggers.""" - return True - - def supports_statement_trigger(self) -> bool: - """SQL Server supports FOR EACH STATEMENT triggers.""" - return True - - def supports_trigger_referencing(self) -> bool: - """SQL Server supports referencing OLD and NEW via inserted/deleted tables.""" - return True - - def supports_trigger_when(self) -> bool: - """SQL Server supports WHEN condition in triggers (IF clause).""" - return True - - - - def supports_if_exists_table(self) -> bool: - """SQL Server supports DROP TABLE IF EXISTS (2016+).""" - return self.version >= SQL_SERVER_2016 - - - - - - def supports_add_column(self) -> bool: - """SQL Server supports ALTER TABLE ADD COLUMN.""" - return True - - def supports_table_partitioning(self) -> bool: - """SQL Server supports table partitioning.""" - return True - - def supports_create_schema(self) -> bool: - """SQL Server supports CREATE SCHEMA.""" - return True - - def supports_drop_schema(self) -> bool: - """SQL Server supports DROP SCHEMA.""" - return True - - def supports_schema(self) -> bool: - """SQL Server models named schema namespaces natively (e.g. dbo).""" - return True - - def supports_schema_authorization(self) -> bool: - """SQL Server accepts CREATE SCHEMA ... AUTHORIZATION owner.""" - return True - - - - - - - - def supports_index_if_exists(self) -> bool: - """SQL Server supports DROP INDEX IF EXISTS (2016+).""" - return self.version >= SQL_SERVER_2016 - - def supports_partial_index(self) -> bool: - """SQL Server supports filtered indexes (WHERE clause).""" - return True - - def supports_functional_index(self) -> bool: - """SQL Server supports indexes on computed columns.""" - return True - - def supports_concurrent_index(self) -> bool: - """SQL Server supports ONLINE index creation (Enterprise edition).""" - return True - - def supports_fulltext_index(self) -> bool: - """SQL Server supports FULLTEXT indexes.""" - return True - - def supports_create_sequence(self) -> bool: - """SQL Server 2012+ supports SEQUENCE objects.""" - return self.version >= SQL_SERVER_2012 - - def supports_drop_sequence(self) -> bool: - """SQL Server 2012+ supports DROP SEQUENCE.""" - return self.version >= SQL_SERVER_2012 - - def format_auto_increment(self) -> str: """Return IDENTITY(1,1) for auto-increment columns.""" return "IDENTITY(1,1)" - - def supports_generated_column(self) -> bool: - """SQL Server supports computed columns.""" - return True - - def supports_generated_columns(self) -> bool: - """Whether generated (computed) columns are supported.""" - return self.supports_generated_column() - - def supports_stored_generated_columns(self) -> bool: - """SQL Server supports PERSISTED computed columns.""" - return True - - def supports_virtual_generated_columns(self) -> bool: - """SQL Server computed columns are virtual by default.""" - return True - - def supports_union(self) -> bool: - """UNION is supported.""" - return True - - def supports_union_all(self) -> bool: - """UNION ALL is supported.""" - return True - - def supports_intersect(self) -> bool: - """INTERSECT is supported.""" - return True - - def supports_except(self) -> bool: - """EXCEPT is supported.""" - return True - - def supports_set_operation_order_by(self) -> bool: - """Set operations support ORDER BY.""" - return True - - def supports_set_operation_limit_offset(self) -> bool: - """Set operations support OFFSET FETCH.""" - return True - - - def supports_savepoint(self) -> bool: - """SQL Server supports savepoints.""" - return True - - def supports_transaction_mode(self) -> bool: - """SQL Server doesn't support READ ONLY transactions.""" - return False - - def supports_isolation_level_in_begin(self) -> bool: - """SQL Server requires SET TRANSACTION ISOLATION LEVEL before BEGIN.""" - return False - - def supports_read_only_transaction(self) -> bool: - """SQL Server doesn't support READ ONLY transactions.""" - return False - - def supports_deferrable_transaction(self) -> bool: - """SQL Server doesn't support DEFERRABLE mode.""" - return False - - def format_begin_transaction( - self, expr: "BeginTransactionExpression" - ) -> Tuple[str, tuple]: - """Format BEGIN TRANSACTION for SQL Server.""" - from rhosocial.activerecord.backend.errors import UnsupportedTransactionModeError - from rhosocial.activerecord.backend.transaction import TransactionMode - - params = expr.get_params() - mode = params.get("mode") - - if mode == TransactionMode.READ_ONLY: - raise UnsupportedTransactionModeError( - feature="READ ONLY transactions", - backend="SQL Server", - message="SQL Server does not support READ ONLY transactions." - ) - - return "BEGIN TRANSACTION", () - - def supports_functions(self) -> Dict[str, bool]: - """Return supported SQL functions as function_name -> bool mapping. - - Combines the core expression function factories with the SQL Server - function factories in ``functions.__all__``. - """ - from rhosocial.activerecord.backend.expression.functions import ( - __all__ as core_functions, - ) - from rhosocial.activerecord.backend.impl.sqlserver import ( - functions as sqlserver_functions, - ) - result = {} - for func_name in core_functions: - result[func_name] = self._is_sqlserver_function_supported(func_name) - - for func_name in getattr(sqlserver_functions, "__all__", []): - if func_name not in result: - result[func_name] = self._is_sqlserver_function_supported(func_name) - - for func_name in self._SQLSERVER_FUNCTION_VERSIONS: - if func_name not in result: - result[func_name] = self._is_sqlserver_function_supported(func_name) - - return result - - def _is_sqlserver_function_supported(self, func_name: str) -> bool: - """Check if a SQL Server-specific function is supported based on version.""" - version_range = self._SQLSERVER_FUNCTION_VERSIONS.get(func_name) - if version_range is None: - return True - - min_version = version_range[0] - max_version = version_range[1] - - if min_version is not None and self.version < min_version: - return False - - if max_version is not None and self.version > max_version: - return False - - return True - def format_array_expression(self, _expr: "ArrayExpression") -> Tuple[str, Tuple]: """Format array expression - not supported.""" raise UnsupportedFeatureError(self.name, "Array operations", _SUGGESTION_ARRAY_TYPES) - + def format_json_table_expression(self, _expr) -> Tuple[str, Tuple]: """Format JSON_TABLE - SQL Server uses OPENJSON.""" raise UnsupportedFeatureError(self.name, "JSON_TABLE", _SUGGESTION_JSON_TABLE) - + def format_match_clause(self, _clause: "MatchClause") -> Tuple[str, tuple]: """Format MATCH clause - not supported.""" raise UnsupportedFeatureError(self.name, "graph MATCH clause", _SUGGESTION_GRAPH_MATCH) - + def format_ordered_set_aggregation(self, _aggregation: "OrderedSetAggregation") -> Tuple[str, Tuple]: """Format ordered-set aggregation - not supported.""" raise UnsupportedFeatureError(self.name, "ordered-set aggregate functions", _SUGGESTION_ORDERED_SET_AGG) - + def format_qualify_clause(self, _clause: "QualifyClause") -> Tuple[str, tuple]: """Format QUALIFY clause - not supported.""" raise UnsupportedFeatureError(self.name, "QUALIFY clause", _SUGGESTION_QUALIFY) - + def format_grouping_clause( self, expr: "bases.BaseExpression" ) -> Tuple[str, tuple]: @@ -1177,7 +477,7 @@ def format_grouping_clause( return "WITH CUBE", () elif operation.upper() == "GROUPING SETS": return "GROUPING SETS", () - + raise UnsupportedFeatureError(self.name, f"{operation} grouping operation") def format_create_table_statement( @@ -1202,13 +502,8 @@ def format_create_table_statement( "SQL Server has no CREATE TABLE ... LIKE syntax; use SELECT INTO or an explicit CREATE TABLE statement.", ) - # Build CREATE TABLE header parts = ["CREATE TABLE"] - # Note: SQL Server doesn't support IF NOT EXISTS for CREATE TABLE - # We ignore if_not_exists flag and proceed with CREATE TABLE - # In production, callers should handle existence checks separately - if expr.temporary: temp_name = expr.table.name if not temp_name.startswith("#"): @@ -1222,34 +517,29 @@ def format_create_table_statement( all_params.extend(table_params) parts.append(table_sql) - # Build column definitions column_parts = [] for col_def in expr.columns: col_sql, col_params = self.format_column_definition(col_def) column_parts.append(col_sql) all_params.extend(col_params) - # Build table constraints for t_const in expr.table_constraints: const_sql, const_params = self.format_table_constraint(t_const) if const_sql: column_parts.append(const_sql) all_params.extend(const_params) - # Build inline indexes (SQL Server specific) for idx_def in expr.indexes: idx_sql = self.format_inline_index(idx_def) column_parts.append(idx_sql) parts.append(f"({', '.join(column_parts)})") - # Handle table partitioning clause if expr.partition is not None: partition_sql, partition_params = expr.partition.to_sql() parts.append(partition_sql) all_params.extend(partition_params) - # Handle In-Memory OLTP table options (2014+) if dialect_options.get("memory_optimized"): durability = dialect_options.get("durability", "SCHEMA_ONLY") parts.append(self.format_memory_optimized_option(durability)) @@ -1293,7 +583,6 @@ def format_column_definition(self, col_def: "ColumnDefinition") -> Tuple[str, tu elif constraint.constraint_type == ColumnConstraintType.NULL: constraint_parts.append("NULL") - # Handle IDENTITY (auto-increment) if constraint.is_auto_increment: constraint_parts.append("IDENTITY(1,1)") @@ -1402,15 +691,12 @@ def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[ if expr.unique: parts.append("UNIQUE") - # SQL Server supports clustered/nonclustered indexes if hasattr(expr, 'index_type') and expr.index_type: index_type = expr.index_type.upper() if index_type in ('CLUSTERED', 'NONCLUSTERED'): parts.append(index_type) parts.append("INDEX") - # Note: SQL Server doesn't support IF NOT EXISTS for CREATE INDEX - # We ignore if_not_exists flag parts.append(self.format_identifier(expr.index_name)) parts.append("ON") parts.append(self.format_identifier(expr.table_name)) @@ -1425,12 +711,10 @@ def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[ col_parts.append(self.format_identifier(str(col))) parts.append(f"({', '.join(col_parts)})") - # SQL Server uses INCLUDE for covering indexes if expr.include: include_cols = ", ".join(self.format_identifier(c) for c in expr.include) parts.append(f"INCLUDE ({include_cols})") - # SQL Server uses WHERE for filtered indexes if expr.where: where_sql, where_params = expr.where.to_sql() parts.append(where_sql) @@ -1492,28 +776,6 @@ def format_alter_sequence_statement(self, expr: "AlterSequenceExpression") -> Tu parts.append(f"CACHE {expr.cache}") return " ".join(parts), () - def format_create_schema_statement(self, expr: "CreateSchemaExpression") -> Tuple[str, tuple]: - """Format CREATE SCHEMA for SQL Server. - - SQL Server uses CREATE SCHEMA schema_name [AUTHORIZATION owner_name]. - Does not support IF NOT EXISTS. - """ - parts = ["CREATE SCHEMA"] - parts.append(self.format_identifier(expr.schema_name)) - if expr.authorization: - parts.append(f"AUTHORIZATION {self.format_identifier(expr.authorization)}") - return " ".join(parts), () - - def format_drop_schema_statement(self, expr: "DropSchemaExpression") -> Tuple[str, tuple]: - """Format DROP SCHEMA for SQL Server. - - SQL Server requires schema to be empty before dropping. - Does not support IF EXISTS or CASCADE. - """ - parts = ["DROP SCHEMA"] - parts.append(self.format_identifier(expr.schema_name)) - return " ".join(parts), () - def format_truncate_statement(self, expr: "TruncateExpression") -> Tuple[str, tuple]: """Format TRUNCATE TABLE for SQL Server. @@ -1614,106 +876,6 @@ def format_merge_statement(self, expr: "MergeExpression") -> Tuple[str, tuple]: # T-SQL requires a MERGE statement to be terminated by a semi-colon. return " ".join(parts) + ";", tuple(all_params) - def format_set_transaction(self, expr: "SetTransactionExpression") -> Tuple[str, tuple]: - """Format SET TRANSACTION for SQL Server. - - SQL Server requires SET TRANSACTION ISOLATION LEVEL before BEGIN TRANSACTION. - """ - from rhosocial.activerecord.backend.errors import UnsupportedTransactionModeError - from rhosocial.activerecord.backend.transaction import TransactionMode - - params = expr.get_params() - mode = params.get("mode") - - if mode == TransactionMode.READ_ONLY: - raise UnsupportedTransactionModeError( - feature="READ ONLY transactions", - backend="SQL Server", - message="SQL Server does not support READ ONLY transactions.", - ) - - isolation_level = params.get("isolation_level") - if isolation_level: - level_name = self.get_isolation_level_name(isolation_level) - return f"SET TRANSACTION ISOLATION LEVEL {level_name}", () - - return "", () - - def format_lateral_expression( - self, - expr_sql: str, - expr_params: tuple, - alias: str, - join_type: str = "CROSS APPLY", - ) -> Tuple[str, tuple]: - """Format LATERAL as CROSS APPLY / OUTER APPLY for SQL Server. - - SQL Server uses CROSS APPLY (equivalent to INNER LATERAL) and - OUTER APPLY (equivalent to LEFT LATERAL) instead of LATERAL. - """ - if join_type.upper() in ("LEFT", "LEFT JOIN", "LEFT OUTER JOIN"): - apply_type = "OUTER APPLY" - else: - apply_type = "CROSS APPLY" - - sql = f"{apply_type} ({expr_sql})" - if alias: - sql += f" AS {self.format_identifier(alias)}" - return sql, expr_params - - def format_for_update_clause(self, clause: "ForUpdateClause") -> Tuple[str, tuple]: - """Format FOR UPDATE for SQL Server using table hints. - - SQL Server does not support FOR UPDATE syntax. Instead, locking - is achieved through table hints: WITH (UPDLOCK, ROWLOCK) etc. - """ - all_params: list = [] - - sql_parts = ["WITH (UPDLOCK, ROWLOCK)"] - - if clause.skip_locked and self.supports_for_update_skip_locked(): - sql_parts[0] = "WITH (UPDLOCK, ROWLOCK, READPAST)" - - return " ".join(sql_parts), tuple(all_params) - - def format_set_operation_expression(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: - """Format set operation for SQL Server. - - SQL Server supports UNION, UNION ALL, INTERSECT, EXCEPT. - ORDER BY and OFFSET FETCH apply to the entire set operation result. - """ - left, right = expr.left, expr.right - operation = expr.operation - all_ = expr.all_ - alias = expr.alias - order_by_clause = expr.order_by_clause - limit_offset_clause = expr.limit_offset_clause - - left_sql, left_params = left.to_sql() - right_sql, right_params = right.to_sql() - all_str = " ALL" if all_ else "" - - base_sql = f"{left_sql} {operation}{all_str} {right_sql}" - - all_params = list(left_params + right_params) - - sql_parts = [base_sql] - - if alias: - sql_parts.append(f"AS {self.format_identifier(alias)}") - - if order_by_clause: - order_by_sql, order_by_params = order_by_clause.to_sql() - sql_parts.append(order_by_sql) - all_params.extend(order_by_params) - - if limit_offset_clause: - limit_offset_sql, limit_offset_params = limit_offset_clause.to_sql() - sql_parts.append(limit_offset_sql) - all_params.extend(limit_offset_params) - - return " ".join(sql_parts), tuple(all_params) - def format_alter_table_statement(self, expr: "AlterTableExpression") -> Tuple[str, tuple]: """Format ALTER TABLE for SQL Server. @@ -1792,7 +954,7 @@ def format_fulltext_match( """ columns = expr.columns search_term = expr.search_term - language = expr.mode # SQL Server uses 'language' parameter, mapped from 'mode' + language = expr.mode escaped = search_term.replace("'", "''") cols = ", ".join(self.format_identifier(c) if isinstance(c, str) else c for c in columns) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py index 5a3dd3b..33e8586 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py @@ -10,6 +10,31 @@ from .routine import SQLServerRoutineMixin from .trigger import SQLServerTriggerDdlMixin from .protocol_support import SQLServerProtocolSupportMixin +from .datetime import SQLServerDateTimeMixin +from .collation import SQLServerCollationMixin +from .cte import SQLServerCTEMixin +from .returning import SQLServerReturningMixin +from .constraint import SQLServerConstraintMixin +from .window import SQLServerWindowMixin +from .json import SQLServerJSONMixin +from .grouping import SQLServerGroupingMixin +from .locking import SQLServerLockingMixin +from .merge import SQLServerMergeMixin +from .temporal import SQLServerTemporalMixin +from .upsert import SQLServerUpsertMixin +from .lateral import SQLServerLateralMixin +from .explain import SQLServerExplainMixin +from .dql import SQLServerDQLMixin +from .dml import SQLServerDMLMixin +from .view import SQLServerViewMixin +from .schema import SQLServerSchemaMixin +from .index import SQLServerIndexMixin +from .generated_column import SQLServerGeneratedColumnMixin +from .set_operation import SQLServerSetOperationMixin +from .table import SQLServerTableMixin +from .identifier import SQLServerIdentifierMixin +from .transaction import SQLServerTransactionMixin +from .function import SQLServerFunctionMixin __all__ = [ "SQLServerTypeSupportMixin", @@ -23,4 +48,29 @@ "SQLServerRoutineMixin", "SQLServerTriggerDdlMixin", "SQLServerProtocolSupportMixin", + "SQLServerDateTimeMixin", + "SQLServerCollationMixin", + "SQLServerCTEMixin", + "SQLServerReturningMixin", + "SQLServerConstraintMixin", + "SQLServerWindowMixin", + "SQLServerJSONMixin", + "SQLServerGroupingMixin", + "SQLServerLockingMixin", + "SQLServerMergeMixin", + "SQLServerTemporalMixin", + "SQLServerUpsertMixin", + "SQLServerLateralMixin", + "SQLServerExplainMixin", + "SQLServerDQLMixin", + "SQLServerDMLMixin", + "SQLServerViewMixin", + "SQLServerSchemaMixin", + "SQLServerIndexMixin", + "SQLServerGeneratedColumnMixin", + "SQLServerSetOperationMixin", + "SQLServerTableMixin", + "SQLServerIdentifierMixin", + "SQLServerTransactionMixin", + "SQLServerFunctionMixin", ] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/collation.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/collation.py new file mode 100644 index 0000000..f7151e9 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/collation.py @@ -0,0 +1,29 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/collation.py +from typing import Any, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from ..collation import validate_sqlserver_collation_name + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.expression.collation import CollateExpression + + +class SQLServerCollationMixin: + """SQL Server COLLATE expression support.""" + + def supports_collate_expression(self) -> bool: + """SQL Server supports expression-level COLLATE.""" + return True + + def validate_collation_name(self, expr: "CollateExpression") -> str: + """Validate SQL Server collation names and return their SQL representation.""" + if "schema" in expr.collation_options: + raise UnsupportedFeatureError(self.name, "schema-qualified COLLATE") + keyword = expr.collation_options.get("keyword", False) + unsupported = set(expr.collation_options) - {"keyword", "schema"} + if unsupported: + options = ", ".join(sorted(unsupported)) + raise UnsupportedFeatureError(self.name, f"COLLATE options: {options}") + if keyword and expr.collation_name != "DATABASE_DEFAULT": + raise ValueError(f"Unsupported SQL Server collation keyword: {expr.collation_name!r}") + return validate_sqlserver_collation_name(expr.collation_name, getattr(self, "version", None)) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/constraint.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/constraint.py new file mode 100644 index 0000000..00b7875 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/constraint.py @@ -0,0 +1,17 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/constraint.py + + +class SQLServerConstraintMixin: + """SQL Server constraint capability declarations.""" + + def supports_constraint_enforced(self) -> bool: + """SQL Server does not support ENFORCED/NOT ENFORCED constraint control.""" + return False + + def supports_fk_match(self) -> bool: + """SQL Server does not support MATCH {SIMPLE|PARTIAL|FULL}.""" + return False + + def supports_deferrable_constraint(self) -> bool: + """SQL Server does not support DEFERRABLE constraints.""" + return False diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/cte.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/cte.py new file mode 100644 index 0000000..af00cba --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/cte.py @@ -0,0 +1,17 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/cte.py + + +class SQLServerCTEMixin: + """SQL Server CTE capability declarations.""" + + def supports_basic_cte(self) -> bool: + """Basic CTEs are supported in all modern SQL Server versions.""" + return True + + def supports_recursive_cte(self) -> bool: + """Recursive CTEs are supported since SQL Server 2005.""" + return True + + def supports_unconditional_cte_order_by(self) -> bool: + """SQL Server prohibits ORDER BY in CTEs unless TOP/OFFSET is present.""" + return False diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/datetime.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/datetime.py new file mode 100644 index 0000000..82a6222 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/datetime.py @@ -0,0 +1,49 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/datetime.py +from typing import Any, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +if TYPE_CHECKING: + from ..dialect import SQLServerDialect + + +class SQLServerDateTimeMixin: + """SQL Server datetime function implementations (DATEADD, DATEDIFF, DATETRUNC).""" + + def format_date_trunc_expression(self, expr: "Any") -> Tuple[str, Tuple]: + source_sql, source_params = expr.source.to_sql() + field = expr.field.value.upper() + if field in {"YEAR", "MONTH", "DAY", "HOUR", "MINUTE", "SECOND"}: + sql = f"DATETRUNC({field}, {source_sql})" + else: + raise UnsupportedFeatureError(self.name, f"date_trunc({expr.field.value})") + return self.apply_alias(sql, source_params, expr) + + def format_interval_expression(self, expr: "Any") -> Tuple[str, Tuple]: + raise UnsupportedFeatureError( + self.name, + "standalone INTERVAL expression", + "Use date_add() or date_sub() for SQL Server date arithmetic.", + ) + + def format_datetime_add_expression(self, expr: "Any") -> Tuple[str, Tuple]: + source_sql, source_params = expr.source.to_sql() + unit = expr.interval.unit.value.upper() + sql = f"DATEADD({unit}, ?, {source_sql})" + return self.apply_alias( + sql, (expr.interval.value,) + source_params, expr + ) + + def format_datetime_subtract_expression(self, expr: "Any") -> Tuple[str, Tuple]: + source_sql, source_params = expr.source.to_sql() + unit = expr.interval.unit.value.upper() + sql = f"DATEADD({unit}, ?, {source_sql})" + return self.apply_alias( + sql, (-expr.interval.value,) + source_params, expr + ) + + def format_datetime_diff_expression(self, expr: "Any") -> Tuple[str, Tuple]: + start_sql, start_params = expr.start.to_sql() + end_sql, end_params = expr.end.to_sql() + sql = f"DATEDIFF({expr.unit.value.upper()}, {start_sql}, {end_sql})" + return self.apply_alias(sql, start_params + end_params, expr) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dml.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dml.py new file mode 100644 index 0000000..cab7aff --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dml.py @@ -0,0 +1,133 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dml.py +from typing import Any, List, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.expression.statements import ( + InsertExpression, + ) + + +class SQLServerDMLMixin: + """SQL Server INSERT / UPDATE / DELETE statement formatting.""" + + def format_insert_statement(self, expr: "InsertExpression") -> Tuple[str, tuple]: + """ + Format INSERT statement with SQL Server-specific options. + + SQL Server supports: + - OUTPUT clause for returning inserted rows + - INSERT TOP (n) for limiting rows + - DEFAULT VALUES + """ + if self.strict_validation: + expr.validate(strict=True) + + all_params: List[Any] = [] + + table_sql, table_params = expr.into.to_sql() + all_params.extend(table_params) + + parts = ["INSERT INTO", table_sql] + + if expr.columns: + columns_sql = "(" + ", ".join([self.format_identifier(c) for c in expr.columns]) + ")" + parts.append(columns_sql) + + # Add OUTPUT clause before VALUES/SELECT (SQL Server requirement) + if expr.returning: + returning_sql, returning_params = self.format_returning_clause(expr.returning) + parts.append(returning_sql) + all_params.extend(returning_params) + + from rhosocial.activerecord.backend.expression.statements import ( + DefaultValuesSource, + ValuesSource, + SelectSource, + ) + + if isinstance(expr.source, DefaultValuesSource): + parts.append("DEFAULT VALUES") + elif isinstance(expr.source, ValuesSource): + all_rows_sql = [] + for row in expr.source.values_list: + row_sql = [] + row_params = [] + for val in row: + s, p = val.to_sql() + row_sql.append(s) + row_params.extend(p) + all_rows_sql.append(f"({', '.join(row_sql)})") + all_params.extend(row_params) + parts.append("VALUES " + ", ".join(all_rows_sql)) + elif isinstance(expr.source, SelectSource): + s_sql, s_params = expr.source.select_query.to_sql() + parts.append(s_sql) + all_params.extend(s_params) + + sql = " ".join(parts) + + return sql, tuple(all_params) + + def format_update_statement(self, expr) -> Tuple[str, tuple]: + """Format UPDATE statement with OUTPUT clause support.""" + if self.strict_validation: + expr.validate(strict=True) + + all_params: List[Any] = [] + + table_sql, table_params = expr.table.to_sql() + all_params.extend(table_params) + + parts = ["UPDATE", table_sql] + + set_parts = [] + for col, val in expr.assignments.items(): + col_sql = self.format_identifier(col) + val_sql, val_params = val.to_sql() + set_parts.append(f"{col_sql} = {val_sql}") + all_params.extend(val_params) + parts.append("SET " + ", ".join(set_parts)) + + if expr.returning: + returning_sql, returning_params = self.format_returning_clause(expr.returning) + parts.append(returning_sql) + all_params.extend(returning_params) + + if expr.where: + where_sql, where_params = expr.where.to_sql() + parts.append(where_sql) + all_params.extend(where_params) + + return " ".join(parts), tuple(all_params) + + def format_delete_statement(self, expr) -> Tuple[str, tuple]: + """Format DELETE statement with OUTPUT clause support.""" + if self.strict_validation: + expr.validate(strict=True) + + all_params: List[Any] = [] + + # DeleteExpression has .tables (list), not .table + if expr.tables: + table_refs = [] + for tbl in expr.tables: + tbl_sql, tbl_params = tbl.to_sql() + table_refs.append(tbl_sql) + all_params.extend(tbl_params) + table_sql = ", ".join(table_refs) + else: + table_sql = "" + + parts = ["DELETE FROM", table_sql] + + if expr.returning: + returning_sql, returning_params = self.format_returning_clause(expr.returning, default_table="DELETED") + parts.append(returning_sql) + all_params.extend(returning_params) + + if expr.where: + where_sql, where_params = expr.where.to_sql() + parts.append(where_sql) + all_params.extend(where_params) + + return " ".join(parts), tuple(all_params) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dql.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dql.py new file mode 100644 index 0000000..50d9e9e --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dql.py @@ -0,0 +1,77 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dql.py +from typing import Any, List, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from .version_constants import SQL_SERVER_2012 + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.expression.query_parts import LimitOffsetClause + + +class SQLServerDQLMixin: + """SQL Server SELECT / LIMIT / OFFSET formatting.""" + + def format_limit_offset( + self, + limit: Optional[int] = None, + offset: Optional[int] = None + ) -> Tuple[Optional[str], List[Any]]: + """ + Format LIMIT/OFFSET as OFFSET FETCH for SQL Server 2012+. + + SQL Server 2012+ uses OFFSET FETCH syntax for pagination. + Note: ORDER BY is required when using OFFSET FETCH. + """ + if limit is None and offset is None: + return None, [] + + if self.version < SQL_SERVER_2012: + raise UnsupportedFeatureError( + self.name, + "OFFSET FETCH pagination", + "SQL Server 2012+ required for OFFSET FETCH. Use ROW_NUMBER() for older versions." + ) + + sql_parts = [] + params = [] + + offset_val = offset or 0 + sql_parts.append(f"OFFSET {offset_val} ROWS") + + if limit is not None: + sql_parts.append(f"FETCH NEXT {limit} ROWS ONLY") + + return " ".join(sql_parts), params + + def format_limit_offset_clause(self, clause: "LimitOffsetClause") -> Tuple[str, tuple]: + """Format LIMIT/OFFSET clause for SQL Server using OFFSET FETCH syntax.""" + if clause.limit is None and clause.offset is None: + return "", () + + if self.version < SQL_SERVER_2012: + raise UnsupportedFeatureError( + self.name, + "OFFSET FETCH pagination", + "SQL Server 2012+ required for OFFSET FETCH. Use ROW_NUMBER() for older versions." + ) + + parts = [] + params = [] + + offset_val = clause.offset if clause.offset is not None else 0 + parts.append(f"OFFSET {offset_val} ROWS") + + if clause.limit is not None: + parts.append(f"FETCH NEXT {clause.limit} ROWS ONLY") + + return " ".join(parts), tuple(params) + + def format_query_statement(self, expr: "QueryExpression") -> Tuple[str, tuple]: + sql, params = super().format_query_statement(expr) + if expr.limit_offset is not None and expr.order_by is None: + lo_sql = expr.limit_offset.to_sql()[0] + if lo_sql and lo_sql in sql: + sql = sql.replace(lo_sql, f"ORDER BY (SELECT NULL) {lo_sql}") + else: + sql += " ORDER BY (SELECT NULL)" + return sql, tuple(params) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/explain.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/explain.py new file mode 100644 index 0000000..ef93e22 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/explain.py @@ -0,0 +1,42 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/explain.py +from typing import Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.expression.statements import ExplainExpression + + +class SQLServerExplainMixin: + """SQL Server EXPLAIN (SHOWPLAN/STATISTICS) implementation.""" + + def supports_explain_analyze(self) -> bool: + """SQL Server uses STATISTICS PROFILE for actual execution.""" + return True + + def supports_explain_format(self, format_type: str) -> bool: + """Check if specific EXPLAIN format is supported.""" + return format_type.upper() in ["XML", "TEXT"] + + def format_explain_statement(self, explain_expr: "ExplainExpression") -> Tuple[str, tuple]: + """ + Format EXPLAIN for SQL Server. + + SQL Server uses: + - SET SHOWPLAN_XML ON (estimated plan) + - SET STATISTICS PROFILE ON (actual plan with statistics) + - SET STATISTICS XML ON (actual plan in XML) + """ + statement_sql, statement_params = explain_expr.statement.to_sql() + options = explain_expr.options + + if options is None: + return f"SET SHOWPLAN_TEXT ON; {statement_sql}", statement_params + + if options.analyze: + if options.format and options.format.name == "XML": + return f"SET STATISTICS XML ON; {statement_sql}; SET STATISTICS XML OFF", statement_params + return f"SET STATISTICS PROFILE ON; {statement_sql}; SET STATISTICS PROFILE OFF", statement_params + + if options.format and options.format.name == "XML": + return f"SET SHOWPLAN_XML ON; {statement_sql}", statement_params + + return f"SET SHOWPLAN_TEXT ON; {statement_sql}", statement_params diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/function.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/function.py new file mode 100644 index 0000000..d5a799b --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/function.py @@ -0,0 +1,50 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/function.py +from typing import Dict + + +class SQLServerFunctionMixin: + """SQL Server function support detection.""" + + def supports_functions(self) -> Dict[str, bool]: + """Return supported SQL functions as function_name -> bool mapping. + + Combines the core expression function factories with the SQL Server + function factories in ``functions.__all__``. + """ + from rhosocial.activerecord.backend.expression.functions import ( + __all__ as core_functions, + ) + from rhosocial.activerecord.backend.impl.sqlserver import ( + functions as sqlserver_functions, + ) + + result = {} + for func_name in core_functions: + result[func_name] = self._is_sqlserver_function_supported(func_name) + + for func_name in getattr(sqlserver_functions, "__all__", []): + if func_name not in result: + result[func_name] = self._is_sqlserver_function_supported(func_name) + + for func_name in self._SQLSERVER_FUNCTION_VERSIONS: + if func_name not in result: + result[func_name] = self._is_sqlserver_function_supported(func_name) + + return result + + def _is_sqlserver_function_supported(self, func_name: str) -> bool: + """Check if a SQL Server-specific function is supported based on version.""" + version_range = self._SQLSERVER_FUNCTION_VERSIONS.get(func_name) + if version_range is None: + return True + + min_version = version_range[0] + max_version = version_range[1] + + if min_version is not None and self.version < min_version: + return False + + if max_version is not None and self.version > max_version: + return False + + return True diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/generated_column.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/generated_column.py new file mode 100644 index 0000000..d9ec635 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/generated_column.py @@ -0,0 +1,21 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/generated_column.py + + +class SQLServerGeneratedColumnMixin: + """SQL Server computed column capability declarations.""" + + def supports_generated_column(self) -> bool: + """SQL Server supports computed columns.""" + return True + + def supports_generated_columns(self) -> bool: + """Whether generated (computed) columns are supported.""" + return self.supports_generated_column() + + def supports_stored_generated_columns(self) -> bool: + """SQL Server supports PERSISTED computed columns.""" + return True + + def supports_virtual_generated_columns(self) -> bool: + """SQL Server computed columns are virtual by default.""" + return True diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/grouping.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/grouping.py new file mode 100644 index 0000000..adfaa99 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/grouping.py @@ -0,0 +1,17 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/grouping.py + + +class SQLServerGroupingMixin: + """SQL Server GROUP BY extension capability declarations.""" + + def supports_rollup(self) -> bool: + """ROLLUP is supported using WITH ROLLUP syntax.""" + return True + + def supports_cube(self) -> bool: + """CUBE is supported using WITH CUBE syntax.""" + return True + + def supports_grouping_sets(self) -> bool: + """GROUPING SETS is supported since SQL Server 2008.""" + return True diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/identifier.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/identifier.py new file mode 100644 index 0000000..dc331a4 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/identifier.py @@ -0,0 +1,33 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/identifier.py + + +class SQLServerIdentifierMixin: + """SQL Server identifier formatting (square brackets).""" + + def format_identifier(self, identifier: str, need_quote: bool = True) -> str: + """ + Format identifier using SQL Server's brackets. + + SQL Server uses square brackets [] for identifiers, escaping + internal brackets by doubling them. + + Args: + identifier: Raw identifier string + need_quote: Whether to quote the identifier (default True) + + Returns: + Quoted identifier with escaped internal brackets, or raw identifier if need_quote is False + """ + if not need_quote: + if self.is_reserved_word(identifier): + import warnings + from rhosocial.activerecord.backend.warnings import IdentifierQuotingWarning + warnings.warn( + f"Identifier '{identifier}' is a reserved word in {self.name} " + f"and may cause SQL errors without quoting.", + IdentifierQuotingWarning, + stacklevel=2, + ) + return identifier + escaped = identifier.replace("]", "]]") + return f"[{escaped}]" diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/index.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/index.py new file mode 100644 index 0000000..5454907 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/index.py @@ -0,0 +1,26 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/index.py +from .version_constants import SQL_SERVER_2016 + + +class SQLServerIndexMixin: + """SQL Server index capability declarations.""" + + def supports_index_if_exists(self) -> bool: + """SQL Server supports DROP INDEX IF EXISTS (2016+).""" + return self.version >= SQL_SERVER_2016 + + def supports_partial_index(self) -> bool: + """SQL Server supports filtered indexes (WHERE clause).""" + return True + + def supports_functional_index(self) -> bool: + """SQL Server supports indexes on computed columns.""" + return True + + def supports_concurrent_index(self) -> bool: + """SQL Server supports ONLINE index creation (Enterprise edition).""" + return True + + def supports_fulltext_index(self) -> bool: + """SQL Server supports FULLTEXT indexes.""" + return True diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/json.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/json.py new file mode 100644 index 0000000..cbab210 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/json.py @@ -0,0 +1,51 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/json.py +from typing import Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression import bases +from .version_constants import SQL_SERVER_2016 + +if TYPE_CHECKING: + pass + + +class SQLServerJSONMixin: + """SQL Server JSON function implementations.""" + + def supports_json_type(self) -> bool: + """JSON functions are supported since SQL Server 2016.""" + return self.version >= SQL_SERVER_2016 + + def get_json_access_operator(self) -> Optional[str]: + """SQL Server doesn't have -> operator, uses JSON_VALUE/JSON_QUERY.""" + return None + + def format_json_function_expression(self, expr) -> Tuple[str, tuple]: + """Format JSON extraction using SQL Server's JSON_VALUE built-in. + + The base implementation renders ``JSON_UNQUOTE(JSON_EXTRACT(...))`` + which is MySQL syntax. SQL Server uses ``JSON_VALUE(column, 'path')``, + which already returns a scalar value without surrounding quotes. + + Args: + expr: The JSONExpression to format. + + Returns: + (SQL string, params tuple). + """ + if isinstance(expr.column, bases.BaseExpression): + col_sql, col_params = expr.column.to_sql() + else: + col_sql, col_params = self.format_identifier(str(expr.column)), () + + escaped_path = self._escape_sql_string(expr.path) + sql = f"JSON_VALUE({col_sql}, '{escaped_path}')" + params = col_params + + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + + return sql, params + + def supports_json_table(self) -> bool: + """OPENJSON provides JSON table functionality since SQL Server 2016.""" + return self.version >= SQL_SERVER_2016 diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/lateral.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/lateral.py new file mode 100644 index 0000000..395518e --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/lateral.py @@ -0,0 +1,32 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/lateral.py +from typing import Tuple + + +class SQLServerLateralMixin: + """SQL Server CROSS APPLY / OUTER APPLY (LATERAL) support.""" + + def supports_lateral_join(self) -> bool: + """SQL Server uses CROSS APPLY/OUTER APPLY instead of LATERAL.""" + return True + + def format_lateral_expression( + self, + expr_sql: str, + expr_params: tuple, + alias: str, + join_type: str = "CROSS APPLY", + ) -> Tuple[str, tuple]: + """Format LATERAL as CROSS APPLY / OUTER APPLY for SQL Server. + + SQL Server uses CROSS APPLY (equivalent to INNER LATERAL) and + OUTER APPLY (equivalent to LEFT LATERAL) instead of LATERAL. + """ + if join_type.upper() in ("LEFT", "LEFT JOIN", "LEFT OUTER JOIN"): + apply_type = "OUTER APPLY" + else: + apply_type = "CROSS APPLY" + + sql = f"{apply_type} ({expr_sql})" + if alias: + sql += f" AS {self.format_identifier(alias)}" + return sql, expr_params diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/locking.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/locking.py new file mode 100644 index 0000000..b7aa030 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/locking.py @@ -0,0 +1,34 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/locking.py +from typing import Tuple, TYPE_CHECKING + +from .version_constants import SQL_SERVER_2019 + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.expression.query_parts import ForUpdateClause + + +class SQLServerLockingMixin: + """SQL Server locking (table hints) capability and formatting.""" + + def supports_for_update(self) -> bool: + """SQL Server supports FOR UPDATE via table hints.""" + return True + + def supports_for_update_skip_locked(self) -> bool: + """SQL Server 2019+ supports READPAST with UPDLOCK.""" + return self.version >= SQL_SERVER_2019 + + def format_for_update_clause(self, clause: "ForUpdateClause") -> Tuple[str, tuple]: + """Format FOR UPDATE for SQL Server using table hints. + + SQL Server does not support FOR UPDATE syntax. Instead, locking + is achieved through table hints: WITH (UPDLOCK, ROWLOCK) etc. + """ + all_params: list = [] + + sql_parts = ["WITH (UPDLOCK, ROWLOCK)"] + + if clause.skip_locked and self.supports_for_update_skip_locked(): + sql_parts[0] = "WITH (UPDLOCK, ROWLOCK, READPAST)" + + return " ".join(sql_parts), tuple(all_params) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/merge.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/merge.py new file mode 100644 index 0000000..ef98179 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/merge.py @@ -0,0 +1,9 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/merge.py + + +class SQLServerMergeMixin: + """SQL Server MERGE statement capability declaration.""" + + def supports_merge_statement(self) -> bool: + """SQL Server has native MERGE support since 2008.""" + return True diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/returning.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/returning.py new file mode 100644 index 0000000..bdbc324 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/returning.py @@ -0,0 +1,51 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/returning.py +from typing import Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.expression.statements import ReturningClause + + +class SQLServerReturningMixin: + """SQL Server OUTPUT clause support for RETURNING.""" + + def supports_returning_insert(self) -> bool: + """SQL Server supports OUTPUT clause for INSERT.""" + return True + + def supports_returning_update(self) -> bool: + """SQL Server supports OUTPUT clause for UPDATE.""" + return True + + def supports_returning_delete(self) -> bool: + """SQL Server supports OUTPUT clause for DELETE.""" + return True + + def format_returning_clause(self, clause: "ReturningClause", default_table: str = "INSERTED") -> Tuple[str, tuple]: + """ + Format RETURNING as OUTPUT clause for SQL Server. + + SQL Server OUTPUT syntax: + - INSERT: OUTPUT inserted.column, inserted.column2 + - UPDATE: OUTPUT deleted.old_column, inserted.new_column + - DELETE: OUTPUT deleted.column + + Args: + clause: The ReturningClause with expressions to output. + default_table: The default virtual table prefix ("INSERTED" for + INSERT/UPDATE, "DELETED" for DELETE). + """ + all_params = [] + expr_parts = [] + + for expr in clause.expressions: + if hasattr(expr, 'table') and expr.table: + expr_sql, expr_params = expr.to_sql() + expr_parts.append(f"{default_table}.{expr_sql}") + else: + expr_sql, expr_params = expr.to_sql() + expr_parts.append(f"{default_table}.{expr_sql}") + all_params.extend(expr_params) + + output_sql = f"OUTPUT {', '.join(expr_parts)}" + + return output_sql, tuple(all_params) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/schema.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/schema.py new file mode 100644 index 0000000..ffb446a --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/schema.py @@ -0,0 +1,50 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/schema.py +from typing import Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.expression.statements import ( + CreateSchemaExpression, + DropSchemaExpression, + ) + + +class SQLServerSchemaMixin: + """SQL Server schema capability declarations and formatting.""" + + def supports_create_schema(self) -> bool: + """SQL Server supports CREATE SCHEMA.""" + return True + + def supports_drop_schema(self) -> bool: + """SQL Server supports DROP SCHEMA.""" + return True + + def supports_schema(self) -> bool: + """SQL Server models named schema namespaces natively (e.g. dbo).""" + return True + + def supports_schema_authorization(self) -> bool: + """SQL Server accepts CREATE SCHEMA ... AUTHORIZATION owner.""" + return True + + def format_create_schema_statement(self, expr: "CreateSchemaExpression") -> Tuple[str, tuple]: + """Format CREATE SCHEMA for SQL Server. + + SQL Server uses CREATE SCHEMA schema_name [AUTHORIZATION owner_name]. + Does not support IF NOT EXISTS. + """ + parts = ["CREATE SCHEMA"] + parts.append(self.format_identifier(expr.schema_name)) + if expr.authorization: + parts.append(f"AUTHORIZATION {self.format_identifier(expr.authorization)}") + return " ".join(parts), () + + def format_drop_schema_statement(self, expr: "DropSchemaExpression") -> Tuple[str, tuple]: + """Format DROP SCHEMA for SQL Server. + + SQL Server requires schema to be empty before dropping. + Does not support IF EXISTS or CASCADE. + """ + parts = ["DROP SCHEMA"] + parts.append(self.format_identifier(expr.schema_name)) + return " ".join(parts), () diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/sequence.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/sequence.py index 5af28a3..8ae6928 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/sequence.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/sequence.py @@ -50,3 +50,13 @@ def format_next_value_for(self, sequence: Any) -> str: quoted = self.format_identifier(sequence_name) # type: ignore[attr-defined] return f"NEXT VALUE FOR {quoted}" + + # --- Sequence capability declarations (moved from dialect.py) --- + + def supports_create_sequence(self) -> bool: + """SQL Server 2012+ supports SEQUENCE objects.""" + return self.version >= _SQL_SERVER_NEXT_VALUE_FOR_VERSION # type: ignore[attr-defined] + + def supports_drop_sequence(self) -> bool: + """SQL Server 2012+ supports DROP SEQUENCE.""" + return self.version >= _SQL_SERVER_NEXT_VALUE_FOR_VERSION # type: ignore[attr-defined] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/set_operation.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/set_operation.py new file mode 100644 index 0000000..e8df38a --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/set_operation.py @@ -0,0 +1,71 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/set_operation.py +from typing import Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.expression import bases + + +class SQLServerSetOperationMixin: + """SQL Server set operation capability declarations and formatting.""" + + def supports_union(self) -> bool: + """UNION is supported.""" + return True + + def supports_union_all(self) -> bool: + """UNION ALL is supported.""" + return True + + def supports_intersect(self) -> bool: + """INTERSECT is supported.""" + return True + + def supports_except(self) -> bool: + """EXCEPT is supported.""" + return True + + def supports_set_operation_order_by(self) -> bool: + """Set operations support ORDER BY.""" + return True + + def supports_set_operation_limit_offset(self) -> bool: + """Set operations support OFFSET FETCH.""" + return True + + def format_set_operation_expression(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Format set operation for SQL Server. + + SQL Server supports UNION, UNION ALL, INTERSECT, EXCEPT. + ORDER BY and OFFSET FETCH apply to the entire set operation result. + """ + left, right = expr.left, expr.right + operation = expr.operation + all_ = expr.all_ + alias = expr.alias + order_by_clause = expr.order_by_clause + limit_offset_clause = expr.limit_offset_clause + + left_sql, left_params = left.to_sql() + right_sql, right_params = right.to_sql() + all_str = " ALL" if all_ else "" + + base_sql = f"{left_sql} {operation}{all_str} {right_sql}" + + all_params = list(left_params + right_params) + + sql_parts = [base_sql] + + if alias: + sql_parts.append(f"AS {self.format_identifier(alias)}") + + if order_by_clause: + order_by_sql, order_by_params = order_by_clause.to_sql() + sql_parts.append(order_by_sql) + all_params.extend(order_by_params) + + if limit_offset_clause: + limit_offset_sql, limit_offset_params = limit_offset_clause.to_sql() + sql_parts.append(limit_offset_sql) + all_params.extend(limit_offset_params) + + return " ".join(sql_parts), tuple(all_params) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/table.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/table.py new file mode 100644 index 0000000..231f9f5 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/table.py @@ -0,0 +1,30 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/table.py +from typing import Tuple, TYPE_CHECKING + +from .version_constants import SQL_SERVER_2016 + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.expression.statements import ( + CreateTableExpression, + DropTableExpression, + AlterTableExpression, + ColumnDefinition, + TableConstraint, + IndexDefinition, + ) + + +class SQLServerTableMixin: + """SQL Server table DDL capability declarations.""" + + def supports_if_exists_table(self) -> bool: + """SQL Server supports DROP TABLE IF EXISTS (2016+).""" + return self.version >= SQL_SERVER_2016 + + def supports_add_column(self) -> bool: + """SQL Server supports ALTER TABLE ADD COLUMN.""" + return True + + def supports_table_partitioning(self) -> bool: + """SQL Server supports table partitioning.""" + return True diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/temporal.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/temporal.py new file mode 100644 index 0000000..21a97c0 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/temporal.py @@ -0,0 +1,10 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/temporal.py +from .version_constants import SQL_SERVER_2016 + + +class SQLServerTemporalMixin: + """SQL Server temporal table capability declaration.""" + + def supports_temporal_tables(self) -> bool: + """SQL Server 2016+ supports temporal tables.""" + return self.version >= SQL_SERVER_2016 diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/transaction.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/transaction.py new file mode 100644 index 0000000..55dc84d --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/transaction.py @@ -0,0 +1,76 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/transaction.py +from typing import Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.expression.transaction import ( + BeginTransactionExpression, + SetTransactionExpression, + ) + + +class SQLServerTransactionMixin: + """SQL Server transaction control capability declarations and formatting.""" + + def supports_savepoint(self) -> bool: + """SQL Server supports savepoints.""" + return True + + def supports_transaction_mode(self) -> bool: + """SQL Server doesn't support READ ONLY transactions.""" + return False + + def supports_isolation_level_in_begin(self) -> bool: + """SQL Server requires SET TRANSACTION ISOLATION LEVEL before BEGIN.""" + return False + + def supports_read_only_transaction(self) -> bool: + """SQL Server doesn't support READ ONLY transactions.""" + return False + + def supports_deferrable_transaction(self) -> bool: + """SQL Server doesn't support DEFERRABLE mode.""" + return False + + def format_begin_transaction( + self, expr: "BeginTransactionExpression" + ) -> Tuple[str, tuple]: + """Format BEGIN TRANSACTION for SQL Server.""" + from rhosocial.activerecord.backend.errors import UnsupportedTransactionModeError + from rhosocial.activerecord.backend.transaction import TransactionMode + + params = expr.get_params() + mode = params.get("mode") + + if mode == TransactionMode.READ_ONLY: + raise UnsupportedTransactionModeError( + feature="READ ONLY transactions", + backend="SQL Server", + message="SQL Server does not support READ ONLY transactions." + ) + + return "BEGIN TRANSACTION", () + + def format_set_transaction(self, expr: "SetTransactionExpression") -> Tuple[str, tuple]: + """Format SET TRANSACTION for SQL Server. + + SQL Server requires SET TRANSACTION ISOLATION LEVEL before BEGIN TRANSACTION. + """ + from rhosocial.activerecord.backend.errors import UnsupportedTransactionModeError + from rhosocial.activerecord.backend.transaction import TransactionMode + + params = expr.get_params() + mode = params.get("mode") + + if mode == TransactionMode.READ_ONLY: + raise UnsupportedTransactionModeError( + feature="READ ONLY transactions", + backend="SQL Server", + message="SQL Server does not support READ ONLY transactions.", + ) + + isolation_level = params.get("isolation_level") + if isolation_level: + level_name = self.get_isolation_level_name(isolation_level) + return f"SET TRANSACTION ISOLATION LEVEL {level_name}", () + + return "", () diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/trigger.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/trigger.py index 448f6d5..c1d9cf4 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/trigger.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/trigger.py @@ -129,3 +129,21 @@ def format_drop_trigger_statement( parts.append("IF EXISTS") parts.append(self.format_identifier(name)) # type: ignore[attr-defined] return " ".join(parts), () + + # --- Additional trigger capability declarations (moved from dialect.py) --- + + def supports_instead_of_trigger(self) -> bool: + """SQL Server supports INSTEAD OF triggers.""" + return True + + def supports_statement_trigger(self) -> bool: + """SQL Server supports FOR EACH STATEMENT triggers.""" + return True + + def supports_trigger_referencing(self) -> bool: + """SQL Server supports referencing OLD and NEW via inserted/deleted tables.""" + return True + + def supports_trigger_when(self) -> bool: + """SQL Server supports WHEN condition in triggers (IF clause).""" + return True diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/upsert.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/upsert.py new file mode 100644 index 0000000..f001505 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/upsert.py @@ -0,0 +1,13 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/upsert.py + + +class SQLServerUpsertMixin: + """SQL Server upsert capability declarations.""" + + def supports_upsert(self) -> bool: + """SQL Server uses MERGE for upsert operations.""" + return True + + def get_upsert_syntax_type(self) -> str: + """Return 'MERGE' for SQL Server.""" + return "MERGE" diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/version_constants.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/version_constants.py new file mode 100644 index 0000000..ceeabf3 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/version_constants.py @@ -0,0 +1,11 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/version_constants.py +"""SQL Server version constants shared across mixin modules.""" + +SQL_SERVER_2005 = (9, 0, 0) +SQL_SERVER_2008 = (10, 0, 0) +SQL_SERVER_2012 = (11, 0, 0) +SQL_SERVER_2014 = (12, 0, 0) +SQL_SERVER_2016 = (13, 0, 0) +SQL_SERVER_2017 = (14, 0, 0) +SQL_SERVER_2019 = (15, 0, 0) +SQL_SERVER_2022 = (16, 0, 0) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/view.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/view.py new file mode 100644 index 0000000..5ad4fad --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/view.py @@ -0,0 +1,75 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/view.py +from typing import Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from .version_constants import SQL_SERVER_2005, SQL_SERVER_2016 + +_SUGGESTION_MATERIALIZED_VIEW = "SQL Server does not support materialized views. Consider using indexed views." + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.expression.statements import ( + CreateViewExpression, + DropViewExpression, + CreateMaterializedViewExpression, + ) + + +class SQLServerViewMixin: + """SQL Server VIEW capability declarations and formatting.""" + + def supports_indexed_view(self) -> bool: + """SQL Server supports indexed views (similar to materialized views).""" + return self.version >= SQL_SERVER_2005 + + def supports_if_exists_view(self) -> bool: + """SQL Server supports DROP VIEW IF EXISTS (2016+).""" + return self.version >= SQL_SERVER_2016 + + def supports_view_check_option(self) -> bool: + """SQL Server supports WITH CHECK OPTION.""" + return True + + def format_create_view_statement( + self, expr: "CreateViewExpression" + ) -> Tuple[str, tuple]: + """Format CREATE VIEW statement for SQL Server.""" + parts = ["CREATE VIEW"] + + if expr.replace: + parts = ["CREATE OR ALTER VIEW"] + + parts.append(self.format_identifier(expr.view_name)) + + if expr.column_aliases: + cols = ", ".join(self.format_identifier(c) for c in expr.column_aliases) + parts.append(f"({cols})") + + query_sql, query_params = expr.query.to_sql() + as_sql = f"AS {query_sql}" + if expr.options and getattr(expr.options, "schemabinding", False): + as_sql = f"WITH SCHEMABINDING {as_sql}" + parts.append(as_sql) + + if expr.options and hasattr(expr.options, 'check_option') and expr.options.check_option: + parts.append(f"WITH {expr.options.check_option.value} CHECK OPTION") + + return " ".join(parts), query_params + + def format_drop_view_statement( + self, expr: "DropViewExpression" + ) -> Tuple[str, tuple]: + """Format DROP VIEW statement for SQL Server.""" + parts = ["DROP VIEW"] + + if expr.if_exists and self.supports_if_exists_view(): + parts.append("IF EXISTS") + + parts.append(self.format_identifier(expr.view_name)) + + return " ".join(parts), () + + def format_create_materialized_view_statement( + self, expr: "CreateMaterializedViewExpression" + ) -> Tuple[str, tuple]: + """Format CREATE MATERIALIZED VIEW - not supported.""" + raise UnsupportedFeatureError(self.name, "CREATE MATERIALIZED VIEW", _SUGGESTION_MATERIALIZED_VIEW) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/window.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/window.py new file mode 100644 index 0000000..501340c --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/window.py @@ -0,0 +1,14 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/window.py +from .version_constants import SQL_SERVER_2012 + + +class SQLServerWindowMixin: + """SQL Server window function capability declarations.""" + + def supports_window_functions(self) -> bool: + """Window functions are supported since SQL Server 2005.""" + return True + + def supports_window_frame_clause(self) -> bool: + """Window frame clauses are supported since SQL Server 2012.""" + return self.version >= SQL_SERVER_2012 From 9fe5b8865d2ac8438123906ba805832ccf614779 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 14 Sep 2026 11:01:09 +0800 Subject: [PATCH 25/58] feat(sqlserver): add JSON/SET/spatial expression classes Expression nodes for format_* signature compliance (mirrors the MySQL/ MariaDB expression-object convention). --- .../backend/impl/sqlserver/expression/json.py | 237 ++++++++++++++++ .../impl/sqlserver/expression/set_type.py | 92 ++++++ .../impl/sqlserver/expression/spatial.py | 263 ++++++++++++++++++ 3 files changed, 592 insertions(+) create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/expression/json.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/expression/set_type.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/expression/spatial.py diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/json.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/json.py new file mode 100644 index 0000000..e5bf9ff --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/json.py @@ -0,0 +1,237 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/expression/json.py +"""SQL Server JSON expression classes for format_* signature compliance.""" + +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import SQLValueExpression +from rhosocial.activerecord.backend.expression.mixins import ( + AliasableMixin, + ComparisonMixin, +) + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +class SQLServerJSONExtractExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): + """SQL Server JSON_VALUE expression for extracting scalar values. + + Example: + >>> expr = SQLServerJSONExtractExpression(dialect, "data", "$.name") + """ + + def __init__( + self, + dialect: "SQLDialectBase", + json_doc: str, + path: str, + *, + alias: Optional[str] = None, + ): + super().__init__(dialect) + self.json_doc = json_doc + self.path = path + self.alias = alias + + @property + def format_method(self) -> str: + return "format_json_extract" + + +class SQLServerJSONUnquoteExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): + """SQL Server JSON_UNQUOTE expression. + + Note: SQL Server's JSON_VALUE returns scalar values already unquoted, + so this raises UnsupportedFeatureError. + + Example: + >>> expr = SQLServerJSONUnquoteExpression(dialect, "data") + """ + + def __init__( + self, + dialect: "SQLDialectBase", + json_val: str, + *, + alias: Optional[str] = None, + ): + super().__init__(dialect) + self.json_val = json_val + self.alias = alias + + @property + def format_method(self) -> str: + return "format_json_unquote" + + +class SQLServerJSONObjectExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): + """SQL Server JSON_OBJECT expression (2022+). + + Example: + >>> expr = SQLServerJSONObjectExpression(dialect, [("name", "Alice"), ("age", 30)]) + """ + + def __init__( + self, + dialect: "SQLDialectBase", + key_value_pairs: Optional[List[Tuple[str, Any]]] = None, + *, + alias: Optional[str] = None, + ): + super().__init__(dialect) + self.key_value_pairs = key_value_pairs or [] + self.alias = alias + + @property + def format_method(self) -> str: + return "format_json_object" + + +class SQLServerJSONArrayExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): + """SQL Server JSON_ARRAY expression (2022+). + + Example: + >>> expr = SQLServerJSONArrayExpression(dialect, [1, "two", True]) + """ + + def __init__( + self, + dialect: "SQLDialectBase", + values: Optional[List[Any]] = None, + *, + alias: Optional[str] = None, + ): + super().__init__(dialect) + self.values = values or [] + self.alias = alias + + @property + def format_method(self) -> str: + return "format_json_array" + + +class SQLServerJSONContainsExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): + """SQL Server JSON containment check using OPENJSON (2016+). + + Example: + >>> expr = SQLServerJSONContainsExpression(dialect, "data", "value") + """ + + def __init__( + self, + dialect: "SQLDialectBase", + target: str, + candidate: str, + path: Optional[str] = None, + *, + alias: Optional[str] = None, + ): + super().__init__(dialect) + self.target = target + self.candidate = candidate + self.path = path + self.alias = alias + + @property + def format_method(self) -> str: + return "format_json_contains" + + +class SQLServerJSONSetExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): + """SQL Server JSON_MODIFY expression (single path-value pair only). + + Example: + >>> expr = SQLServerJSONSetExpression(dialect, "data", "$.name", "Bob") + """ + + def __init__( + self, + dialect: "SQLDialectBase", + json_doc: str, + path: str, + value: Any, + *, + alias: Optional[str] = None, + ): + super().__init__(dialect) + self.json_doc = json_doc + self.path = path + self.value = value + self.alias = alias + + @property + def format_method(self) -> str: + return "format_json_set" + + +class SQLServerJSONRemoveExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): + """SQL Server JSON property removal using JSON_MODIFY (single path only). + + Example: + >>> expr = SQLServerJSONRemoveExpression(dialect, "data", "$.name") + """ + + def __init__( + self, + dialect: "SQLDialectBase", + json_doc: str, + path: str, + *, + alias: Optional[str] = None, + ): + super().__init__(dialect) + self.json_doc = json_doc + self.path = path + self.alias = alias + + @property + def format_method(self) -> str: + return "format_json_remove" + + +class SQLServerJSONTypeExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): + """SQL Server JSON_TYPE expression. + + Note: SQL Server has no JSON_TYPE; classify values via OPENJSON. + + Example: + >>> expr = SQLServerJSONTypeExpression(dialect, "data") + """ + + def __init__( + self, + dialect: "SQLDialectBase", + json_val: str, + *, + alias: Optional[str] = None, + ): + super().__init__(dialect) + self.json_val = json_val + self.alias = alias + + @property + def format_method(self) -> str: + return "format_json_type" + + +class SQLServerJSONValidExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): + """SQL Server ISJSON function (equivalent of JSON_VALID). + + Example: + >>> expr = SQLServerJSONValidExpression(dialect, "data") + """ + + def __init__( + self, + dialect: "SQLDialectBase", + json_val: str, + *, + alias: Optional[str] = None, + ): + super().__init__(dialect) + self.json_val = json_val + self.alias = alias + + @property + def format_method(self) -> str: + return "format_json_valid" diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/set_type.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/set_type.py new file mode 100644 index 0000000..bea64a0 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/set_type.py @@ -0,0 +1,92 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/expression/set_type.py +"""SQL Server SET type expression classes for format_* signature compliance.""" + +from typing import List, Optional, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import SQLValueExpression +from rhosocial.activerecord.backend.expression.mixins import ( + AliasableMixin, + ComparisonMixin, +) + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +class SQLServerSetLiteralExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): + """SQL Server SET literal expression (comma-separated values in VARCHAR). + + SQL Server stores comma-separated values in VARCHAR columns. + + Example: + >>> expr = SQLServerSetLiteralExpression(dialect, ["a", "b", "c"]) + """ + + def __init__( + self, + dialect: "SQLDialectBase", + values: List[str], + column_values: Optional[List[str]] = None, + *, + alias: Optional[str] = None, + ): + super().__init__(dialect) + self.values = values + self.column_values = column_values + self.alias = alias + + @property + def format_method(self) -> str: + return "format_set_literal" + + +class SQLServerFindInSetExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): + """SQL Server FIND_IN_SET equivalent using LIKE predicate. + + SQL Server has no FIND_IN_SET; membership is checked with a LIKE predicate. + + Example: + >>> expr = SQLServerFindInSetExpression(dialect, "value", "set_column") + """ + + def __init__( + self, + dialect: "SQLDialectBase", + value: str, + set_column: str, + *, + alias: Optional[str] = None, + ): + super().__init__(dialect) + self.value = value + self.set_column = set_column + self.alias = alias + + @property + def format_method(self) -> str: + return "format_find_in_set" + + +class SQLServerSetContainsExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): + """SQL Server SET containment predicate as AND-ed membership checks. + + Example: + >>> expr = SQLServerSetContainsExpression(dialect, "col", ["a", "b"]) + """ + + def __init__( + self, + dialect: "SQLDialectBase", + column: str, + values: List[str], + *, + alias: Optional[str] = None, + ): + super().__init__(dialect) + self.column = column + self.values = values + self.alias = alias + + @property + def format_method(self) -> str: + return "format_set_contains" diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/spatial.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/spatial.py new file mode 100644 index 0000000..e769969 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/spatial.py @@ -0,0 +1,263 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/expression/spatial.py +"""SQL Server spatial expression classes for format_* signature compliance.""" + +from typing import Optional, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import SQLValueExpression +from rhosocial.activerecord.backend.expression.mixins import ( + AliasableMixin, + ComparisonMixin, +) + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +class SQLServerSpatialLiteralExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): + """SQL Server WKT literal using geometry::STGeomFromText. + + Example: + >>> expr = SQLServerSpatialLiteralExpression(dialect, "POINT (1 2)", srid=4326) + """ + + def __init__( + self, + dialect: "SQLDialectBase", + wkt: str, + srid: Optional[int] = None, + *, + alias: Optional[str] = None, + ): + super().__init__(dialect) + self.wkt = wkt + self.srid = srid + self.alias = alias + + @property + def format_method(self) -> str: + return "format_spatial_literal" + + +class SQLServerSTGeomFromTextExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): + """SQL Server geometry::STGeomFromText call. + + Example: + >>> expr = SQLServerSTGeomFromTextExpression(dialect, "POINT (1 2)", srid=0) + """ + + def __init__( + self, + dialect: "SQLDialectBase", + wkt: str, + srid: Optional[int] = None, + *, + alias: Optional[str] = None, + ): + super().__init__(dialect) + self.wkt = wkt + self.srid = srid + self.alias = alias + + @property + def format_method(self) -> str: + return "format_st_geom_from_text" + + +class SQLServerSTAsTextExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): + """SQL Server geometry STAsText method call. + + Example: + >>> expr = SQLServerSTAsTextExpression(dialect, "geom_col") + """ + + def __init__( + self, + dialect: "SQLDialectBase", + geom: str, + *, + alias: Optional[str] = None, + ): + super().__init__(dialect) + self.geom = geom + self.alias = alias + + @property + def format_method(self) -> str: + return "format_st_as_text" + + +class SQLServerSTAsGeoJSONExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): + """SQL Server geometry STAsGeoJSON method call. + + Example: + >>> expr = SQLServerSTAsGeoJSONExpression(dialect, "geom_col") + """ + + def __init__( + self, + dialect: "SQLDialectBase", + geom: str, + *, + alias: Optional[str] = None, + ): + super().__init__(dialect) + self.geom = geom + self.alias = alias + + @property + def format_method(self) -> str: + return "format_st_as_geojson" + + +class SQLServerSTDistanceExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): + """SQL Server geometry STDistance method call. + + Example: + >>> expr = SQLServerSTDistanceExpression(dialect, "geom1", "geom2") + """ + + def __init__( + self, + dialect: "SQLDialectBase", + geom1: str, + geom2: str, + *, + alias: Optional[str] = None, + ): + super().__init__(dialect) + self.geom1 = geom1 + self.geom2 = geom2 + self.alias = alias + + @property + def format_method(self) -> str: + return "format_st_distance" + + +class SQLServerSTWithinExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): + """SQL Server geometry STWithin method call. + + Example: + >>> expr = SQLServerSTWithinExpression(dialect, "geom1", "geom2") + """ + + def __init__( + self, + dialect: "SQLDialectBase", + geom1: str, + geom2: str, + *, + alias: Optional[str] = None, + ): + super().__init__(dialect) + self.geom1 = geom1 + self.geom2 = geom2 + self.alias = alias + + @property + def format_method(self) -> str: + return "format_st_within" + + +class SQLServerSTContainsExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): + """SQL Server geometry STContains method call. + + Example: + >>> expr = SQLServerSTContainsExpression(dialect, "geom1", "geom2") + """ + + def __init__( + self, + dialect: "SQLDialectBase", + geom1: str, + geom2: str, + *, + alias: Optional[str] = None, + ): + super().__init__(dialect) + self.geom1 = geom1 + self.geom2 = geom2 + self.alias = alias + + @property + def format_method(self) -> str: + return "format_st_contains" + + +class SQLServerSTDistanceSphereExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): + """SQL Server spherical distance check. + + SQL Server has no ST_Distance_Sphere; STDistance is used for planar distance. + + Example: + >>> expr = SQLServerSTDistanceSphereExpression(dialect, "geom1", "geom2") + """ + + def __init__( + self, + dialect: "SQLDialectBase", + geom1: str, + geom2: str, + *, + alias: Optional[str] = None, + ): + super().__init__(dialect) + self.geom1 = geom1 + self.geom2 = geom2 + self.alias = alias + + @property + def format_method(self) -> str: + return "format_st_distance_sphere" + + +class SQLServerSTIntersectsExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): + """SQL Server geometry STIntersects method call. + + Example: + >>> expr = SQLServerSTIntersectsExpression(dialect, "geom1", "geom2") + """ + + def __init__( + self, + dialect: "SQLDialectBase", + geom1: str, + geom2: str, + *, + alias: Optional[str] = None, + ): + super().__init__(dialect) + self.geom1 = geom1 + self.geom2 = geom2 + self.alias = alias + + @property + def format_method(self) -> str: + return "format_st_intersects" + + +class SQLServerCreateSpatialIndexExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): + """SQL Server CREATE SPATIAL INDEX statement. + + Example: + >>> expr = SQLServerCreateSpatialIndexExpression(dialect, "idx", "table", "geom") + """ + + def __init__( + self, + dialect: "SQLDialectBase", + index_name: str, + table_name: str, + column: str, + *, + alias: Optional[str] = None, + ): + super().__init__(dialect) + self.index_name = index_name + self.table_name = table_name + self.column = column + self.alias = alias + + @property + def format_method(self) -> str: + return "format_create_spatial_index" From ae203fe3f60fff390453b5afd7a46d23eb4527f7 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 14 Sep 2026 11:19:18 +0800 Subject: [PATCH 26/58] refactor(sqlserver): rename protocols/json_support.py to json.py Drop the redundant _support suffix from the protocol module name. --- .../backend/impl/sqlserver/dialect.py | 4 + .../backend/impl/sqlserver/expression/json.py | 6 + .../impl/sqlserver/mixins/protocol_support.py | 167 ++++++++++-------- .../backend/impl/sqlserver/protocols.py | 4 + .../impl/sqlserver/protocols/__init__.py | 6 +- .../backend/impl/sqlserver/protocols/json.py | 33 ++++ .../impl/sqlserver/protocols/json_support.py | 15 -- .../impl/sqlserver/protocols/set_type.py | 30 ++++ .../impl/sqlserver/protocols/spatial.py | 67 +++++++ .../feature/backend/test_json_functions.py | 86 ++++++--- .../backend/test_json_functions_backend.py | 8 +- .../feature/backend/test_set_type.py | 57 ++++-- .../feature/backend/test_set_type_backend.py | 26 ++- .../feature/backend/test_spatial_types.py | 52 ++++-- .../backend/test_spatial_types_backend.py | 116 ++++++++---- 15 files changed, 488 insertions(+), 189 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/protocols/json.py delete mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/protocols/json_support.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/protocols/set_type.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/protocols/spatial.py diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index 8ec16d7..a394253 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -49,6 +49,8 @@ SQLServerTableSupport, SQLServerLockingSupport, SQLServerJSONSupport, + SQLServerSetTypeSupport, + SQLServerSpatialSupport, SQLServerTemporalTableSupport, SQLServerSequenceSupport, SQLServerFullTextSearchSupport, @@ -329,6 +331,8 @@ class SQLServerDialect( SQLServerSequenceSupport, SQLServerIdentitySupport, SQLServerIndexedViewSupport, + SQLServerSetTypeSupport, + SQLServerSpatialSupport, SQLServerPartitionSupport, # Before PartitionSupport PartitionSupport, ): diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/json.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/json.py index e5bf9ff..b0db30d 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/json.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/json.py @@ -26,11 +26,13 @@ def __init__( json_doc: str, path: str, *, + paths: Optional[List[str]] = None, alias: Optional[str] = None, ): super().__init__(dialect) self.json_doc = json_doc self.path = path + self.paths = paths or [] self.alias = alias @property @@ -151,12 +153,14 @@ def __init__( path: str, value: Any, *, + path_value_pairs: Optional[List[Tuple[str, Any]]] = None, alias: Optional[str] = None, ): super().__init__(dialect) self.json_doc = json_doc self.path = path self.value = value + self.path_value_pairs = path_value_pairs or [] self.alias = alias @property @@ -177,11 +181,13 @@ def __init__( json_doc: str, path: str, *, + paths: Optional[List[str]] = None, alias: Optional[str] = None, ): super().__init__(dialect) self.json_doc = json_doc self.path = path + self.paths = paths or [] self.alias = alias @property 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 fa828c4..2ecaf77 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/protocol_support.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/protocol_support.py @@ -402,6 +402,14 @@ def format_create_indexed_view_statement(self, expr: Any) -> Tuple[str, tuple]: parts.append(f"WITH SCHEMABINDING AS {query_sql}") return " ".join(parts), query_params + # ========== Shared helpers ========== + + def _with_alias(self, sql: str, alias: Optional[str]) -> str: + """Append ``AS [alias]`` to ``sql`` when an alias is present.""" + if alias: + return f"{sql} AS {self.format_identifier(alias)}" + return sql + # ========== JSON Function Support ========== _JSON_FUNCTION_MIN_VERSION = (13, 0, 0) @@ -423,23 +431,23 @@ def supports_json_function(self, function_name: str) -> bool: return self.version >= (16, 0, 0) return self.version >= self._JSON_FUNCTION_MIN_VERSION - def format_json_extract( - self, json_doc: str, path: str, paths: Optional[List[str]] = None - ) -> Tuple[str, tuple]: + def format_json_extract(self, expr) -> Tuple[str, tuple]: """Format a JSON extraction as JSON_VALUE (single path only). SQL Server's JSON_VALUE returns the scalar value at the path, already unquoted. Multi-path extraction has no direct T-SQL equivalent. """ - if paths: + if expr.paths: raise UnsupportedFeatureError( self.name, "JSON_EXTRACT with multiple paths", "SQL Server's JSON_VALUE extracts a single scalar path only.", ) - return f"JSON_VALUE({json_doc}, {self.get_parameter_placeholder()})", (path,) + placeholder = self.get_parameter_placeholder() + sql = f"JSON_VALUE({expr.json_doc}, {placeholder})" + return self._with_alias(sql, expr.alias), (expr.path,) - def format_json_unquote(self, json_val: str) -> Tuple[str, tuple]: + def format_json_unquote(self, expr) -> Tuple[str, tuple]: """Format JSON_UNQUOTE - not needed in SQL Server. JSON_VALUE already returns scalar values without surrounding quotes, @@ -451,14 +459,15 @@ def format_json_unquote(self, json_val: str) -> Tuple[str, tuple]: "SQL Server's JSON_VALUE returns scalar values already unquoted.", ) - def format_json_object(self, key_value_pairs: List[Tuple[str, Any]]) -> Tuple[str, tuple]: + def format_json_object(self, expr) -> Tuple[str, tuple]: """Format a JSON_OBJECT function (SQL Server 2022+). SQL Syntax: JSON_OBJECT('key1' : value1, 'key2' : value2, ...) """ + key_value_pairs = expr.key_value_pairs if not key_value_pairs: - return "JSON_OBJECT()", () + return self._with_alias("JSON_OBJECT()", expr.alias), () parts = [] params: List[Any] = [] @@ -468,68 +477,63 @@ def format_json_object(self, key_value_pairs: List[Tuple[str, Any]]) -> Tuple[st params.append(key) params.append(value) - return f"JSON_OBJECT({', '.join(parts)})", tuple(params) + sql = f"JSON_OBJECT({', '.join(parts)})" + return self._with_alias(sql, expr.alias), tuple(params) - def format_json_array(self, values: List[Any]) -> Tuple[str, tuple]: + def format_json_array(self, expr) -> Tuple[str, tuple]: """Format a JSON_ARRAY function (SQL Server 2022+).""" + values = expr.values if not values: - return "JSON_ARRAY()", () + return self._with_alias("JSON_ARRAY()", expr.alias), () placeholder = self.get_parameter_placeholder() - return f"JSON_ARRAY({', '.join([placeholder] * len(values))})", tuple(values) + sql = f"JSON_ARRAY({', '.join([placeholder] * len(values))})" + return self._with_alias(sql, expr.alias), tuple(values) - def format_json_contains( - self, target: str, candidate: str, path: Optional[str] = None - ) -> Tuple[str, tuple]: + def format_json_contains(self, expr) -> Tuple[str, tuple]: """Format a JSON containment check using OPENJSON (2016+). SQL Server has no JSON_CONTAINS; membership of ``candidate`` in the JSON array/object at ``path`` is checked with an OPENJSON predicate. """ placeholder = self.get_parameter_placeholder() - json_path = path if path is not None else "$" + json_path = expr.path if expr.path is not None else "$" sql = ( - f"EXISTS (SELECT 1 FROM OPENJSON({target}, {placeholder}) " + f"EXISTS (SELECT 1 FROM OPENJSON({expr.target}, {placeholder}) " f"WHERE value = {placeholder})" ) - return sql, (json_path, candidate) + return self._with_alias(sql, expr.alias), (json_path, expr.candidate) - def format_json_set( - self, - json_doc: str, - path: str, - value: Any, - path_value_pairs: Optional[List[Tuple[str, Any]]] = None, - ) -> Tuple[str, tuple]: + def format_json_set(self, expr) -> Tuple[str, tuple]: """Format a JSON_MODIFY function (single path-value pair only). SQL Server's JSON_MODIFY accepts exactly one path-value pair. """ - if path_value_pairs: + if expr.path_value_pairs: raise UnsupportedFeatureError( self.name, "JSON_SET with multiple path-value pairs", "SQL Server's JSON_MODIFY accepts a single path-value pair.", ) placeholder = self.get_parameter_placeholder() - return f"JSON_MODIFY({json_doc}, {placeholder}, {placeholder})", (path, value) + sql = f"JSON_MODIFY({expr.json_doc}, {placeholder}, {placeholder})" + return self._with_alias(sql, expr.alias), (expr.path, expr.value) - def format_json_remove( - self, json_doc: str, path: str, paths: Optional[List[str]] = None - ) -> Tuple[str, tuple]: + def format_json_remove(self, expr) -> Tuple[str, tuple]: """Format a JSON property removal using JSON_MODIFY (single path only). SQL Server removes a property by setting it to NULL with JSON_MODIFY. """ - if paths: + if expr.paths: raise UnsupportedFeatureError( self.name, "JSON_REMOVE with multiple paths", "SQL Server's JSON_MODIFY accepts a single path.", ) placeholder = self.get_parameter_placeholder() - return f"JSON_MODIFY({json_doc}, {placeholder}, NULL)", (path,) + sql = f"JSON_MODIFY({expr.json_doc}, {placeholder}, NULL)" + return self._with_alias(sql, expr.alias), (expr.path,) - def format_json_type(self, json_val: str) -> Tuple[str, tuple]: + def format_json_type(self, expr) -> Tuple[str, tuple]: """Format JSON_TYPE - not supported by SQL Server.""" raise UnsupportedFeatureError( self.name, @@ -537,9 +541,10 @@ def format_json_type(self, json_val: str) -> Tuple[str, tuple]: "SQL Server has no JSON_TYPE; classify values via OPENJSON.", ) - def format_json_valid(self, json_val: str) -> Tuple[str, tuple]: + def format_json_valid(self, expr) -> Tuple[str, tuple]: """Format an ISJSON function (SQL Server equivalent of JSON_VALID).""" - return f"ISJSON({json_val})", () + sql = f"ISJSON({expr.json_val})" + return self._with_alias(sql, expr.alias), () def format_json_search( self, json_doc: str, search_str: str, path: Optional[str] = None, all_: bool = False @@ -557,14 +562,14 @@ def supports_set_type(self) -> bool: """SQL Server has no MySQL-style SET column type.""" return False - def format_set_literal( - self, values: List[str], column_values: Optional[List[str]] = None - ) -> Tuple[str, tuple]: + def format_set_literal(self, expr) -> Tuple[str, tuple]: """Format a comma-separated value as a bind parameter. SQL Server stores comma-separated values in VARCHAR columns; the literal is passed as a single parameter. """ + values = expr.values + column_values = expr.column_values if len(values) > 64: raise ValueError("SQL Server SET approximation supports maximum 64 members") @@ -575,31 +580,38 @@ def format_set_literal( f"Invalid SET values: {invalid_values}. Allowed values: {column_values}" ) + placeholder = self.get_parameter_placeholder() if not values: - return self.get_parameter_placeholder(), ("",) + return self._with_alias(placeholder, expr.alias), ("",) sorted_values = sorted(values) - return self.get_parameter_placeholder(), (",".join(sorted_values),) + return self._with_alias(placeholder, expr.alias), (",".join(sorted_values),) + + def _format_find_in_set_parts(self, value: str, set_column: str) -> Tuple[str, tuple]: + """Format the comma-delimited membership predicate for one value.""" + placeholder = self.get_parameter_placeholder() + col_sql = self.format_identifier(set_column) + return f"(',' + {col_sql} + ',' LIKE '%,' + {placeholder} + ',%')", (value,) - def format_find_in_set(self, value: str, set_column: str) -> Tuple[str, tuple]: + def format_find_in_set(self, expr) -> Tuple[str, tuple]: """Format a comma-separated membership check. SQL Server has no FIND_IN_SET; membership is checked with a LIKE predicate over the comma-delimited column value. """ - placeholder = self.get_parameter_placeholder() - col_sql = self.format_identifier(set_column) - return f"(',' + {col_sql} + ',' LIKE '%,' + {placeholder} + ',%')", (value,) + sql, params = self._format_find_in_set_parts(expr.value, expr.set_column) + return self._with_alias(sql, expr.alias), params - def format_set_contains(self, column: str, values: List[str]) -> Tuple[str, tuple]: + def format_set_contains(self, expr) -> Tuple[str, tuple]: """Format a set-containment predicate as AND-ed membership checks.""" conditions = [] params: List[str] = [] - for value in values: - condition, value_params = self.format_find_in_set(value, column) + for value in expr.values: + condition, value_params = self._format_find_in_set_parts(value, expr.column) conditions.append(condition) params.extend(value_params) - return " AND ".join(conditions), tuple(params) + sql = " AND ".join(conditions) + return self._with_alias(sql, expr.alias), tuple(params) # ========== Spatial Type Support ========== @@ -627,15 +639,11 @@ def supports_geojson(self) -> bool: """STAsGeoJSON is not available in the installed SQL Server types assembly.""" return False - def format_spatial_literal( - self, wkt: str, srid: Optional[int] = None - ) -> Tuple[str, tuple]: + def format_spatial_literal(self, expr) -> Tuple[str, tuple]: """Format a WKT literal using geometry::STGeomFromText.""" - return self.format_st_geom_from_text(wkt, srid) + return self.format_st_geom_from_text(expr) - def format_st_geom_from_text( - self, wkt: str, srid: Optional[int] = None - ) -> Tuple[str, tuple]: + def format_st_geom_from_text(self, expr) -> Tuple[str, tuple]: """Format a geometry::STGeomFromText call. SQL Server requires the SRID argument; it defaults to 0. @@ -644,45 +652,50 @@ def format_st_geom_from_text( geometry::STGeomFromText(?, ?) """ placeholder = self.get_parameter_placeholder() - if srid is None: - srid = 0 - return f"geometry::STGeomFromText({placeholder}, {placeholder})", (wkt, srid) + srid = expr.srid if expr.srid is not None else 0 + sql = f"geometry::STGeomFromText({placeholder}, {placeholder})" + return self._with_alias(sql, expr.alias), (expr.wkt, srid) - def format_st_as_text(self, geom: str) -> Tuple[str, tuple]: + def format_st_as_text(self, expr) -> Tuple[str, tuple]: """Format a geometry STAsText method call: ``geom.STAsText()``.""" - return f"{geom}.STAsText()", () + sql = f"{expr.geom}.STAsText()" + return self._with_alias(sql, expr.alias), () - def format_st_as_geojson(self, geom: str) -> Tuple[str, tuple]: + def format_st_as_geojson(self, expr) -> Tuple[str, tuple]: """Format a geometry STAsGeoJSON method call: ``geom.STAsGeoJSON()``.""" - return f"{geom}.STAsGeoJSON()", () + sql = f"{expr.geom}.STAsGeoJSON()" + return self._with_alias(sql, expr.alias), () - def format_st_distance(self, geom1: str, geom2: str) -> Tuple[str, tuple]: + def format_st_distance(self, expr) -> Tuple[str, tuple]: """Format a geometry STDistance method call: ``g1.STDistance(g2)``.""" - return f"{geom1}.STDistance({geom2})", () + sql = f"{expr.geom1}.STDistance({expr.geom2})" + return self._with_alias(sql, expr.alias), () - def format_st_within(self, geom1: str, geom2: str) -> Tuple[str, tuple]: + def format_st_within(self, expr) -> Tuple[str, tuple]: """Format a geometry STWithin method call: ``g1.STWithin(g2)``.""" - return f"{geom1}.STWithin({geom2})", () + sql = f"{expr.geom1}.STWithin({expr.geom2})" + return self._with_alias(sql, expr.alias), () - def format_st_contains(self, geom1: str, geom2: str) -> Tuple[str, tuple]: + def format_st_contains(self, expr) -> Tuple[str, tuple]: """Format a geometry STContains method call: ``g1.STContains(g2)``.""" - return f"{geom1}.STContains({geom2})", () + sql = f"{expr.geom1}.STContains({expr.geom2})" + return self._with_alias(sql, expr.alias), () - def format_st_distance_sphere(self, geom1: str, geom2: str) -> Tuple[str, tuple]: + def format_st_distance_sphere(self, expr) -> Tuple[str, tuple]: """Format a spherical distance check. SQL Server has no ST_Distance_Sphere; STDistance is used for the planar distance between the two geometries. """ - return f"{geom1}.STDistance({geom2})", () + sql = f"{expr.geom1}.STDistance({expr.geom2})" + return self._with_alias(sql, expr.alias), () - def format_st_intersects(self, geom1: str, geom2: str) -> Tuple[str, tuple]: + def format_st_intersects(self, expr) -> Tuple[str, tuple]: """Format a geometry STIntersects method call: ``g1.STIntersects(g2)``.""" - return f"{geom1}.STIntersects({geom2})", () + sql = f"{expr.geom1}.STIntersects({expr.geom2})" + return self._with_alias(sql, expr.alias), () - def format_create_spatial_index( - self, index_name: str, table_name: str, column: str - ) -> Tuple[str, tuple]: + def format_create_spatial_index(self, expr) -> Tuple[str, tuple]: """Format a CREATE SPATIAL INDEX statement. SQL Syntax: @@ -697,8 +710,8 @@ def format_create_spatial_index( "requires SQL Server 2008+.", ) sql = ( - f"CREATE SPATIAL INDEX {self.format_identifier(index_name)} " - f"ON {self.format_identifier(table_name)} ({self.format_identifier(column)}) " + f"CREATE SPATIAL INDEX {self.format_identifier(expr.index_name)} " + f"ON {self.format_identifier(expr.table_name)} ({self.format_identifier(expr.column)}) " f"WITH (BOUNDING_BOX = (0, 0, 100, 100))" ) return sql, () diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols.py index a03579a..e1476fd 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols.py @@ -9,6 +9,8 @@ SQLServerTableSupport, SQLServerLockingSupport, SQLServerJSONSupport, + SQLServerSetTypeSupport, + SQLServerSpatialSupport, SQLServerTemporalTableSupport, SQLServerSequenceSupport, SQLServerFullTextSearchSupport, @@ -26,6 +28,8 @@ "SQLServerTableSupport", "SQLServerLockingSupport", "SQLServerJSONSupport", + "SQLServerSetTypeSupport", + "SQLServerSpatialSupport", "SQLServerTemporalTableSupport", "SQLServerSequenceSupport", "SQLServerFullTextSearchSupport", diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/__init__.py index 23fa9b9..00e542c 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/__init__.py @@ -5,7 +5,9 @@ from .output import SQLServerOutputSupport from .table import SQLServerTableSupport from .locking import SQLServerLockingSupport -from .json_support import SQLServerJSONSupport +from .json import SQLServerJSONSupport +from .set_type import SQLServerSetTypeSupport +from .spatial import SQLServerSpatialSupport from .temporal import SQLServerTemporalTableSupport from .sequence import SQLServerSequenceSupport from .fulltext import SQLServerFullTextSearchSupport @@ -22,6 +24,8 @@ "SQLServerTableSupport", "SQLServerLockingSupport", "SQLServerJSONSupport", + "SQLServerSetTypeSupport", + "SQLServerSpatialSupport", "SQLServerTemporalTableSupport", "SQLServerSequenceSupport", "SQLServerFullTextSearchSupport", diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/json.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/json.py new file mode 100644 index 0000000..1d03d00 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/json.py @@ -0,0 +1,33 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/protocols/json.py +from typing import Protocol, Tuple, Optional, Dict, runtime_checkable + +from rhosocial.activerecord.backend.dialect.protocols import JSONSupport + + +@runtime_checkable +class SQLServerJSONSupport(JSONSupport, Protocol): + def format_openjson_expression( + self, + json_doc: str, + path: Optional[str] = None, + schema: Optional[Dict[str, str]] = None, + alias: Optional[str] = None, + ) -> Tuple[str, tuple]: ... + + def format_json_extract(self, expr) -> Tuple[str, tuple]: ... + + def format_json_unquote(self, expr) -> Tuple[str, tuple]: ... + + def format_json_object(self, expr) -> Tuple[str, tuple]: ... + + def format_json_array(self, expr) -> Tuple[str, tuple]: ... + + def format_json_contains(self, expr) -> Tuple[str, tuple]: ... + + def format_json_set(self, expr) -> Tuple[str, tuple]: ... + + def format_json_remove(self, expr) -> Tuple[str, tuple]: ... + + def format_json_type(self, expr) -> Tuple[str, tuple]: ... + + def format_json_valid(self, expr) -> Tuple[str, tuple]: ... diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/json_support.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/json_support.py deleted file mode 100644 index dbfcb86..0000000 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/json_support.py +++ /dev/null @@ -1,15 +0,0 @@ -# src/rhosocial/activerecord/backend/impl/sqlserver/protocols/json_support.py -from typing import Protocol, Tuple, Optional, Dict, runtime_checkable - -from rhosocial.activerecord.backend.dialect.protocols import JSONSupport - - -@runtime_checkable -class SQLServerJSONSupport(JSONSupport, Protocol): - def format_openjson_expression( - self, - json_doc: str, - path: Optional[str] = None, - schema: Optional[Dict[str, str]] = None, - alias: Optional[str] = None, - ) -> Tuple[str, tuple]: ... \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/set_type.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/set_type.py new file mode 100644 index 0000000..e60143f --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/set_type.py @@ -0,0 +1,30 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/protocols/set_type.py +"""SQL Server SET type protocol. + +SQL Server has no MySQL-style SET column type; comma-separated values are +stored in VARCHAR columns. The formatters accept the corresponding +expression nodes and read their state off ``expr.``. +""" + +from typing import Protocol, Tuple, runtime_checkable + + +@runtime_checkable +class SQLServerSetTypeSupport(Protocol): + """SQL Server SET type approximation protocol.""" + + def supports_set_type(self) -> bool: + """Whether the MySQL-style SET type is supported (always False).""" + ... + + def format_set_literal(self, expr) -> Tuple[str, tuple]: + """Format a :class:`SQLServerSetLiteralExpression` node.""" + ... + + def format_find_in_set(self, expr) -> Tuple[str, tuple]: + """Format a :class:`SQLServerFindInSetExpression` node.""" + ... + + def format_set_contains(self, expr) -> Tuple[str, tuple]: + """Format a :class:`SQLServerSetContainsExpression` node.""" + ... diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/spatial.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/spatial.py new file mode 100644 index 0000000..4072191 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/spatial.py @@ -0,0 +1,67 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/protocols/spatial.py +"""SQL Server spatial data type protocol. + +SQL Server exposes spatial operations through the ``geometry``/``geography`` +types (``geometry::STGeomFromText``, ``geom.STAsText()``, ...). Each +formatter accepts its expression node and reads the state off +``expr.``. +""" + +from typing import Protocol, Tuple, runtime_checkable + + +@runtime_checkable +class SQLServerSpatialSupport(Protocol): + """SQL Server spatial data type protocol.""" + + def supports_spatial_type(self, type_name: str) -> bool: + """Whether a specific spatial data type is supported (2008+).""" + ... + + def supports_spatial_index(self) -> bool: + """Whether SPATIAL indexes are supported (2008+).""" + ... + + def supports_geojson(self) -> bool: + """Whether STAsGeoJSON is available in the installed types assembly.""" + ... + + def format_spatial_literal(self, expr) -> Tuple[str, tuple]: + """Format a :class:`SQLServerSpatialLiteralExpression` node.""" + ... + + def format_st_geom_from_text(self, expr) -> Tuple[str, tuple]: + """Format a :class:`SQLServerSTGeomFromTextExpression` node.""" + ... + + def format_st_as_text(self, expr) -> Tuple[str, tuple]: + """Format a :class:`SQLServerSTAsTextExpression` node.""" + ... + + def format_st_as_geojson(self, expr) -> Tuple[str, tuple]: + """Format a :class:`SQLServerSTAsGeoJSONExpression` node.""" + ... + + def format_st_distance(self, expr) -> Tuple[str, tuple]: + """Format a :class:`SQLServerSTDistanceExpression` node.""" + ... + + def format_st_within(self, expr) -> Tuple[str, tuple]: + """Format a :class:`SQLServerSTWithinExpression` node.""" + ... + + def format_st_contains(self, expr) -> Tuple[str, tuple]: + """Format a :class:`SQLServerSTContainsExpression` node.""" + ... + + def format_st_distance_sphere(self, expr) -> Tuple[str, tuple]: + """Format a :class:`SQLServerSTDistanceSphereExpression` node.""" + ... + + def format_st_intersects(self, expr) -> Tuple[str, tuple]: + """Format a :class:`SQLServerSTIntersectsExpression` node.""" + ... + + def format_create_spatial_index(self, expr) -> Tuple[str, tuple]: + """Format a :class:`SQLServerCreateSpatialIndexExpression` node.""" + ... diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_json_functions.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_json_functions.py index 9492ba0..5ee0f04 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_json_functions.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_json_functions.py @@ -13,6 +13,17 @@ 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.json import ( + SQLServerJSONArrayExpression, + SQLServerJSONContainsExpression, + SQLServerJSONExtractExpression, + SQLServerJSONObjectExpression, + SQLServerJSONRemoveExpression, + SQLServerJSONSetExpression, + SQLServerJSONTypeExpression, + SQLServerJSONUnquoteExpression, + SQLServerJSONValidExpression, +) SQL_SERVER_2014 = (12, 0, 0) SQL_SERVER_2016 = (13, 0, 0) @@ -54,7 +65,8 @@ def test_format_json_extract_single_path(self): """Test JSON_VALUE extraction with a single path.""" dialect = SQLServerDialect(version=SQL_SERVER_2016) - sql, params = dialect.format_json_extract('data', '$.name') + expr = SQLServerJSONExtractExpression(dialect, 'data', '$.name') + sql, params = expr.to_sql() assert sql == 'JSON_VALUE(data, ?)' assert params == ('$.name',) @@ -63,21 +75,25 @@ def test_format_json_extract_multiple_paths(self): """Test JSON_VALUE raises for multiple paths (no T-SQL equivalent).""" dialect = SQLServerDialect(version=SQL_SERVER_2016) + expr = SQLServerJSONExtractExpression( + dialect, 'data', '$.name', paths=['$.age', '$.city'] + ) with pytest.raises(UnsupportedFeatureError): - dialect.format_json_extract('data', '$.name', ['$.age', '$.city']) + expr.to_sql() def test_format_json_unquote(self): """Test JSON_UNQUOTE is not supported (JSON_VALUE returns scalars unquoted).""" dialect = SQLServerDialect(version=SQL_SERVER_2016) + expr = SQLServerJSONUnquoteExpression(dialect, 'data') with pytest.raises(UnsupportedFeatureError): - dialect.format_json_unquote('data') + expr.to_sql() def test_format_json_object_empty(self): """Test JSON_OBJECT with no arguments.""" dialect = SQLServerDialect(version=SQL_SERVER_2022) - sql, params = dialect.format_json_object([]) + sql, params = SQLServerJSONObjectExpression(dialect, []).to_sql() assert sql == 'JSON_OBJECT()' assert params == () @@ -86,7 +102,9 @@ def test_format_json_object_single_pair(self): """Test JSON_OBJECT with single key-value pair.""" dialect = SQLServerDialect(version=SQL_SERVER_2022) - sql, params = dialect.format_json_object([('name', 'John')]) + sql, params = SQLServerJSONObjectExpression( + dialect, [('name', 'John')] + ).to_sql() assert sql == 'JSON_OBJECT(? : ?)' assert params == ('name', 'John') @@ -95,7 +113,9 @@ def test_format_json_object_multiple_pairs(self): """Test JSON_OBJECT with multiple key-value pairs.""" dialect = SQLServerDialect(version=SQL_SERVER_2022) - sql, params = dialect.format_json_object([('name', 'John'), ('age', 30), ('city', 'NYC')]) + sql, params = SQLServerJSONObjectExpression( + dialect, [('name', 'John'), ('age', 30), ('city', 'NYC')] + ).to_sql() assert sql == 'JSON_OBJECT(? : ?, ? : ?, ? : ?)' assert params == ('name', 'John', 'age', 30, 'city', 'NYC') @@ -104,7 +124,7 @@ def test_format_json_array_empty(self): """Test JSON_ARRAY with no arguments.""" dialect = SQLServerDialect(version=SQL_SERVER_2022) - sql, params = dialect.format_json_array([]) + sql, params = SQLServerJSONArrayExpression(dialect, []).to_sql() assert sql == 'JSON_ARRAY()' assert params == () @@ -113,7 +133,7 @@ def test_format_json_array_single_value(self): """Test JSON_ARRAY with single value.""" dialect = SQLServerDialect(version=SQL_SERVER_2022) - sql, params = dialect.format_json_array([1]) + sql, params = SQLServerJSONArrayExpression(dialect, [1]).to_sql() assert sql == 'JSON_ARRAY(?)' assert params == (1,) @@ -122,7 +142,9 @@ def test_format_json_array_multiple_values(self): """Test JSON_ARRAY with multiple values.""" dialect = SQLServerDialect(version=SQL_SERVER_2022) - sql, params = dialect.format_json_array([1, 'hello', None, True]) + sql, params = SQLServerJSONArrayExpression( + dialect, [1, 'hello', None, True] + ).to_sql() assert sql == 'JSON_ARRAY(?, ?, ?, ?)' assert params == (1, 'hello', None, True) @@ -131,7 +153,8 @@ def test_format_json_contains_no_path(self): """Test OPENJSON containment check without path.""" dialect = SQLServerDialect(version=SQL_SERVER_2016) - sql, params = dialect.format_json_contains('data', '{"name": "John"}') + expr = SQLServerJSONContainsExpression(dialect, 'data', '{"name": "John"}') + sql, params = expr.to_sql() assert 'EXISTS' in sql assert 'OPENJSON(data, ?)' in sql @@ -141,7 +164,8 @@ def test_format_json_contains_with_path(self): """Test OPENJSON containment check with path.""" dialect = SQLServerDialect(version=SQL_SERVER_2016) - sql, params = dialect.format_json_contains('data', 'sqlserver', '$.tags') + expr = SQLServerJSONContainsExpression(dialect, 'data', 'sqlserver', '$.tags') + sql, params = expr.to_sql() assert 'EXISTS' in sql assert 'OPENJSON(data, ?)' in sql @@ -151,7 +175,8 @@ def test_format_json_set_single_pair(self): """Test JSON_MODIFY with single path-value pair.""" dialect = SQLServerDialect(version=SQL_SERVER_2016) - sql, params = dialect.format_json_set('data', '$.name', 'John') + expr = SQLServerJSONSetExpression(dialect, 'data', '$.name', 'John') + sql, params = expr.to_sql() assert sql == 'JSON_MODIFY(data, ?, ?)' assert params == ('$.name', 'John') @@ -160,17 +185,19 @@ def test_format_json_set_multiple_pairs(self): """Test JSON_MODIFY raises for multiple path-value pairs.""" dialect = SQLServerDialect(version=SQL_SERVER_2016) + expr = SQLServerJSONSetExpression( + dialect, 'data', '$.name', 'John', + path_value_pairs=[('$.age', 30), ('$.city', 'NYC')], + ) with pytest.raises(UnsupportedFeatureError): - dialect.format_json_set( - 'data', '$.name', 'John', - path_value_pairs=[('$.age', 30), ('$.city', 'NYC')] - ) + expr.to_sql() def test_format_json_remove_single_path(self): """Test JSON_MODIFY property removal with a single path.""" dialect = SQLServerDialect(version=SQL_SERVER_2016) - sql, params = dialect.format_json_remove('data', '$.temp') + expr = SQLServerJSONRemoveExpression(dialect, 'data', '$.temp') + sql, params = expr.to_sql() assert sql == 'JSON_MODIFY(data, ?, NULL)' assert params == ('$.temp',) @@ -179,21 +206,25 @@ def test_format_json_remove_multiple_paths(self): """Test JSON_MODIFY removal raises for multiple paths.""" dialect = SQLServerDialect(version=SQL_SERVER_2016) + expr = SQLServerJSONRemoveExpression( + dialect, 'data', '$.temp', paths=['$.cache', '$.old'] + ) with pytest.raises(UnsupportedFeatureError): - dialect.format_json_remove('data', '$.temp', paths=['$.cache', '$.old']) + expr.to_sql() def test_format_json_type(self): """Test JSON_TYPE is not supported by SQL Server.""" dialect = SQLServerDialect(version=SQL_SERVER_2016) + expr = SQLServerJSONTypeExpression(dialect, 'data') with pytest.raises(UnsupportedFeatureError): - dialect.format_json_type('data') + expr.to_sql() def test_format_json_valid(self): """Test ISJSON formatting.""" dialect = SQLServerDialect(version=SQL_SERVER_2016) - sql, params = dialect.format_json_valid('data') + sql, params = SQLServerJSONValidExpression(dialect, 'data').to_sql() assert sql == 'ISJSON(data)' assert params == () @@ -234,7 +265,8 @@ async def test_async_format_json_extract(self): """Test async version of JSON_VALUE formatting.""" dialect = SQLServerDialect(version=SQL_SERVER_2016) - sql, params = dialect.format_json_extract('data', '$.name') + expr = SQLServerJSONExtractExpression(dialect, 'data', '$.name') + sql, params = expr.to_sql() assert 'JSON_VALUE' in sql assert params == ('$.name',) @@ -244,7 +276,9 @@ async def test_async_format_json_object(self): """Test async version of JSON_OBJECT formatting.""" dialect = SQLServerDialect(version=SQL_SERVER_2022) - sql, params = dialect.format_json_object([('key', 'value')]) + sql, params = SQLServerJSONObjectExpression( + dialect, [('key', 'value')] + ).to_sql() assert 'JSON_OBJECT' in sql assert params == ('key', 'value') @@ -254,7 +288,7 @@ async def test_async_format_json_array(self): """Test async version of JSON_ARRAY formatting.""" dialect = SQLServerDialect(version=SQL_SERVER_2022) - sql, params = dialect.format_json_array([1, 2, 3]) + sql, params = SQLServerJSONArrayExpression(dialect, [1, 2, 3]).to_sql() assert 'JSON_ARRAY' in sql assert params == (1, 2, 3) @@ -264,7 +298,8 @@ async def test_async_format_json_contains(self): """Test async version of OPENJSON containment check.""" dialect = SQLServerDialect(version=SQL_SERVER_2016) - sql, params = dialect.format_json_contains('data', 'value', '$.path') + expr = SQLServerJSONContainsExpression(dialect, 'data', 'value', '$.path') + sql, params = expr.to_sql() assert 'OPENJSON' in sql assert 'value' in params @@ -274,7 +309,8 @@ async def test_async_format_json_set(self): """Test async version of JSON_MODIFY formatting.""" dialect = SQLServerDialect(version=SQL_SERVER_2016) - sql, params = dialect.format_json_set('data', '$.key', 'value') + expr = SQLServerJSONSetExpression(dialect, 'data', '$.key', 'value') + sql, params = expr.to_sql() assert 'JSON_MODIFY' in sql assert '$.key' in params diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_json_functions_backend.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_json_functions_backend.py index 641c5ac..4d0603e 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_json_functions_backend.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_json_functions_backend.py @@ -9,6 +9,10 @@ """ import pytest +from rhosocial.activerecord.backend.impl.sqlserver.expression.json import ( + SQLServerJSONExtractExpression, +) + class TestSQLServerJSONFunctionBackend: """Synchronous tests for SQL Server JSON functions with real database.""" @@ -136,7 +140,7 @@ def test_format_json_extract_integration(self, sqlserver_backend): ) dialect = sqlserver_backend.dialect - sql, params = dialect.format_json_extract('data', '$.name') + sql, params = SQLServerJSONExtractExpression(dialect, 'data', '$.name').to_sql() result = sqlserver_backend.execute( f"SELECT {sql} as name FROM #test_format_json_extract", @@ -281,7 +285,7 @@ async def test_async_format_json_extract_integration(self, async_sqlserver_backe ) dialect = async_sqlserver_backend.dialect - sql, params = dialect.format_json_extract('data', '$.name') + sql, params = SQLServerJSONExtractExpression(dialect, 'data', '$.name').to_sql() result = await async_sqlserver_backend.execute( f"SELECT {sql} as name FROM #test_async_format_json_extract", diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_set_type.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_set_type.py index 25a160c..0cb4dd8 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_set_type.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_set_type.py @@ -8,6 +8,11 @@ """ import pytest from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect +from rhosocial.activerecord.backend.impl.sqlserver.expression.set_type import ( + SQLServerFindInSetExpression, + SQLServerSetContainsExpression, + SQLServerSetLiteralExpression, +) class TestSetTypeProtocol: @@ -28,7 +33,7 @@ def test_format_set_literal_single_value(self): """Test comma-separated literal with single value.""" dialect = SQLServerDialect(version=(16, 0, 0)) - sql, params = dialect.format_set_literal(['value1']) + sql, params = SQLServerSetLiteralExpression(dialect, ['value1']).to_sql() assert sql == '?' assert params == ('value1',) @@ -37,7 +42,9 @@ def test_format_set_literal_multiple_values(self): """Test comma-separated literal with multiple values.""" dialect = SQLServerDialect(version=(16, 0, 0)) - sql, params = dialect.format_set_literal(['value3', 'value1', 'value2']) + sql, params = SQLServerSetLiteralExpression( + dialect, ['value3', 'value1', 'value2'] + ).to_sql() assert sql == '?' assert params == ('value1,value2,value3',) @@ -46,7 +53,7 @@ def test_format_set_literal_empty(self): """Test comma-separated literal with empty list.""" dialect = SQLServerDialect(version=(16, 0, 0)) - sql, params = dialect.format_set_literal([]) + sql, params = SQLServerSetLiteralExpression(dialect, []).to_sql() assert sql == '?' assert params == ('',) @@ -56,7 +63,9 @@ def test_format_set_literal_with_validation(self): dialect = SQLServerDialect(version=(16, 0, 0)) column_values = ['red', 'green', 'blue'] - sql, params = dialect.format_set_literal(['red', 'blue'], column_values) + sql, params = SQLServerSetLiteralExpression( + dialect, ['red', 'blue'], column_values + ).to_sql() assert sql == '?' assert params == ('blue,red',) @@ -66,23 +75,25 @@ def test_format_set_literal_invalid_value_raises_error(self): dialect = SQLServerDialect(version=(16, 0, 0)) column_values = ['red', 'green', 'blue'] + expr = SQLServerSetLiteralExpression(dialect, ['red', 'yellow'], column_values) with pytest.raises(ValueError, match="Invalid SET values"): - dialect.format_set_literal(['red', 'yellow'], column_values) + expr.to_sql() def test_format_set_literal_max_members_exceeded(self): """Test that exceeding 64 members raises error.""" dialect = SQLServerDialect(version=(16, 0, 0)) values = [f'val{i}' for i in range(65)] + expr = SQLServerSetLiteralExpression(dialect, values) with pytest.raises(ValueError, match="maximum 64 members"): - dialect.format_set_literal(values) + expr.to_sql() def test_format_set_literal_max_members_allowed(self): """Test that 64 members is allowed.""" dialect = SQLServerDialect(version=(16, 0, 0)) values = [f'val{i:02d}' for i in range(64)] - sql, params = dialect.format_set_literal(values) + sql, params = SQLServerSetLiteralExpression(dialect, values).to_sql() assert sql == '?' assert len(params[0].split(',')) == 64 @@ -91,7 +102,9 @@ def test_format_find_in_set(self): """Test comma-separated membership check formatting.""" dialect = SQLServerDialect(version=(16, 0, 0)) - sql, params = dialect.format_find_in_set('value1', 'tags') + sql, params = SQLServerFindInSetExpression( + dialect, 'value1', 'tags' + ).to_sql() assert 'LIKE' in sql assert '[tags]' in sql @@ -101,7 +114,9 @@ def test_format_find_in_set_different_column(self): """Test membership check with different column name.""" dialect = SQLServerDialect(version=(16, 0, 0)) - sql, params = dialect.format_find_in_set('active', 'status') + sql, params = SQLServerFindInSetExpression( + dialect, 'active', 'status' + ).to_sql() assert 'LIKE' in sql assert '[status]' in sql @@ -111,7 +126,9 @@ def test_format_set_contains_single_value(self): """Test set contains check with single value.""" dialect = SQLServerDialect(version=(16, 0, 0)) - sql, params = dialect.format_set_contains('tags', ['value1']) + sql, params = SQLServerSetContainsExpression( + dialect, 'tags', ['value1'] + ).to_sql() assert 'LIKE' in sql assert '[tags]' in sql @@ -121,7 +138,9 @@ def test_format_set_contains_multiple_values(self): """Test set contains check with multiple values.""" dialect = SQLServerDialect(version=(16, 0, 0)) - sql, params = dialect.format_set_contains('tags', ['value1', 'value2']) + sql, params = SQLServerSetContainsExpression( + dialect, 'tags', ['value1', 'value2'] + ).to_sql() assert 'LIKE' in sql assert ' AND ' in sql @@ -131,7 +150,9 @@ def test_format_set_contains_three_values(self): """Test set contains check with three values.""" dialect = SQLServerDialect(version=(16, 0, 0)) - sql, params = dialect.format_set_contains('status', ['active', 'pending', 'verified']) + sql, params = SQLServerSetContainsExpression( + dialect, 'status', ['active', 'pending', 'verified'] + ).to_sql() assert sql.count('LIKE') == 3 assert sql.count(' AND ') == 2 @@ -152,7 +173,9 @@ async def test_async_format_set_literal(self): """Test async version of comma-separated literal formatting.""" dialect = SQLServerDialect(version=(16, 0, 0)) - sql, params = dialect.format_set_literal(['a', 'b', 'c']) + sql, params = SQLServerSetLiteralExpression( + dialect, ['a', 'b', 'c'] + ).to_sql() assert sql == '?' assert params == ('a,b,c',) @@ -162,7 +185,9 @@ async def test_async_format_find_in_set(self): """Test async version of membership check formatting.""" dialect = SQLServerDialect(version=(16, 0, 0)) - sql, params = dialect.format_find_in_set('test', 'column') + sql, params = SQLServerFindInSetExpression( + dialect, 'test', 'column' + ).to_sql() assert 'LIKE' in sql assert params == ('test',) @@ -172,7 +197,9 @@ async def test_async_format_set_contains(self): """Test async version of set contains formatting.""" dialect = SQLServerDialect(version=(16, 0, 0)) - sql, params = dialect.format_set_contains('tags', ['a', 'b']) + sql, params = SQLServerSetContainsExpression( + dialect, 'tags', ['a', 'b'] + ).to_sql() assert ' AND ' in sql assert params == ('a', 'b') diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_set_type_backend.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_set_type_backend.py index 87ae70c..a4d839e 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_set_type_backend.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_set_type_backend.py @@ -7,6 +7,12 @@ """ import pytest +from rhosocial.activerecord.backend.impl.sqlserver.expression.set_type import ( + SQLServerFindInSetExpression, + SQLServerSetContainsExpression, + SQLServerSetLiteralExpression, +) + class TestSQLServerSetTypeBackend: """Synchronous tests for comma-separated values with real database.""" @@ -111,7 +117,9 @@ def test_format_set_literal_integration(self, sqlserver_backend): """) dialect = sqlserver_backend.dialect - sql_literal, params = dialect.format_set_literal(['red', 'blue'], ['red', 'green', 'blue']) + sql_literal, params = SQLServerSetLiteralExpression( + dialect, ['red', 'blue'], ['red', 'green', 'blue'] + ).to_sql() sqlserver_backend.execute( f"INSERT INTO #test_set_literal (colors) VALUES ({sql_literal})", @@ -140,7 +148,7 @@ def test_format_find_in_set_integration(self, sqlserver_backend): sqlserver_backend.execute("INSERT INTO #test_find_format (tags) VALUES ('a,c')") dialect = sqlserver_backend.dialect - condition, params = dialect.format_find_in_set('a', 'tags') + condition, params = SQLServerFindInSetExpression(dialect, 'a', 'tags').to_sql() result = sqlserver_backend.execute( f"SELECT id, tags FROM #test_find_format WHERE {condition}", @@ -168,7 +176,9 @@ def test_format_set_contains_integration(self, sqlserver_backend): sqlserver_backend.execute("INSERT INTO #test_contains_format (permissions) VALUES ('read,write,admin')") dialect = sqlserver_backend.dialect - condition, params = dialect.format_set_contains('permissions', ['read', 'write']) + condition, params = SQLServerSetContainsExpression( + dialect, 'permissions', ['read', 'write'] + ).to_sql() result = sqlserver_backend.execute( f"SELECT id, permissions FROM #test_contains_format WHERE {condition}", @@ -323,7 +333,9 @@ async def test_async_format_set_literal_integration(self, async_sqlserver_backen """) dialect = async_sqlserver_backend.dialect - sql_literal, params = dialect.format_set_literal(['green', 'red'], ['red', 'green', 'blue']) + sql_literal, params = SQLServerSetLiteralExpression( + dialect, ['green', 'red'], ['red', 'green', 'blue'] + ).to_sql() await async_sqlserver_backend.execute( f"INSERT INTO #test_async_set_literal (colors) VALUES ({sql_literal})", @@ -352,7 +364,7 @@ async def test_async_format_find_in_set_integration(self, async_sqlserver_backen await async_sqlserver_backend.execute("INSERT INTO #test_async_find_format (tags) VALUES ('z')") dialect = async_sqlserver_backend.dialect - condition, params = dialect.format_find_in_set('x', 'tags') + condition, params = SQLServerFindInSetExpression(dialect, 'x', 'tags').to_sql() result = await async_sqlserver_backend.execute( f"SELECT id, tags FROM #test_async_find_format WHERE {condition}", @@ -379,7 +391,9 @@ async def test_async_format_set_contains_integration(self, async_sqlserver_backe await async_sqlserver_backend.execute("INSERT INTO #test_async_contains (roles) VALUES ('admin,moderator')") dialect = async_sqlserver_backend.dialect - condition, params = dialect.format_set_contains('roles', ['admin']) + condition, params = SQLServerSetContainsExpression( + dialect, 'roles', ['admin'] + ).to_sql() result = await async_sqlserver_backend.execute( f"SELECT id, roles FROM #test_async_contains WHERE {condition}", diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_spatial_types.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_spatial_types.py index 6eeebb4..ac8af92 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_spatial_types.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_spatial_types.py @@ -9,6 +9,18 @@ - SPATIAL index creation """ from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect +from rhosocial.activerecord.backend.impl.sqlserver.expression.spatial import ( + SQLServerCreateSpatialIndexExpression, + SQLServerSpatialLiteralExpression, + SQLServerSTAsGeoJSONExpression, + SQLServerSTAsTextExpression, + SQLServerSTContainsExpression, + SQLServerSTDistanceExpression, + SQLServerSTDistanceSphereExpression, + SQLServerSTGeomFromTextExpression, + SQLServerSTIntersectsExpression, + SQLServerSTWithinExpression, +) class TestSpatialTypeProtocol: @@ -63,14 +75,16 @@ class TestSpatialLiteralFormatting: def test_format_spatial_literal(self): """Test spatial literal formatting.""" dialect = SQLServerDialect(version=(10, 3, 0)) - sql, params = dialect.format_spatial_literal("POINT(1 1)") + expr = SQLServerSpatialLiteralExpression(dialect, "POINT(1 1)") + sql, params = expr.to_sql() assert "STGeomFromText" in sql assert params == ("POINT(1 1)", 0) def test_format_spatial_literal_with_srid(self): """Test spatial literal formatting with SRID.""" dialect = SQLServerDialect(version=(10, 3, 0)) - sql, params = dialect.format_spatial_literal("POINT(1 1)", srid=4326) + expr = SQLServerSpatialLiteralExpression(dialect, "POINT(1 1)", srid=4326) + sql, params = expr.to_sql() assert "STGeomFromText" in sql assert 4326 in params @@ -81,52 +95,60 @@ class TestSpatialFunctionFormatting: def test_format_st_geom_from_text(self): """Test ST_GeomFromText formatting.""" dialect = SQLServerDialect(version=(10, 3, 0)) - sql, params = dialect.format_st_geom_from_text("POINT(1 1)") + expr = SQLServerSTGeomFromTextExpression(dialect, "POINT(1 1)") + sql, params = expr.to_sql() assert "STGeomFromText" in sql assert params == ("POINT(1 1)", 0) def test_format_st_as_text(self): """Test STAsText formatting.""" dialect = SQLServerDialect(version=(10, 3, 0)) - sql, params = dialect.format_st_as_text("geom_col") + expr = SQLServerSTAsTextExpression(dialect, "geom_col") + sql, params = expr.to_sql() assert "STAsText" in sql assert "geom_col" in sql def test_format_st_as_geojson(self): """Test STAsGeoJSON formatting.""" dialect = SQLServerDialect(version=(10, 3, 0)) - sql, params = dialect.format_st_as_geojson("geom_col") + expr = SQLServerSTAsGeoJSONExpression(dialect, "geom_col") + sql, params = expr.to_sql() assert "STAsGeoJSON" in sql assert "geom_col" in sql def test_format_st_distance(self): """Test STDistance formatting.""" dialect = SQLServerDialect(version=(10, 3, 0)) - sql, params = dialect.format_st_distance("geom1", "geom2") + expr = SQLServerSTDistanceExpression(dialect, "geom1", "geom2") + sql, params = expr.to_sql() assert "STDistance" in sql def test_format_st_within(self): """Test STWithin formatting.""" dialect = SQLServerDialect(version=(10, 3, 0)) - sql, params = dialect.format_st_within("geom1", "geom2") + expr = SQLServerSTWithinExpression(dialect, "geom1", "geom2") + sql, params = expr.to_sql() assert "STWithin" in sql def test_format_st_contains(self): """Test STContains formatting.""" dialect = SQLServerDialect(version=(10, 3, 0)) - sql, params = dialect.format_st_contains("geom1", "geom2") + expr = SQLServerSTContainsExpression(dialect, "geom1", "geom2") + sql, params = expr.to_sql() assert "STContains" in sql def test_format_st_distance_sphere(self): """Test spherical distance formatting (STDistance approximation).""" dialect = SQLServerDialect(version=(10, 3, 0)) - sql, params = dialect.format_st_distance_sphere("geom1", "geom2") + expr = SQLServerSTDistanceSphereExpression(dialect, "geom1", "geom2") + sql, params = expr.to_sql() assert "STDistance" in sql def test_format_st_intersects(self): """Test STIntersects formatting.""" dialect = SQLServerDialect(version=(10, 3, 0)) - sql, params = dialect.format_st_intersects("geom1", "geom2") + expr = SQLServerSTIntersectsExpression(dialect, "geom1", "geom2") + sql, params = expr.to_sql() assert "STIntersects" in sql @@ -136,7 +158,10 @@ class TestSpatialIndexFormatting: def test_format_create_spatial_index(self): """Test CREATE SPATIAL INDEX formatting.""" dialect = SQLServerDialect(version=(10, 3, 0)) - sql, params = dialect.format_create_spatial_index("idx_spatial", "test_table", "geom_col") + expr = SQLServerCreateSpatialIndexExpression( + dialect, "idx_spatial", "test_table", "geom_col" + ) + sql, params = expr.to_sql() assert "SPATIAL INDEX" in sql assert "idx_spatial" in sql assert "test_table" in sql @@ -146,5 +171,8 @@ def test_format_create_spatial_index_unsupported(self): """Test CREATE SPATIAL INDEX does not raise for supported versions.""" # SQL Server supports spatial indexes since 2008, so this should not raise dialect = SQLServerDialect(version=(10, 3, 0)) - sql, params = dialect.format_create_spatial_index("idx_spatial", "test_table", "geom_col") + expr = SQLServerCreateSpatialIndexExpression( + dialect, "idx_spatial", "test_table", "geom_col" + ) + sql, params = expr.to_sql() assert "SPATIAL INDEX" in sql diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_spatial_types_backend.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_spatial_types_backend.py index 44afcc0..9b5fa18 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_spatial_types_backend.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_spatial_types_backend.py @@ -8,6 +8,16 @@ """ import pytest +from rhosocial.activerecord.backend.impl.sqlserver.expression.spatial import ( + SQLServerCreateSpatialIndexExpression, + SQLServerSpatialLiteralExpression, + SQLServerSTContainsExpression, + SQLServerSTDistanceExpression, + SQLServerSTGeomFromTextExpression, + SQLServerSTAsTextExpression, + SQLServerSTWithinExpression, +) + class TestSQLServerSpatialTypeBackend: """Synchronous tests for SQL Server spatial types with real database.""" @@ -47,7 +57,7 @@ def test_format_spatial_literal_without_srid(self, sqlserver_backend): """) dialect = sqlserver_backend.dialect - sql, params = dialect.format_spatial_literal('POINT(5 5)') + sql, params = SQLServerSpatialLiteralExpression(dialect, 'POINT(5 5)').to_sql() sqlserver_backend.execute( f"INSERT INTO #test_spatial_literal (location) VALUES ({sql})", @@ -73,7 +83,9 @@ def test_format_spatial_literal_with_srid(self, sqlserver_backend): """) dialect = sqlserver_backend.dialect - sql, params = dialect.format_spatial_literal('POINT(10 20)', 4326) + sql, params = SQLServerSpatialLiteralExpression( + dialect, 'POINT(10 20)', srid=4326 + ).to_sql() sqlserver_backend.execute( f"INSERT INTO #test_spatial_srid (location) VALUES ({sql})", @@ -93,7 +105,7 @@ def test_format_spatial_literal_with_srid(self, sqlserver_backend): def test_format_st_geom_from_text_without_srid(self, sqlserver_backend): """Test format_st_geom_from_text generates correct SQL without SRID.""" dialect = sqlserver_backend.dialect - sql, params = dialect.format_st_geom_from_text('POINT(3 4)') + sql, params = SQLServerSTGeomFromTextExpression(dialect, 'POINT(3 4)').to_sql() result = sqlserver_backend.execute( f"SELECT {sql}.STAsText() as wkt", @@ -106,7 +118,9 @@ def test_format_st_geom_from_text_without_srid(self, sqlserver_backend): def test_format_st_geom_from_text_with_srid(self, sqlserver_backend): """Test format_st_geom_from_text generates correct SQL with SRID.""" dialect = sqlserver_backend.dialect - sql, params = dialect.format_st_geom_from_text('POINT(1 1)', 4326) + sql, params = SQLServerSTGeomFromTextExpression( + dialect, 'POINT(1 1)', srid=4326 + ).to_sql() result = sqlserver_backend.execute( f"SELECT {sql}.STSrid as srid", @@ -129,7 +143,7 @@ def test_format_st_as_text(self, sqlserver_backend): ) dialect = sqlserver_backend.dialect - sql, params = dialect.format_st_as_text('location') + sql, params = SQLServerSTAsTextExpression(dialect, 'location').to_sql() result = sqlserver_backend.execute( f"SELECT {sql} as wkt FROM #test_astext", @@ -151,10 +165,16 @@ def test_format_st_distance(self, sqlserver_backend): """Test format_st_distance generates correct SQL.""" dialect = sqlserver_backend.dialect - point1_sql, point1_params = dialect.format_st_geom_from_text('POINT(0 0)') - point2_sql, point2_params = dialect.format_st_geom_from_text('POINT(3 4)') + point1_sql, point1_params = SQLServerSTGeomFromTextExpression( + dialect, 'POINT(0 0)' + ).to_sql() + point2_sql, point2_params = SQLServerSTGeomFromTextExpression( + dialect, 'POINT(3 4)' + ).to_sql() - distance_sql, _ = dialect.format_st_distance(point1_sql, point2_sql) + distance_sql, _ = SQLServerSTDistanceExpression( + dialect, point1_sql, point2_sql + ).to_sql() result = sqlserver_backend.execute( f"SELECT {distance_sql} as distance", @@ -167,12 +187,16 @@ def test_format_st_within(self, sqlserver_backend): """Test format_st_within generates correct SQL.""" dialect = sqlserver_backend.dialect - point_sql, point_params = dialect.format_st_geom_from_text('POINT(5 5)') - polygon_sql, polygon_params = dialect.format_st_geom_from_text( - 'POLYGON((0 0, 10 0, 10 10, 0 10, 0 0))' - ) + point_sql, point_params = SQLServerSTGeomFromTextExpression( + dialect, 'POINT(5 5)' + ).to_sql() + polygon_sql, polygon_params = SQLServerSTGeomFromTextExpression( + dialect, 'POLYGON((0 0, 10 0, 10 10, 0 10, 0 0))' + ).to_sql() - within_sql, _ = dialect.format_st_within(point_sql, polygon_sql) + within_sql, _ = SQLServerSTWithinExpression( + dialect, point_sql, polygon_sql + ).to_sql() result = sqlserver_backend.execute( f"SELECT {within_sql} as is_within", @@ -185,12 +209,16 @@ def test_format_st_contains(self, sqlserver_backend): """Test format_st_contains generates correct SQL.""" dialect = sqlserver_backend.dialect - polygon_sql, polygon_params = dialect.format_st_geom_from_text( - 'POLYGON((0 0, 10 0, 10 10, 0 10, 0 0))' - ) - point_sql, point_params = dialect.format_st_geom_from_text('POINT(5 5)') + polygon_sql, polygon_params = SQLServerSTGeomFromTextExpression( + dialect, 'POLYGON((0 0, 10 0, 10 10, 0 10, 0 0))' + ).to_sql() + point_sql, point_params = SQLServerSTGeomFromTextExpression( + dialect, 'POINT(5 5)' + ).to_sql() - contains_sql, _ = dialect.format_st_contains(polygon_sql, point_sql) + contains_sql, _ = SQLServerSTContainsExpression( + dialect, polygon_sql, point_sql + ).to_sql() result = sqlserver_backend.execute( f"SELECT {contains_sql} as contains_point", @@ -214,13 +242,15 @@ def test_format_create_spatial_index(self, sqlserver_backend): """) dialect = sqlserver_backend.dialect - index_sql, params = dialect.format_create_spatial_index( - 'idx_location', '#test_spatial_idx', 'location' - ) + index_sql, params = SQLServerCreateSpatialIndexExpression( + dialect, 'idx_location', '#test_spatial_idx', 'location' + ).to_sql() sqlserver_backend.execute(index_sql) - insert_sql, insert_params = dialect.format_spatial_literal('POINT(1 1)') + insert_sql, insert_params = SQLServerSpatialLiteralExpression( + dialect, 'POINT(1 1)' + ).to_sql() sqlserver_backend.execute( f"INSERT INTO #test_spatial_idx (name, location) VALUES ('test', {insert_sql})", insert_params @@ -260,7 +290,7 @@ async def test_async_format_spatial_literal(self, async_sqlserver_backend): """) dialect = async_sqlserver_backend.dialect - sql, params = dialect.format_spatial_literal('POINT(5 5)') + sql, params = SQLServerSpatialLiteralExpression(dialect, 'POINT(5 5)').to_sql() await async_sqlserver_backend.execute( f"INSERT INTO #test_async_spatial (location) VALUES ({sql})", @@ -280,7 +310,9 @@ async def test_async_format_spatial_literal(self, async_sqlserver_backend): async def test_async_format_st_geom_from_text(self, async_sqlserver_backend): """Test format_st_geom_from_text generates correct SQL (async).""" dialect = async_sqlserver_backend.dialect - sql, params = dialect.format_st_geom_from_text('POINT(10 20)', 4326) + sql, params = SQLServerSTGeomFromTextExpression( + dialect, 'POINT(10 20)', srid=4326 + ).to_sql() result = await async_sqlserver_backend.execute( f"SELECT {sql}.STSrid as srid", @@ -294,10 +326,16 @@ async def test_async_format_st_distance(self, async_sqlserver_backend): """Test format_st_distance generates correct SQL (async).""" dialect = async_sqlserver_backend.dialect - point1_sql, point1_params = dialect.format_st_geom_from_text('POINT(0 0)') - point2_sql, point2_params = dialect.format_st_geom_from_text('POINT(3 4)') + point1_sql, point1_params = SQLServerSTGeomFromTextExpression( + dialect, 'POINT(0 0)' + ).to_sql() + point2_sql, point2_params = SQLServerSTGeomFromTextExpression( + dialect, 'POINT(3 4)' + ).to_sql() - distance_sql, _ = dialect.format_st_distance(point1_sql, point2_sql) + distance_sql, _ = SQLServerSTDistanceExpression( + dialect, point1_sql, point2_sql + ).to_sql() result = await async_sqlserver_backend.execute( f"SELECT {distance_sql} as distance", @@ -311,12 +349,16 @@ async def test_async_format_st_within(self, async_sqlserver_backend): """Test format_st_within generates correct SQL (async).""" dialect = async_sqlserver_backend.dialect - point_sql, point_params = dialect.format_st_geom_from_text('POINT(5 5)') - polygon_sql, polygon_params = dialect.format_st_geom_from_text( - 'POLYGON((0 0, 10 0, 10 10, 0 10, 0 0))' - ) + point_sql, point_params = SQLServerSTGeomFromTextExpression( + dialect, 'POINT(5 5)' + ).to_sql() + polygon_sql, polygon_params = SQLServerSTGeomFromTextExpression( + dialect, 'POLYGON((0 0, 10 0, 10 10, 0 10, 0 0))' + ).to_sql() - within_sql, _ = dialect.format_st_within(point_sql, polygon_sql) + within_sql, _ = SQLServerSTWithinExpression( + dialect, point_sql, polygon_sql + ).to_sql() result = await async_sqlserver_backend.execute( f"SELECT {within_sql} as is_within", @@ -347,13 +389,15 @@ async def test_async_format_create_spatial_index(self, async_sqlserver_backend): """) dialect = async_sqlserver_backend.dialect - index_sql, _ = dialect.format_create_spatial_index( - 'idx_loc', '#test_async_idx', 'location' - ) + index_sql, _ = SQLServerCreateSpatialIndexExpression( + dialect, 'idx_loc', '#test_async_idx', 'location' + ).to_sql() await async_sqlserver_backend.execute(index_sql) - insert_sql, insert_params = dialect.format_spatial_literal('POINT(1 1)') + insert_sql, insert_params = SQLServerSpatialLiteralExpression( + dialect, 'POINT(1 1)' + ).to_sql() await async_sqlserver_backend.execute( f"INSERT INTO #test_async_idx (location) VALUES ({insert_sql})", insert_params From 1191d24181f9c464748833d7d2b47e9b70ec5f60 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 14 Sep 2026 11:23:50 +0800 Subject: [PATCH 27/58] fix(sqlserver): declare format_identifier on the dialect SQLDialectBase precedes the feature mixins in the MRO, so the bracket formatter in SQLServerIdentifierMixin was shadowed and identifiers were rendered with double quotes. Re-declare it on the dialect. --- .../activerecord/backend/impl/sqlserver/dialect.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index a394253..bcd1aeb 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -416,6 +416,14 @@ class SQLServerDialect( "bit_shift_right": (SQL_SERVER_2022, None), } + def format_identifier(self, identifier: str, need_quote: bool = True) -> str: + """Format an identifier with SQL Server square brackets. + + Declared on the dialect so it is not shadowed by ``SQLDialectBase`` + (double quotes), which precedes the feature mixins in the MRO. + """ + return SQLServerIdentifierMixin.format_identifier(self, identifier, need_quote) + def __init__(self, version: Optional[Tuple[int, int, int]] = None): """ Initialize SQL Server dialect with specific version. From 8be33d184c5ef3c4a1e550bac2b1f47bf78e1e10 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 14 Sep 2026 11:36:15 +0800 Subject: [PATCH 28/58] test(sqlserver): route direct formatter calls through expression nodes Tests were calling dialect formatters with raw strings/lists, bypassing the expression layer. Construct the corresponding expression classes and call to_sql() so the tests exercise the real expression<->formatter integration: - NEXT VALUE FOR -> SQLServerNextValueForExpression - CONTAINS / FREETEXT -> SQLServerContainsPredicate / SQLServerFreetextPredicate - TRY_CAST / TRY_CONVERT -> SQLServerTryCastExpression / SQLServerTryConvertExpression - table hints -> SQLServerTableHintClause - OPENJSON -> SQLServerOpenJsonExpression - temporal period -> SQLServerTemporalPeriodDefinition - FULLTEXT CATALOG -> SQLServerCreateFullTextCatalogExpression - OPTION hints -> SQLServerOptionHintClause --- .../backend/test_dialect_version_branches.py | 22 ++++-- .../backend/test_p2_coverage_features.py | 14 ++-- .../feature/backend/test_protocol_support.py | 79 ++++++++++++++----- 3 files changed, 83 insertions(+), 32 deletions(-) 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 b219a34..0ffc148 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 @@ -19,6 +19,13 @@ SQL_SERVER_2022, SQLServerDialect, ) +from rhosocial.activerecord.backend.impl.sqlserver.expression.locking import ( + SQLServerTableHint, + SQLServerTableHintClause, +) +from rhosocial.activerecord.backend.impl.sqlserver.expression.option_hint import ( + SQLServerOptionHintClause, +) from rhosocial.activerecord.backend.expression.query_parts import GroupingClause VERSIONS = { @@ -255,8 +262,12 @@ def test_create_table_like_is_unsupported(self): def test_table_hint_single_and_multiple(self): d = SQLServerDialect((16, 0, 0)) - assert d.format_table_hint(["NOLOCK"]) == "WITH (NOLOCK)" - assert d.format_table_hint(["NOLOCK", "READCOMMITTED"]) == "WITH (NOLOCK, READCOMMITTED)" + single = SQLServerTableHintClause(d, [SQLServerTableHint("NOLOCK")]).to_sql() + assert single == ("WITH (NOLOCK)", ()) + multiple = SQLServerTableHintClause( + d, [SQLServerTableHint("NOLOCK"), SQLServerTableHint("READCOMMITTED")] + ).to_sql() + assert multiple == ("WITH (NOLOCK, READCOMMITTED)", ()) def test_locking_hints_gain_readpast_only_on_2019_plus(self): base = SQLServerDialect((13, 0, 0)).format_table_hint_locking("UPDLOCK, READPAST") @@ -361,9 +372,10 @@ def test_explain_format_support_matrix(self): assert d.supports_explain_format("JSON") is False def test_option_query_hint_clause(self): - clause = SimpleNamespace(hints=["RECOMPILE", "MAXDOP 4"]) - sql, params = SQLServerDialect((16, 0, 0)).format_query_option_clause(clause) - assert (sql, params) == ("OPTION (RECOMPILE, MAXDOP 4)", ()) + clause = SQLServerOptionHintClause( + SQLServerDialect((16, 0, 0)), ["RECOMPILE", "MAXDOP 4"] + ) + assert clause.to_sql() == ("OPTION (RECOMPILE, MAXDOP 4)", ()) def test_top_n_clause_variants(self): d = SQLServerDialect((16, 0, 0)) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p2_coverage_features.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p2_coverage_features.py index c64f09e..4987e34 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p2_coverage_features.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p2_coverage_features.py @@ -337,14 +337,16 @@ def test_drop_fulltext_index(self, dialect): def test_fulltext_catalog_gate_below_2005(self): d = SQLServerDialect(SQL_SERVER_2005) assert d.supports_fulltext_catalog() is True - assert d.format_create_fulltext_catalog_statement("ftc") == ( - "CREATE FULLTEXT CATALOG [ftc]" - ) + sql, params = SQLServerCreateFullTextCatalogExpression(d, "ftc").to_sql() + assert sql == "CREATE FULLTEXT CATALOG [ftc]" + assert params == () def test_direct_formatter_default_suffix(self, dialect): - assert dialect.format_create_fulltext_catalog_statement("ftc", as_default=True) == ( - "CREATE FULLTEXT CATALOG [ftc] AS DEFAULT" - ) + sql, params = SQLServerCreateFullTextCatalogExpression( + dialect, "ftc", is_default=True + ).to_sql() + assert sql == "CREATE FULLTEXT CATALOG [ftc] AS DEFAULT" + assert params == () def test_validation_requires_fields(self, dialect): with pytest.raises(ValueError): diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_protocol_support.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_protocol_support.py index 4df61b5..7e8bde0 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_protocol_support.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_protocol_support.py @@ -18,9 +18,29 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect +from rhosocial.activerecord.backend.impl.sqlserver.expression.fulltext import ( + SQLServerCreateFullTextCatalogExpression, +) +from rhosocial.activerecord.backend.impl.sqlserver.expression.functions import ( + SQLServerContainsPredicate, + SQLServerFreetextPredicate, + SQLServerTryCastExpression, + SQLServerTryConvertExpression, +) +from rhosocial.activerecord.backend.impl.sqlserver.expression.locking import ( + SQLServerTableHint, + SQLServerTableHintClause, +) +from rhosocial.activerecord.backend.impl.sqlserver.expression.openjson import ( + OpenJsonColumn, + SQLServerOpenJsonExpression, +) from rhosocial.activerecord.backend.impl.sqlserver.expression.sequence import ( SQLServerNextValueForExpression, ) +from rhosocial.activerecord.backend.impl.sqlserver.expression.temporal import ( + SQLServerTemporalPeriodDefinition, +) from rhosocial.activerecord.backend.impl.sqlserver.protocols import ( SQLServerOutputSupport, SQLServerPaginationSupport, @@ -151,23 +171,28 @@ def test_next_value_for_schema_qualified(self): def test_format_next_value_for_plain_string(self): d = SQLServerDialect(SQL_SERVER_2022) - assert d.format_next_value_for("my_seq") == "NEXT VALUE FOR [my_seq]" + sql, params = SQLServerNextValueForExpression(d, "my_seq").to_sql() + assert sql == "NEXT VALUE FOR [my_seq]" + assert params == () def test_next_value_for_supported_at_gate(self): d = SQLServerDialect(SQL_SERVER_2014) - sql = d.format_next_value_for("my_seq") + sql = SQLServerNextValueForExpression(d, "my_seq").to_sql()[0] assert sql == "NEXT VALUE FOR [my_seq]" def test_next_value_for_gate_below_2012(self): d = SQLServerDialect(SQL_SERVER_2005) with pytest.raises(UnsupportedFeatureError) as exc_info: - d.format_next_value_for("my_seq") + SQLServerNextValueForExpression(d, "my_seq").to_sql() assert "NEXT VALUE FOR" in str(exc_info.value) assert "requires SQL Server 2012+" in str(exc_info.value) def test_next_value_for_gate_on_2012(self): d = SQLServerDialect(SQL_SERVER_2012) - assert d.format_next_value_for("my_seq") == "NEXT VALUE FOR [my_seq]" + assert ( + SQLServerNextValueForExpression(d, "my_seq").to_sql()[0] + == "NEXT VALUE FOR [my_seq]" + ) def test_next_value_for_in_select(self): d = SQLServerDialect(SQL_SERVER_2022) @@ -316,16 +341,18 @@ def test_supports_order_by_in_subquery(self, dialect): assert dialect.supports_order_by_in_subquery() is True def test_format_contains_predicate(self, dialect): - sql, params = dialect.format_contains_predicate("title", "database") + sql, params = SQLServerContainsPredicate(dialect, "title", "database").to_sql() assert sql == "CONTAINS([title], 'database')" assert params == () def test_format_contains_predicate_escapes(self, dialect): - sql, _ = dialect.format_contains_predicate("title", "it's") + sql, _ = SQLServerContainsPredicate(dialect, "title", "it's").to_sql() assert sql == "CONTAINS([title], 'it''s')" def test_format_freetext_predicate(self, dialect): - sql, params = dialect.format_freetext_predicate("title", "database design") + sql, params = SQLServerFreetextPredicate( + dialect, "title", "database design" + ).to_sql() assert sql == "FREETEXT([title], 'database design')" assert params == () @@ -342,9 +369,9 @@ def test_supports_fulltext_catalog(self, dialect): assert dialect.supports_fulltext_catalog() is True def test_format_create_fulltext_catalog_statement(self, dialect): - assert dialect.format_create_fulltext_catalog_statement("ftc") == ( - "CREATE FULLTEXT CATALOG [ftc]" - ) + sql, params = SQLServerCreateFullTextCatalogExpression(dialect, "ftc").to_sql() + assert sql == "CREATE FULLTEXT CATALOG [ftc]" + assert params == () def test_supports_readpast_on_2019(self): assert SQLServerDialect(SQL_SERVER_2019).supports_readpast() is True @@ -372,12 +399,14 @@ def test_supports_try_convert(self, dialect): assert dialect.supports_try_convert() is True def test_format_try_cast_expression(self, dialect): - sql, params = dialect.format_try_cast_expression("value", "INT") + sql, params = SQLServerTryCastExpression(dialect, "value", "INT").to_sql() assert sql == "TRY_CAST(value AS INT)" assert params == () def test_format_try_convert_expression(self, dialect): - sql, params = dialect.format_try_convert_expression("value", "INT", style=100) + sql, params = SQLServerTryConvertExpression( + dialect, "INT", "value", style=100 + ).to_sql() assert sql == "TRY_CONVERT(INT, value, 100)" assert params == () @@ -385,9 +414,11 @@ def test_supports_table_hints(self, dialect): assert dialect.supports_table_hints() is True def test_format_table_hint(self, dialect): - assert dialect.format_table_hint(["NOLOCK", "ROWLOCK"]) == ( - "WITH (NOLOCK, ROWLOCK)" - ) + sql, params = SQLServerTableHintClause( + dialect, [SQLServerTableHint("NOLOCK"), SQLServerTableHint("ROWLOCK")] + ).to_sql() + assert sql == "WITH (NOLOCK, ROWLOCK)" + assert params == () def test_supports_select_into(self, dialect): assert dialect.supports_select_into() is True @@ -415,9 +446,13 @@ def test_format_select_into_requires_target_table(self, dialect): dialect.format_select_into_statement(query) def test_format_openjson_expression(self, dialect): - sql, params = dialect.format_openjson_expression( - "@json", path="$.orders", schema={"order_id": "INT"}, alias="o" - ) + sql, params = SQLServerOpenJsonExpression( + dialect, + "@json", + path="$.orders", + schema=[OpenJsonColumn(name="order_id", type="INT")], + alias="o", + ).to_sql() assert sql == "OPENJSON(@json, '$.orders') WITH ([order_id] INT) AS [o]" assert params == () @@ -428,9 +463,11 @@ def test_supports_system_versioning_below_2016(self): assert SQLServerDialect(SQL_SERVER_2014).supports_system_versioning() is False def test_format_temporal_period_definition(self, dialect): - assert dialect.format_temporal_period_definition("SysStart", "SysEnd") == ( - "PERIOD FOR SYSTEM_TIME ([SysStart], [SysEnd])" - ) + sql, params = SQLServerTemporalPeriodDefinition( + dialect, "SysStart", "SysEnd" + ).to_sql() + assert sql == "PERIOD FOR SYSTEM_TIME ([SysStart], [SysEnd])" + assert params == () def test_supports_sequence_as_data_type(self, dialect): assert dialect.supports_sequence_as_data_type() is False From 5458750439c840889daa5b56b12c073ae1324333 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 14 Sep 2026 11:57:26 +0800 Subject: [PATCH 29/58] fix(sqlserver): compose CollationMixin for format_collate_expression CollationMixin was imported but not in the bases, so format_collate_ expression resolved to the CollationSupport protocol stub (None). --- src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index bcd1aeb..fb97e7c 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -222,6 +222,7 @@ class SQLServerDialect( DDLTypeMixin, SQLServerIdentifierMixin, SQLServerCollationMixin, + CollationMixin, SQLServerCTEMixin, SQLServerWindowMixin, SQLServerJSONMixin, From bf368bee076a8c30395d6a1cce7d601f30adbed7 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 14 Sep 2026 12:46:23 +0800 Subject: [PATCH 30/58] refactor(sqlserver): emit bind placeholders via dialect accessor --- .../sqlserver/introspection/introspector.py | 42 +++++++++++-------- .../impl/sqlserver/mixins/backend_mixin.py | 6 +-- .../backend/impl/sqlserver/mixins/datetime.py | 4 +- 3 files changed, 30 insertions(+), 22 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/introspector.py b/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/introspector.py index ac3208d..8588b97 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/introspector.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/introspector.py @@ -63,9 +63,10 @@ def _build_table_list_sql( ) -> Tuple[str, tuple]: target = schema or self._get_default_schema() params: List[str] = [target] - conditions = ["TABLE_SCHEMA = ?"] + p = self.dialect.p() + conditions = [f"TABLE_SCHEMA = {p}"] if table_type: - conditions.append("TABLE_TYPE = ?") + conditions.append(f"TABLE_TYPE = {p}") params.append(table_type) elif not include_views: conditions.append("TABLE_TYPE = 'BASE TABLE'") @@ -81,7 +82,8 @@ def _build_column_info_sql( self, table_name: str, schema: Optional[str] ) -> Tuple[str, tuple]: target = schema or self._get_default_schema() - return (""" + p = self.dialect.p() + return (f""" SELECT c.COLUMN_NAME, c.DATA_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT, c.CHARACTER_MAXIMUM_LENGTH, @@ -92,21 +94,22 @@ def _build_column_info_sql( c.COLUMN_NAME, 'IsIdentity' ) AS IS_IDENTITY FROM INFORMATION_SCHEMA.COLUMNS c - WHERE c.TABLE_SCHEMA = ? AND c.TABLE_NAME = ? + WHERE c.TABLE_SCHEMA = {p} AND c.TABLE_NAME = {p} ORDER BY c.ORDINAL_POSITION """, (target, table_name)) def _build_primary_key_sql( self, table_name: str, schema: str ) -> Tuple[str, tuple]: - return (""" + p = self.dialect.p() + return (f""" SELECT ccu.COLUMN_NAME FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc JOIN INFORMATION_SCHEMA.CONSTRAINT_COLUMN_USAGE ccu ON tc.CONSTRAINT_NAME = ccu.CONSTRAINT_NAME AND tc.TABLE_SCHEMA = ccu.TABLE_SCHEMA - WHERE tc.TABLE_SCHEMA = ? - AND tc.TABLE_NAME = ? + WHERE tc.TABLE_SCHEMA = {p} + AND tc.TABLE_NAME = {p} AND tc.CONSTRAINT_TYPE = 'PRIMARY KEY' """, (schema, table_name)) @@ -114,7 +117,8 @@ def _build_index_info_sql( self, table_name: str, schema: Optional[str] ) -> Tuple[str, tuple]: target = schema or self._get_default_schema() - return (""" + p = self.dialect.p() + return (f""" SELECT i.name AS index_name, i.is_unique, @@ -130,7 +134,7 @@ def _build_index_info_sql( ON i.object_id = ic.object_id AND i.index_id = ic.index_id JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id - WHERE s.name = ? AND t.name = ? + WHERE s.name = {p} AND t.name = {p} AND i.name IS NOT NULL ORDER BY i.name, ic.key_ordinal """, (target, table_name)) @@ -139,7 +143,8 @@ def _build_foreign_key_sql( self, table_name: str, schema: Optional[str] ) -> Tuple[str, tuple]: target = schema or self._get_default_schema() - return (""" + p = self.dialect.p() + return (f""" SELECT fk.name AS fk_name, COL_NAME(fc.parent_object_id, fc.parent_column_id) AS col_name, @@ -153,7 +158,7 @@ def _build_foreign_key_sql( ON fk.object_id = fc.constraint_object_id JOIN sys.tables t ON fk.parent_object_id = t.object_id JOIN sys.schemas s ON t.schema_id = s.schema_id - WHERE s.name = ? AND t.name = ? + WHERE s.name = {p} AND t.name = {p} ORDER BY fk.name, fc.constraint_column_id """, (target, table_name)) @@ -161,10 +166,11 @@ def _build_view_list_sql( self, schema: Optional[str], include_system: bool ) -> Tuple[str, tuple]: target = schema or self._get_default_schema() - return (""" + p = self.dialect.p() + return (f""" SELECT TABLE_NAME AS view_name FROM INFORMATION_SCHEMA.VIEWS - WHERE TABLE_SCHEMA = ? + WHERE TABLE_SCHEMA = {p} ORDER BY TABLE_NAME """, (target,)) @@ -172,13 +178,14 @@ def _build_view_info_sql( self, view_name: str, schema: Optional[str] ) -> Tuple[str, tuple]: target = schema or self._get_default_schema() - return (""" + p = self.dialect.p() + return (f""" SELECT TABLE_NAME AS view_name, VIEW_DEFINITION AS definition, IS_UPDATABLE FROM INFORMATION_SCHEMA.VIEWS - WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? + WHERE TABLE_SCHEMA = {p} AND TABLE_NAME = {p} """, (target, view_name)) def _build_trigger_list_sql( @@ -186,7 +193,8 @@ def _build_trigger_list_sql( ) -> Tuple[str, tuple]: target = schema or self._get_default_schema() params: List[str] = [target] - table_filter = "AND t.name = ?" if table_name else "" + p = self.dialect.p() + table_filter = f"AND t.name = {p}" if table_name else "" if table_name: params.append(table_name) return (f""" @@ -199,7 +207,7 @@ def _build_trigger_list_sql( FROM sys.triggers tr JOIN sys.tables t ON tr.parent_id = t.object_id JOIN sys.schemas s ON t.schema_id = s.schema_id - WHERE s.name = ? {table_filter} + WHERE s.name = {p} {table_filter} ORDER BY tr.name """, tuple(params)) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/backend_mixin.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/backend_mixin.py index 6748cfb..7a1acd6 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/backend_mixin.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/backend_mixin.py @@ -96,7 +96,7 @@ def _get_identity_columns(self, table_name: str) -> Set[str]: cursor.execute( "SELECT c.name FROM sys.identity_columns c " "JOIN sys.tables t ON c.object_id = t.object_id " - "WHERE t.name = ? AND SCHEMA_NAME(t.schema_id) = 'dbo'", + f"WHERE t.name = {self.dialect.p()} AND SCHEMA_NAME(t.schema_id) = 'dbo'", (table_name,), ) for row in cursor.fetchall(): @@ -186,7 +186,7 @@ def get_scope_identity(self) -> Optional[int]: def get_identity_current(self, table: str) -> Optional[int]: cursor = self._get_cursor() - cursor.execute("SELECT IDENT_CURRENT(?)", (table,)) + cursor.execute(f"SELECT IDENT_CURRENT({self.dialect.p()})", (table,)) row = cursor.fetchone() cursor.close() if row and row[0] is not None: @@ -195,7 +195,7 @@ def get_identity_current(self, table: str) -> Optional[int]: def reset_identity(self, table: str, seed: int = 1) -> None: cursor = self._get_cursor() - cursor.execute("DBCC CHECKIDENT(?, RESEED, ?)", (table, seed)) + cursor.execute(f"DBCC CHECKIDENT({self.dialect.p()}, RESEED, {self.dialect.p()})", (table, seed)) cursor.close() def set_lock_timeout(self, timeout_ms: int) -> None: diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/datetime.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/datetime.py index 82a6222..1076b99 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/datetime.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/datetime.py @@ -29,7 +29,7 @@ def format_interval_expression(self, expr: "Any") -> Tuple[str, Tuple]: def format_datetime_add_expression(self, expr: "Any") -> Tuple[str, Tuple]: source_sql, source_params = expr.source.to_sql() unit = expr.interval.unit.value.upper() - sql = f"DATEADD({unit}, ?, {source_sql})" + sql = f"DATEADD({unit}, {self.p()}, {source_sql})" return self.apply_alias( sql, (expr.interval.value,) + source_params, expr ) @@ -37,7 +37,7 @@ def format_datetime_add_expression(self, expr: "Any") -> Tuple[str, Tuple]: def format_datetime_subtract_expression(self, expr: "Any") -> Tuple[str, Tuple]: source_sql, source_params = expr.source.to_sql() unit = expr.interval.unit.value.upper() - sql = f"DATEADD({unit}, ?, {source_sql})" + sql = f"DATEADD({unit}, {self.p()}, {source_sql})" return self.apply_alias( sql, (-expr.interval.value,) + source_params, expr ) From c6f92eb1fe4669376232fb2bf0318f621f530aed Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 14 Sep 2026 13:55:03 +0800 Subject: [PATCH 31/58] fix: replace hardcoded ? placeholder with dialect.p() in async_backend --- .../activerecord/backend/impl/sqlserver/async_backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/async_backend.py b/src/rhosocial/activerecord/backend/impl/sqlserver/async_backend.py index e33ff74..7a23574 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/async_backend.py @@ -261,7 +261,7 @@ async def _get_identity_columns_async(self, table_name: str) -> Set[str]: await cursor.execute( "SELECT c.name FROM sys.identity_columns c " "JOIN sys.tables t ON c.object_id = t.object_id " - "WHERE t.name = ? AND SCHEMA_NAME(t.schema_id) = 'dbo'", + f"WHERE t.name = {self.dialect.p()} AND SCHEMA_NAME(t.schema_id) = 'dbo'", (table_name,), ) for row in await cursor.fetchall(): From bf3d2fcd564a5b7042d05cfeac404d35c9773df8 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 14 Sep 2026 16:37:47 +0800 Subject: [PATCH 32/58] refactor: normalize format_* return types to Tuple[str, tuple] and eliminate isinstance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Global: Tuple[str, Tuple] → Tuple[str, tuple] across all format_* methods - format_auto_increment: str → Tuple[str, tuple] - format_top_n_clause: str → Tuple[str, tuple] - format_inline_index: str → Tuple[str, tuple] - format_limit_offset: Tuple[Optional[str], List[Any]] → Tuple[str, tuple] - format_next_value_for: str → Tuple[str, tuple] - format_set_identity_insert: str → Tuple[str, tuple] - format_temporal_period_definition: str → Tuple[str, tuple] - format_table_hint / format_table_hint_locking: str → Tuple[str, tuple] - format_tablesample_clause: str → Tuple[str, tuple] - format_*_fulltext_*: str → Tuple[str, tuple] - Eliminated isinstance checks: - format_column_definition: data_type always DataType, default_value uses inline_sql_literal - format_create_index_statement: columns unified to expression objects - format_fulltext_match: columns unified to expression objects - format_json_function_expression: column unified to expression objects - format_sqlserver_partition_function: values use inline_sql_literal - format_openjson_schema: schema unified to expression objects - Added type hints to ~40% of methods previously lacking them - Updated all protocol definitions to match new signatures --- .../impl/sqlserver/alter_table_modifier.py | 16 +-- .../backend/impl/sqlserver/dialect.py | 73 ++++++------- .../impl/sqlserver/expression/fulltext.py | 16 +-- .../impl/sqlserver/expression/sequence.py | 2 +- .../backend/impl/sqlserver/mixins/datetime.py | 10 +- .../backend/impl/sqlserver/mixins/dml.py | 6 +- .../backend/impl/sqlserver/mixins/dql.py | 13 ++- .../backend/impl/sqlserver/mixins/json.py | 10 +- .../impl/sqlserver/mixins/partition.py | 10 +- .../impl/sqlserver/mixins/protocol_support.py | 102 +++++++++--------- .../backend/impl/sqlserver/mixins/sequence.py | 9 +- .../impl/sqlserver/protocols/fulltext.py | 8 +- .../impl/sqlserver/protocols/identity.py | 4 +- .../impl/sqlserver/protocols/locking.py | 4 +- .../impl/sqlserver/protocols/sequence.py | 4 +- .../impl/sqlserver/protocols/table_hint.py | 4 +- .../impl/sqlserver/protocols/temporal.py | 4 +- 17 files changed, 135 insertions(+), 160 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/alter_table_modifier.py b/src/rhosocial/activerecord/backend/impl/sqlserver/alter_table_modifier.py index f448876..70cf1ae 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/alter_table_modifier.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/alter_table_modifier.py @@ -6,6 +6,8 @@ (``mixins/backend_mixin`` -> ``dialect``). """ +from typing import Any, Tuple + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError @@ -35,7 +37,7 @@ def supports_drop_constraint_if_exists(self) -> bool: """``DROP CONSTRAINT IF EXISTS`` is supported since SQL Server 2016.""" return True - def format_add_column_action(self, action): + def format_add_column_action(self, action: Any) -> Tuple[str, tuple]: if getattr(action, "if_not_exists", None) is True: raise UnsupportedFeatureError( self.name, @@ -47,13 +49,13 @@ def format_add_column_action(self, action): # SQL Server ALTER TABLE ... ADD takes the column without a COLUMN literal. return f"ADD {column_sql}", column_params - def format_drop_column_action(self, action): + def format_drop_column_action(self, action: Any) -> Tuple[str, tuple]: name = self.format_identifier(action.column_name) if getattr(action, "if_exists", None) is True: return f"DROP COLUMN IF EXISTS {name}", () return f"DROP COLUMN {name}", () - def format_drop_table_constraint_action(self, action): + def format_drop_table_constraint_action(self, action: Any) -> Tuple[str, tuple]: name = self.format_identifier(action.constraint_name) if getattr(action, "if_exists", None) is True: result = f"DROP CONSTRAINT IF EXISTS {name}" @@ -63,7 +65,7 @@ def format_drop_table_constraint_action(self, action): result += " CASCADE" return result, () - def format_alter_column_action(self, action): + def format_alter_column_action(self, action: Any) -> Tuple[str, tuple]: """Dispatch an ALTER COLUMN action to a SQL Server-specific formatter. SQL Server re-specifies the full column rather than applying the @@ -96,7 +98,7 @@ def format_alter_column_action(self, action): "NULL/NOT NULL, COLLATE, SPARSE, or MASKED.", ) - def format_alter_column_type_action(self, action): + def format_alter_column_type_action(self, action: Any) -> Tuple[str, tuple]: """Format a T-SQL ALTER COLUMN type change. SQL Syntax: @@ -137,7 +139,7 @@ def format_alter_column_type_action(self, action): return " ".join(parts), () - def format_add_masked_action(self, action): + def format_add_masked_action(self, action: Any) -> Tuple[str, tuple]: """Format ALTER COLUMN ... ADD MASKED (dynamic data masking, 2016+). SQL Syntax: @@ -165,7 +167,7 @@ def format_add_masked_action(self, action): (), ) - def format_drop_masked_action(self, action): + def format_drop_masked_action(self, action: Any) -> Tuple[str, tuple]: """Format ALTER COLUMN ... DROP MASKED (dynamic data masking, 2016+). SQL Syntax: diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index fb97e7c..455a584 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -452,15 +452,15 @@ def create_schema_differ(self): return SQLServerSchemaDiffer() - def format_auto_increment(self) -> str: + def format_auto_increment(self) -> Tuple[str, tuple]: """Return IDENTITY(1,1) for auto-increment columns.""" - return "IDENTITY(1,1)" + return "IDENTITY(1,1)", () - def format_array_expression(self, _expr: "ArrayExpression") -> Tuple[str, Tuple]: + def format_array_expression(self, _expr: "ArrayExpression") -> Tuple[str, tuple]: """Format array expression - not supported.""" raise UnsupportedFeatureError(self.name, "Array operations", _SUGGESTION_ARRAY_TYPES) - def format_json_table_expression(self, _expr) -> Tuple[str, Tuple]: + def format_json_table_expression(self, _expr: "BaseExpression") -> Tuple[str, tuple]: """Format JSON_TABLE - SQL Server uses OPENJSON.""" raise UnsupportedFeatureError(self.name, "JSON_TABLE", _SUGGESTION_JSON_TABLE) @@ -468,7 +468,7 @@ def format_match_clause(self, _clause: "MatchClause") -> Tuple[str, tuple]: """Format MATCH clause - not supported.""" raise UnsupportedFeatureError(self.name, "graph MATCH clause", _SUGGESTION_GRAPH_MATCH) - def format_ordered_set_aggregation(self, _aggregation: "OrderedSetAggregation") -> Tuple[str, Tuple]: + def format_ordered_set_aggregation(self, _aggregation: "OrderedSetAggregation") -> Tuple[str, tuple]: """Format ordered-set aggregation - not supported.""" raise UnsupportedFeatureError(self.name, "ordered-set aggregate functions", _SUGGESTION_ORDERED_SET_AGG) @@ -543,8 +543,9 @@ def format_create_table_statement( all_params.extend(const_params) for idx_def in expr.indexes: - idx_sql = self.format_inline_index(idx_def) + idx_sql, idx_params = self.format_inline_index(idx_def) column_parts.append(idx_sql) + all_params.extend(idx_params) parts.append(f"({', '.join(column_parts)})") @@ -564,14 +565,9 @@ def format_column_definition(self, col_def: "ColumnDefinition") -> Tuple[str, tu from rhosocial.activerecord.backend.expression.statements import ( ColumnConstraintType, ) - from rhosocial.activerecord.backend.expression.types._base import DataType - if isinstance(col_def.data_type, DataType): - type_sql, type_params = col_def.data_type.to_sql() - parts = [self.format_identifier(col_def.name), type_sql] - params: List[Any] = list(type_params) - else: - parts = [self.format_identifier(col_def.name), str(col_def.data_type)] - params: List[Any] = [] + type_sql, type_params = col_def.data_type.to_sql() + parts = [self.format_identifier(col_def.name), type_sql] + params: List[Any] = list(type_params) constraint_parts = [] for constraint in col_def.constraints: @@ -583,16 +579,8 @@ def format_column_definition(self, col_def: "ColumnDefinition") -> Tuple[str, tu constraint_parts.append("UNIQUE") elif constraint.constraint_type == ColumnConstraintType.DEFAULT: if constraint.default_value is not None: - from rhosocial.activerecord.backend.expression import bases - if isinstance(constraint.default_value, bases.BaseExpression): - default_sql, default_params = constraint.default_value.to_sql() - constraint_parts.append(f"DEFAULT {default_sql}") - params.extend(default_params) - elif isinstance(constraint.default_value, str): - escaped = self._escape_sql_string(constraint.default_value) - constraint_parts.append(f"DEFAULT '{escaped}'") - else: - constraint_parts.append(f"DEFAULT {constraint.default_value}") + default_sql = self.inline_sql_literal(constraint.default_value) + constraint_parts.append(f"DEFAULT {default_sql}") elif constraint.constraint_type == ColumnConstraintType.NULL: constraint_parts.append("NULL") @@ -648,7 +636,7 @@ def format_table_constraint(self, t_const: "TableConstraint") -> Tuple[str, tupl return ' '.join(parts), tuple(params) - def format_inline_index(self, idx_def: "IndexDefinition") -> str: + def format_inline_index(self, idx_def: "IndexDefinition") -> Tuple[str, tuple]: """Format an inline index definition for SQL Server.""" parts = [] @@ -658,7 +646,11 @@ def format_inline_index(self, idx_def: "IndexDefinition") -> str: parts.append("INDEX") parts.append(self.format_identifier(idx_def.name)) - cols_str = ', '.join(self.format_identifier(c) for c in idx_def.columns) + col_parts = [] + for col in idx_def.columns: + col_sql, col_params = col.to_sql() + col_parts.append(col_sql) + cols_str = ', '.join(col_parts) idx_options = getattr(idx_def, "dialect_options", None) or {} if idx_options.get("hash_index"): @@ -674,7 +666,7 @@ def format_inline_index(self, idx_def: "IndexDefinition") -> str: else: parts.append(f"({cols_str})") - return ' '.join(parts) + return ' '.join(parts), () def format_drop_table_statement( self, expr: "DropTableExpression" @@ -696,8 +688,6 @@ def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[ SQL Server doesn't support IF NOT EXISTS for CREATE INDEX. We ignore the if_not_exists flag and generate standard CREATE INDEX. """ - from rhosocial.activerecord.backend.expression.bases import ToSQLProtocol - all_params = [] parts = ["CREATE"] @@ -716,12 +706,9 @@ def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[ col_parts = [] for col in expr.columns: - if isinstance(col, ToSQLProtocol): - col_sql, col_params = col.to_sql() - col_parts.append(col_sql) - all_params.extend(col_params) - else: - col_parts.append(self.format_identifier(str(col))) + col_sql, col_params = col.to_sql() + col_parts.append(col_sql) + all_params.extend(col_params) parts.append(f"({', '.join(col_parts)})") if expr.include: @@ -914,7 +901,7 @@ def format_alter_table_statement(self, expr: "AlterTableExpression") -> Tuple[st return " ".join(parts), tuple(all_params) - def format_function_call(self, expr) -> Tuple[str, tuple]: + def format_function_call(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: """Format a function call, mapping MySQL/generic function names to their SQL Server equivalents. @@ -936,7 +923,7 @@ def format_function_call(self, expr) -> Tuple[str, tuple]: return super().format_function_call(renamed_expr) return super().format_function_call(expr) - def _clone_with_func_name(self, expr, new_name: str): + def _clone_with_func_name(self, expr: "bases.BaseExpression", new_name: str) -> "bases.BaseExpression": """Return a shallow copy of ``expr`` with ``func_name`` replaced. The base ``format_function_call`` renders ``expr.func_name.upper()``, @@ -947,7 +934,7 @@ def _clone_with_func_name(self, expr, new_name: str): clone.func_name = new_name return clone - def format_expression(self, expr) -> Tuple[str, tuple]: + def format_expression(self, expr: Any) -> Tuple[str, tuple]: """Format an arbitrary expression to SQL.""" if isinstance(expr, BaseExpression): return expr.to_sql() @@ -970,7 +957,7 @@ def format_fulltext_match( language = expr.mode escaped = search_term.replace("'", "''") - cols = ", ".join(self.format_identifier(c) if isinstance(c, str) else c for c in columns) + cols = ", ".join(c.to_sql()[0] if hasattr(c, 'to_sql') else self.format_identifier(c) for c in columns) sql = f"CONTAINS({cols}, '{escaped}'" if language: @@ -979,17 +966,17 @@ def format_fulltext_match( return sql, () - def format_top_n_clause(self, n: int, percentage: bool = False) -> str: + def format_top_n_clause(self, n: int, percentage: bool = False) -> Tuple[str, tuple]: """Format TOP n clause for SQL Server. SELECT TOP n / SELECT TOP n PERCENT Only valid for SELECT statements. """ if percentage: - return f"TOP {n} PERCENT" - return f"TOP {n}" + return f"TOP {n} PERCENT", () + return f"TOP {n}", () - def format_query_option_clause(self, clause) -> Tuple[str, tuple]: + def format_query_option_clause(self, clause: "bases.BaseExpression") -> Tuple[str, tuple]: """Format an OPTION query hint clause. SQL Server exposes optimizer hints through the query-level OPTION diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/fulltext.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/fulltext.py index bc8eb68..bb0e3bf 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/fulltext.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/fulltext.py @@ -52,10 +52,10 @@ def validate(self, strict: bool = True) -> None: raise ValueError("catalog_name is required") def to_sql(self) -> SQLQueryAndParams: - sql = self.dialect.format_create_fulltext_catalog_statement( + sql, params = self.dialect.format_create_fulltext_catalog_statement( self.catalog_name, as_default=self.is_default ) - return sql, () + return sql, params class SQLServerDropFullTextCatalogExpression(BaseExpression): @@ -81,8 +81,8 @@ def validate(self, strict: bool = True) -> None: raise ValueError("catalog_name is required") def to_sql(self) -> SQLQueryAndParams: - sql = self.dialect.format_drop_fulltext_catalog_statement(self.catalog_name) - return sql, () + sql, params = self.dialect.format_drop_fulltext_catalog_statement(self.catalog_name) + return sql, params class SQLServerCreateFullTextIndexExpression(BaseExpression): @@ -129,13 +129,13 @@ def validate(self, strict: bool = True) -> None: raise ValueError("catalog_name is required") def to_sql(self) -> SQLQueryAndParams: - sql = self.dialect.format_create_fulltext_index_statement( + sql, params = self.dialect.format_create_fulltext_index_statement( self.table, self.columns, self.key_index, self.catalog_name, ) - return sql, () + return sql, params class SQLServerDropFullTextIndexExpression(BaseExpression): @@ -161,5 +161,5 @@ def validate(self, strict: bool = True) -> None: raise ValueError("table is required") def to_sql(self) -> SQLQueryAndParams: - sql = self.dialect.format_drop_fulltext_index_statement(self.table) - return sql, () + sql, params = self.dialect.format_drop_fulltext_index_statement(self.table) + return sql, params diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/sequence.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/sequence.py index a8d5577..27be180 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/sequence.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/sequence.py @@ -45,4 +45,4 @@ def validate(self, strict: bool = True) -> None: raise TypeError(f"sequence_name must be str, got {type(self.sequence_name)}") def to_sql(self) -> SQLQueryAndParams: - return self.dialect.format_next_value_for(self), () + return self.dialect.format_next_value_for(self) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/datetime.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/datetime.py index 1076b99..249c400 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/datetime.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/datetime.py @@ -10,7 +10,7 @@ class SQLServerDateTimeMixin: """SQL Server datetime function implementations (DATEADD, DATEDIFF, DATETRUNC).""" - def format_date_trunc_expression(self, expr: "Any") -> Tuple[str, Tuple]: + def format_date_trunc_expression(self, expr: "Any") -> Tuple[str, tuple]: source_sql, source_params = expr.source.to_sql() field = expr.field.value.upper() if field in {"YEAR", "MONTH", "DAY", "HOUR", "MINUTE", "SECOND"}: @@ -19,14 +19,14 @@ def format_date_trunc_expression(self, expr: "Any") -> Tuple[str, Tuple]: raise UnsupportedFeatureError(self.name, f"date_trunc({expr.field.value})") return self.apply_alias(sql, source_params, expr) - def format_interval_expression(self, expr: "Any") -> Tuple[str, Tuple]: + def format_interval_expression(self, expr: "Any") -> Tuple[str, tuple]: raise UnsupportedFeatureError( self.name, "standalone INTERVAL expression", "Use date_add() or date_sub() for SQL Server date arithmetic.", ) - def format_datetime_add_expression(self, expr: "Any") -> Tuple[str, Tuple]: + def format_datetime_add_expression(self, expr: "Any") -> Tuple[str, tuple]: source_sql, source_params = expr.source.to_sql() unit = expr.interval.unit.value.upper() sql = f"DATEADD({unit}, {self.p()}, {source_sql})" @@ -34,7 +34,7 @@ def format_datetime_add_expression(self, expr: "Any") -> Tuple[str, Tuple]: sql, (expr.interval.value,) + source_params, expr ) - def format_datetime_subtract_expression(self, expr: "Any") -> Tuple[str, Tuple]: + def format_datetime_subtract_expression(self, expr: "Any") -> Tuple[str, tuple]: source_sql, source_params = expr.source.to_sql() unit = expr.interval.unit.value.upper() sql = f"DATEADD({unit}, {self.p()}, {source_sql})" @@ -42,7 +42,7 @@ def format_datetime_subtract_expression(self, expr: "Any") -> Tuple[str, Tuple]: sql, (-expr.interval.value,) + source_params, expr ) - def format_datetime_diff_expression(self, expr: "Any") -> Tuple[str, Tuple]: + def format_datetime_diff_expression(self, expr: "Any") -> Tuple[str, tuple]: start_sql, start_params = expr.start.to_sql() end_sql, end_params = expr.end.to_sql() sql = f"DATEDIFF({expr.unit.value.upper()}, {start_sql}, {end_sql})" diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dml.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dml.py index cab7aff..4490783 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dml.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dml.py @@ -4,6 +4,8 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements import ( InsertExpression, + UpdateExpression, + DeleteExpression, ) @@ -68,7 +70,7 @@ def format_insert_statement(self, expr: "InsertExpression") -> Tuple[str, tuple] return sql, tuple(all_params) - def format_update_statement(self, expr) -> Tuple[str, tuple]: + def format_update_statement(self, expr: "UpdateExpression") -> Tuple[str, tuple]: """Format UPDATE statement with OUTPUT clause support.""" if self.strict_validation: expr.validate(strict=True) @@ -100,7 +102,7 @@ def format_update_statement(self, expr) -> Tuple[str, tuple]: return " ".join(parts), tuple(all_params) - def format_delete_statement(self, expr) -> Tuple[str, tuple]: + def format_delete_statement(self, expr: "DeleteExpression") -> Tuple[str, tuple]: """Format DELETE statement with OUTPUT clause support.""" if self.strict_validation: expr.validate(strict=True) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dql.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dql.py index 50d9e9e..48472f9 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dql.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dql.py @@ -1,11 +1,12 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dql.py -from typing import Any, List, Optional, Tuple, TYPE_CHECKING +from typing import Any, Optional, Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from .version_constants import SQL_SERVER_2012 if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.query_parts import LimitOffsetClause + from rhosocial.activerecord.backend.expression.statements import QueryExpression class SQLServerDQLMixin: @@ -15,7 +16,7 @@ def format_limit_offset( self, limit: Optional[int] = None, offset: Optional[int] = None - ) -> Tuple[Optional[str], List[Any]]: + ) -> Tuple[str, tuple]: """ Format LIMIT/OFFSET as OFFSET FETCH for SQL Server 2012+. @@ -23,7 +24,7 @@ def format_limit_offset( Note: ORDER BY is required when using OFFSET FETCH. """ if limit is None and offset is None: - return None, [] + return "", () if self.version < SQL_SERVER_2012: raise UnsupportedFeatureError( @@ -33,7 +34,6 @@ def format_limit_offset( ) sql_parts = [] - params = [] offset_val = offset or 0 sql_parts.append(f"OFFSET {offset_val} ROWS") @@ -41,7 +41,7 @@ def format_limit_offset( if limit is not None: sql_parts.append(f"FETCH NEXT {limit} ROWS ONLY") - return " ".join(sql_parts), params + return " ".join(sql_parts), () def format_limit_offset_clause(self, clause: "LimitOffsetClause") -> Tuple[str, tuple]: """Format LIMIT/OFFSET clause for SQL Server using OFFSET FETCH syntax.""" @@ -56,7 +56,6 @@ def format_limit_offset_clause(self, clause: "LimitOffsetClause") -> Tuple[str, ) parts = [] - params = [] offset_val = clause.offset if clause.offset is not None else 0 parts.append(f"OFFSET {offset_val} ROWS") @@ -64,7 +63,7 @@ def format_limit_offset_clause(self, clause: "LimitOffsetClause") -> Tuple[str, if clause.limit is not None: parts.append(f"FETCH NEXT {clause.limit} ROWS ONLY") - return " ".join(parts), tuple(params) + return " ".join(parts), () def format_query_statement(self, expr: "QueryExpression") -> Tuple[str, tuple]: sql, params = super().format_query_statement(expr) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/json.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/json.py index cbab210..e346919 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/json.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/json.py @@ -1,11 +1,10 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/mixins/json.py from typing import Optional, Tuple, TYPE_CHECKING -from rhosocial.activerecord.backend.expression import bases from .version_constants import SQL_SERVER_2016 if TYPE_CHECKING: - pass + from rhosocial.activerecord.backend.expression.bases import BaseExpression class SQLServerJSONMixin: @@ -19,7 +18,7 @@ def get_json_access_operator(self) -> Optional[str]: """SQL Server doesn't have -> operator, uses JSON_VALUE/JSON_QUERY.""" return None - def format_json_function_expression(self, expr) -> Tuple[str, tuple]: + def format_json_function_expression(self, expr: "BaseExpression") -> Tuple[str, tuple]: """Format JSON extraction using SQL Server's JSON_VALUE built-in. The base implementation renders ``JSON_UNQUOTE(JSON_EXTRACT(...))`` @@ -32,10 +31,7 @@ def format_json_function_expression(self, expr) -> Tuple[str, tuple]: Returns: (SQL string, params tuple). """ - if isinstance(expr.column, bases.BaseExpression): - col_sql, col_params = expr.column.to_sql() - else: - col_sql, col_params = self.format_identifier(str(expr.column)), () + col_sql, col_params = expr.column.to_sql() escaped_path = self._escape_sql_string(expr.path) sql = f"JSON_VALUE({col_sql}, '{escaped_path}')" diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/partition.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/partition.py index 01e3704..02b6ebc 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/partition.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/partition.py @@ -140,15 +140,7 @@ def format_sqlserver_partition_function( """ boundary_parts = [] for value in expr.boundary_values: - if value is None: - boundary_parts.append("NULL") - elif isinstance(value, str): - escaped = value.replace("'", "''") - boundary_parts.append(f"'{escaped}'") - elif isinstance(value, bool): - boundary_parts.append("1" if value else "0") - else: - boundary_parts.append(str(value)) + boundary_parts.append(self.inline_sql_literal(value)) func_name = expr.function_name # Support schema-qualified names 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 2ecaf77..4e918c8 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/protocol_support.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/protocol_support.py @@ -58,7 +58,7 @@ def supports_fulltext_catalog(self) -> bool: def format_create_fulltext_catalog_statement( self, catalog_name: str, as_default: bool = False - ) -> str: + ) -> Tuple[str, tuple]: """Format CREATE FULLTEXT CATALOG statement (2005+). SQL Syntax: @@ -70,7 +70,7 @@ def format_create_fulltext_catalog_statement( database default. Returns: - The CREATE FULLTEXT CATALOG statement. + Tuple of (SQL string, params tuple). """ self.check_feature_support( "supports_fulltext_catalog", @@ -80,9 +80,9 @@ def format_create_fulltext_catalog_statement( sql = f"CREATE FULLTEXT CATALOG {self.format_identifier(catalog_name)}" if as_default: sql += " AS DEFAULT" - return sql + return sql, () - def format_drop_fulltext_catalog_statement(self, catalog_name: str) -> str: + def format_drop_fulltext_catalog_statement(self, catalog_name: str) -> Tuple[str, tuple]: """Format DROP FULLTEXT CATALOG statement (2005+). SQL Syntax: @@ -93,7 +93,7 @@ def format_drop_fulltext_catalog_statement(self, catalog_name: str) -> str: "FULLTEXT catalogs", "requires SQL Server 2005+.", ) - return f"DROP FULLTEXT CATALOG {self.format_identifier(catalog_name)}" + return f"DROP FULLTEXT CATALOG {self.format_identifier(catalog_name)}", () def format_create_fulltext_index_statement( self, @@ -101,7 +101,7 @@ def format_create_fulltext_index_statement( columns: List[str], key_index: str, catalog_name: str, - ) -> str: + ) -> Tuple[str, tuple]: """Format CREATE FULLTEXT INDEX statement (2005+). SQL Syntax: @@ -114,13 +114,14 @@ def format_create_fulltext_index_statement( "requires SQL Server 2005+.", ) columns_str = ", ".join(self.format_identifier(col) for col in columns) - return ( + sql = ( f"CREATE FULLTEXT INDEX ON {self.format_identifier(table)} " f"({columns_str}) KEY INDEX {self.format_identifier(key_index)} " f"ON {self.format_identifier(catalog_name)}" ) + return sql, () - def format_drop_fulltext_index_statement(self, table: str) -> str: + def format_drop_fulltext_index_statement(self, table: str) -> Tuple[str, tuple]: """Format DROP FULLTEXT INDEX statement (2005+). SQL Syntax: @@ -131,7 +132,7 @@ def format_drop_fulltext_index_statement(self, table: str) -> str: "FULLTEXT indexes", "requires SQL Server 2005+.", ) - return f"DROP FULLTEXT INDEX ON {self.format_identifier(table)}" + return f"DROP FULLTEXT INDEX ON {self.format_identifier(table)}", () def format_contains_predicate(self, column: str, search_string: str) -> Tuple[str, tuple]: """Format a CONTAINS predicate delegating to format_fulltext_match.""" @@ -163,7 +164,7 @@ def supports_readpast(self) -> bool: """READPAST hint requires SQL Server 2019+.""" return self.version >= (15, 0, 0) - def format_table_hint_locking(self, hint_type: str) -> str: + def format_table_hint_locking(self, hint_type: str) -> Tuple[str, tuple]: """Format a locking table hint delegating to SQLServerTableHintClause.""" from ..expression.locking import SQLServerTableHint, SQLServerTableHintClause @@ -172,7 +173,7 @@ def format_table_hint_locking(self, hint_type: str) -> str: if "READPAST" in hint_type and self.supports_readpast(): hints.append(SQLServerTableHint("READPAST")) clause = SQLServerTableHintClause(self, hints) - return clause.to_sql()[0] + return clause.to_sql() def supports_try_cast(self) -> bool: """TRY_CAST requires SQL Server 2012+.""" @@ -200,14 +201,14 @@ def supports_table_hints(self) -> bool: """SQL Server supports WITH (...) table hints.""" return True - def format_table_hint(self, hints: List[str]) -> str: + def format_table_hint(self, hints: List[str]) -> Tuple[str, tuple]: """Format a WITH (hint1, hint2, ...) table hint clause.""" from ..expression.locking import SQLServerTableHint, SQLServerTableHintClause clause = SQLServerTableHintClause( self, [SQLServerTableHint(hint) for hint in hints] ) - return clause.to_sql()[0] + return clause.to_sql() def supports_select_into(self) -> bool: """SQL Server supports SELECT INTO.""" @@ -222,7 +223,7 @@ def format_tablesample_clause( sample: int, percentage: bool = False, repeatable: Optional[int] = None, - ) -> str: + ) -> Tuple[str, tuple]: """Format a TABLESAMPLE clause (SQL Server 2005+). SQL Syntax: @@ -234,14 +235,14 @@ def format_tablesample_clause( repeatable: Optional REPEATABLE seed for reproducible sampling. Returns: - The TABLESAMPLE clause, e.g. - ``TABLESAMPLE (10 ROWS) REPEATABLE (42)``. + Tuple of (SQL string, params tuple), e.g. + ``(TABLESAMPLE (10 ROWS) REPEATABLE (42), ())``. """ unit = "PERCENT" if percentage else "ROWS" clause = f"TABLESAMPLE ({sample} {unit})" if repeatable is not None: clause += f" REPEATABLE ({repeatable})" - return clause + return clause, () def format_select_into_statement(self, expr: Any) -> Tuple[str, tuple]: """Format a SELECT INTO statement (all SQL Server versions). @@ -292,21 +293,15 @@ def format_openjson_expression( self, json_doc: str, path: Optional[str] = None, - schema: Optional[Dict[str, str]] = None, + schema: Optional[List[Any]] = None, alias: Optional[str] = None, ) -> Tuple[str, tuple]: """Format an OPENJSON expression delegating to SQLServerOpenJsonExpression.""" from ..expression.openjson import OpenJsonColumn, SQLServerOpenJsonExpression - columns = [] + columns: List[OpenJsonColumn] = [] if schema: - if isinstance(schema, dict): - columns = [ - OpenJsonColumn(name=name, type=type_name) - for name, type_name in schema.items() - ] - else: - columns = list(schema) + columns = list(schema) return SQLServerOpenJsonExpression( self, json_doc, path=path, schema=columns, alias=alias ).to_sql() @@ -315,13 +310,14 @@ def supports_system_versioning(self) -> bool: """Temporal table system-versioning requires SQL Server 2016+.""" return self.version >= (13, 0, 0) - def format_temporal_period_definition(self, start_column: str, end_column: str) -> str: + def format_temporal_period_definition(self, start_column: str, end_column: str) -> Tuple[str, tuple]: """Format PERIOD FOR SYSTEM_TIME (start, end).""" from ..expression.temporal import SQLServerTemporalPeriodDefinition - return SQLServerTemporalPeriodDefinition( + sql = SQLServerTemporalPeriodDefinition( self, start_column, end_column - ).to_sql()[0] + ).to_sql() + return sql, () def format_create_temporal_table_statement(self, expr: Any) -> Tuple[str, tuple]: """Format CREATE TABLE with temporal options - not yet implemented.""" @@ -363,10 +359,10 @@ def supports_identity_insert(self) -> bool: """SQL Server supports SET IDENTITY_INSERT.""" return True - def format_set_identity_insert(self, table: str, on: bool) -> str: + def format_set_identity_insert(self, table: str, on: bool) -> Tuple[str, tuple]: """Format SET IDENTITY_INSERT table {ON|OFF}.""" state = "ON" if on else "OFF" - return f"SET IDENTITY_INSERT {self.format_identifier(table)} {state}" + return f"SET IDENTITY_INSERT {self.format_identifier(table)} {state}", () def format_create_indexed_view_statement(self, expr: Any) -> Tuple[str, tuple]: """Format CREATE VIEW WITH SCHEMABINDING (indexed view) (2005+). @@ -431,7 +427,7 @@ def supports_json_function(self, function_name: str) -> bool: return self.version >= (16, 0, 0) return self.version >= self._JSON_FUNCTION_MIN_VERSION - def format_json_extract(self, expr) -> Tuple[str, tuple]: + def format_json_extract(self, expr: Any) -> Tuple[str, tuple]: """Format a JSON extraction as JSON_VALUE (single path only). SQL Server's JSON_VALUE returns the scalar value at the path, already @@ -447,7 +443,7 @@ def format_json_extract(self, expr) -> Tuple[str, tuple]: sql = f"JSON_VALUE({expr.json_doc}, {placeholder})" return self._with_alias(sql, expr.alias), (expr.path,) - def format_json_unquote(self, expr) -> Tuple[str, tuple]: + def format_json_unquote(self, expr: Any) -> Tuple[str, tuple]: """Format JSON_UNQUOTE - not needed in SQL Server. JSON_VALUE already returns scalar values without surrounding quotes, @@ -459,7 +455,7 @@ def format_json_unquote(self, expr) -> Tuple[str, tuple]: "SQL Server's JSON_VALUE returns scalar values already unquoted.", ) - def format_json_object(self, expr) -> Tuple[str, tuple]: + def format_json_object(self, expr: Any) -> Tuple[str, tuple]: """Format a JSON_OBJECT function (SQL Server 2022+). SQL Syntax: @@ -480,7 +476,7 @@ def format_json_object(self, expr) -> Tuple[str, tuple]: sql = f"JSON_OBJECT({', '.join(parts)})" return self._with_alias(sql, expr.alias), tuple(params) - def format_json_array(self, expr) -> Tuple[str, tuple]: + def format_json_array(self, expr: Any) -> Tuple[str, tuple]: """Format a JSON_ARRAY function (SQL Server 2022+).""" values = expr.values if not values: @@ -489,7 +485,7 @@ def format_json_array(self, expr) -> Tuple[str, tuple]: sql = f"JSON_ARRAY({', '.join([placeholder] * len(values))})" return self._with_alias(sql, expr.alias), tuple(values) - def format_json_contains(self, expr) -> Tuple[str, tuple]: + def format_json_contains(self, expr: Any) -> Tuple[str, tuple]: """Format a JSON containment check using OPENJSON (2016+). SQL Server has no JSON_CONTAINS; membership of ``candidate`` in the @@ -503,7 +499,7 @@ def format_json_contains(self, expr) -> Tuple[str, tuple]: ) return self._with_alias(sql, expr.alias), (json_path, expr.candidate) - def format_json_set(self, expr) -> Tuple[str, tuple]: + def format_json_set(self, expr: Any) -> Tuple[str, tuple]: """Format a JSON_MODIFY function (single path-value pair only). SQL Server's JSON_MODIFY accepts exactly one path-value pair. @@ -518,7 +514,7 @@ def format_json_set(self, expr) -> Tuple[str, tuple]: sql = f"JSON_MODIFY({expr.json_doc}, {placeholder}, {placeholder})" return self._with_alias(sql, expr.alias), (expr.path, expr.value) - def format_json_remove(self, expr) -> Tuple[str, tuple]: + def format_json_remove(self, expr: Any) -> Tuple[str, tuple]: """Format a JSON property removal using JSON_MODIFY (single path only). SQL Server removes a property by setting it to NULL with JSON_MODIFY. @@ -533,7 +529,7 @@ def format_json_remove(self, expr) -> Tuple[str, tuple]: sql = f"JSON_MODIFY({expr.json_doc}, {placeholder}, NULL)" return self._with_alias(sql, expr.alias), (expr.path,) - def format_json_type(self, expr) -> Tuple[str, tuple]: + def format_json_type(self, expr: Any) -> Tuple[str, tuple]: """Format JSON_TYPE - not supported by SQL Server.""" raise UnsupportedFeatureError( self.name, @@ -541,7 +537,7 @@ def format_json_type(self, expr) -> Tuple[str, tuple]: "SQL Server has no JSON_TYPE; classify values via OPENJSON.", ) - def format_json_valid(self, expr) -> Tuple[str, tuple]: + def format_json_valid(self, expr: Any) -> Tuple[str, tuple]: """Format an ISJSON function (SQL Server equivalent of JSON_VALID).""" sql = f"ISJSON({expr.json_val})" return self._with_alias(sql, expr.alias), () @@ -562,7 +558,7 @@ def supports_set_type(self) -> bool: """SQL Server has no MySQL-style SET column type.""" return False - def format_set_literal(self, expr) -> Tuple[str, tuple]: + def format_set_literal(self, expr: Any) -> Tuple[str, tuple]: """Format a comma-separated value as a bind parameter. SQL Server stores comma-separated values in VARCHAR columns; the @@ -593,7 +589,7 @@ def _format_find_in_set_parts(self, value: str, set_column: str) -> Tuple[str, t col_sql = self.format_identifier(set_column) return f"(',' + {col_sql} + ',' LIKE '%,' + {placeholder} + ',%')", (value,) - def format_find_in_set(self, expr) -> Tuple[str, tuple]: + def format_find_in_set(self, expr: Any) -> Tuple[str, tuple]: """Format a comma-separated membership check. SQL Server has no FIND_IN_SET; membership is checked with a LIKE @@ -602,7 +598,7 @@ def format_find_in_set(self, expr) -> Tuple[str, tuple]: sql, params = self._format_find_in_set_parts(expr.value, expr.set_column) return self._with_alias(sql, expr.alias), params - def format_set_contains(self, expr) -> Tuple[str, tuple]: + def format_set_contains(self, expr: Any) -> Tuple[str, tuple]: """Format a set-containment predicate as AND-ed membership checks.""" conditions = [] params: List[str] = [] @@ -639,11 +635,11 @@ def supports_geojson(self) -> bool: """STAsGeoJSON is not available in the installed SQL Server types assembly.""" return False - def format_spatial_literal(self, expr) -> Tuple[str, tuple]: + def format_spatial_literal(self, expr: Any) -> Tuple[str, tuple]: """Format a WKT literal using geometry::STGeomFromText.""" return self.format_st_geom_from_text(expr) - def format_st_geom_from_text(self, expr) -> Tuple[str, tuple]: + def format_st_geom_from_text(self, expr: Any) -> Tuple[str, tuple]: """Format a geometry::STGeomFromText call. SQL Server requires the SRID argument; it defaults to 0. @@ -656,32 +652,32 @@ def format_st_geom_from_text(self, expr) -> Tuple[str, tuple]: sql = f"geometry::STGeomFromText({placeholder}, {placeholder})" return self._with_alias(sql, expr.alias), (expr.wkt, srid) - def format_st_as_text(self, expr) -> Tuple[str, tuple]: + def format_st_as_text(self, expr: Any) -> Tuple[str, tuple]: """Format a geometry STAsText method call: ``geom.STAsText()``.""" sql = f"{expr.geom}.STAsText()" return self._with_alias(sql, expr.alias), () - def format_st_as_geojson(self, expr) -> Tuple[str, tuple]: + def format_st_as_geojson(self, expr: Any) -> Tuple[str, tuple]: """Format a geometry STAsGeoJSON method call: ``geom.STAsGeoJSON()``.""" sql = f"{expr.geom}.STAsGeoJSON()" return self._with_alias(sql, expr.alias), () - def format_st_distance(self, expr) -> Tuple[str, tuple]: + def format_st_distance(self, expr: Any) -> Tuple[str, tuple]: """Format a geometry STDistance method call: ``g1.STDistance(g2)``.""" sql = f"{expr.geom1}.STDistance({expr.geom2})" return self._with_alias(sql, expr.alias), () - def format_st_within(self, expr) -> Tuple[str, tuple]: + def format_st_within(self, expr: Any) -> Tuple[str, tuple]: """Format a geometry STWithin method call: ``g1.STWithin(g2)``.""" sql = f"{expr.geom1}.STWithin({expr.geom2})" return self._with_alias(sql, expr.alias), () - def format_st_contains(self, expr) -> Tuple[str, tuple]: + def format_st_contains(self, expr: Any) -> Tuple[str, tuple]: """Format a geometry STContains method call: ``g1.STContains(g2)``.""" sql = f"{expr.geom1}.STContains({expr.geom2})" return self._with_alias(sql, expr.alias), () - def format_st_distance_sphere(self, expr) -> Tuple[str, tuple]: + def format_st_distance_sphere(self, expr: Any) -> Tuple[str, tuple]: """Format a spherical distance check. SQL Server has no ST_Distance_Sphere; STDistance is used for the @@ -690,12 +686,12 @@ def format_st_distance_sphere(self, expr) -> Tuple[str, tuple]: sql = f"{expr.geom1}.STDistance({expr.geom2})" return self._with_alias(sql, expr.alias), () - def format_st_intersects(self, expr) -> Tuple[str, tuple]: + def format_st_intersects(self, expr: Any) -> Tuple[str, tuple]: """Format a geometry STIntersects method call: ``g1.STIntersects(g2)``.""" sql = f"{expr.geom1}.STIntersects({expr.geom2})" return self._with_alias(sql, expr.alias), () - def format_create_spatial_index(self, expr) -> Tuple[str, tuple]: + def format_create_spatial_index(self, expr: Any) -> Tuple[str, tuple]: """Format a CREATE SPATIAL INDEX statement. SQL Syntax: diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/sequence.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/sequence.py index 8ae6928..9a49a5e 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/sequence.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/sequence.py @@ -6,7 +6,7 @@ ``SQLServerNextValueForExpression``. """ -from typing import Any +from typing import Any, Tuple from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError @@ -21,7 +21,7 @@ def supports_sequence_as_data_type(self) -> bool: """SQL Server sequences are not usable as column data types.""" return False - def format_next_value_for(self, sequence: Any) -> str: + def format_next_value_for(self, sequence: Any) -> Tuple[str, tuple]: """Format NEXT VALUE FOR expression (SQL Server 2012+). Args: @@ -29,7 +29,8 @@ def format_next_value_for(self, sequence: Any) -> str: sequence name string. Returns: - SQL string such as ``NEXT VALUE FOR [my_seq]``. + Tuple of (SQL string, params tuple) such as + ``(NEXT VALUE FOR [my_seq], ())``. """ if self.version < _SQL_SERVER_NEXT_VALUE_FOR_VERSION: # type: ignore[attr-defined] raise UnsupportedFeatureError( @@ -49,7 +50,7 @@ def format_next_value_for(self, sequence: Any) -> str: else: quoted = self.format_identifier(sequence_name) # type: ignore[attr-defined] - return f"NEXT VALUE FOR {quoted}" + return f"NEXT VALUE FOR {quoted}", () # --- Sequence capability declarations (moved from dialect.py) --- diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/fulltext.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/fulltext.py index ed4c53b..f04d8e7 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/fulltext.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/fulltext.py @@ -7,16 +7,16 @@ class SQLServerFullTextSearchSupport(Protocol): def supports_fulltext_catalog(self) -> bool: ... def format_create_fulltext_catalog_statement( self, catalog_name: str, as_default: bool = False - ) -> str: ... - def format_drop_fulltext_catalog_statement(self, catalog_name: str) -> str: ... + ) -> Tuple[str, tuple]: ... + def format_drop_fulltext_catalog_statement(self, catalog_name: str) -> Tuple[str, tuple]: ... def format_create_fulltext_index_statement( self, table: str, columns: List[str], key_index: str, catalog_name: str, - ) -> str: ... - def format_drop_fulltext_index_statement(self, table: str) -> str: ... + ) -> Tuple[str, tuple]: ... + def format_drop_fulltext_index_statement(self, table: str) -> Tuple[str, tuple]: ... def format_contains_predicate(self, column: str, search_string: str) -> Tuple[str, tuple]: ... def format_freetext_predicate(self, column: str, search_string: str) -> Tuple[str, tuple]: ... def format_containstable_function( diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/identity.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/identity.py index 3040afa..6102c2b 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/identity.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/identity.py @@ -1,8 +1,8 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/protocols/identity.py -from typing import Protocol, runtime_checkable +from typing import Protocol, Tuple, runtime_checkable @runtime_checkable class SQLServerIdentitySupport(Protocol): def supports_identity_insert(self) -> bool: ... - def format_set_identity_insert(self, table: str, on: bool) -> str: ... \ No newline at end of file + def format_set_identity_insert(self, table: str, on: bool) -> Tuple[str, tuple]: ... \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/locking.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/locking.py index bee6870..363c2b6 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/locking.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/locking.py @@ -1,5 +1,5 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/protocols/locking.py -from typing import Protocol, runtime_checkable +from typing import Protocol, Tuple, runtime_checkable from rhosocial.activerecord.backend.dialect.protocols import LockingSupport @@ -7,4 +7,4 @@ @runtime_checkable class SQLServerLockingSupport(LockingSupport, Protocol): def supports_readpast(self) -> bool: ... - def format_table_hint_locking(self, hint_type: str) -> str: ... \ No newline at end of file + def format_table_hint_locking(self, hint_type: str) -> Tuple[str, tuple]: ... \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/sequence.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/sequence.py index 6e63573..d1ff930 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/sequence.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/sequence.py @@ -1,8 +1,8 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/protocols/sequence.py -from typing import Protocol, runtime_checkable +from typing import Any, Protocol, Tuple, runtime_checkable @runtime_checkable class SQLServerSequenceSupport(Protocol): def supports_sequence_as_data_type(self) -> bool: ... - def format_next_value_for(self, sequence_name: str) -> str: ... \ No newline at end of file + def format_next_value_for(self, sequence: Any) -> Tuple[str, tuple]: ... \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/table_hint.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/table_hint.py index deeedec..221a794 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/table_hint.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/table_hint.py @@ -1,8 +1,8 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/protocols/table_hint.py -from typing import Protocol, List, runtime_checkable +from typing import Protocol, List, Tuple, runtime_checkable @runtime_checkable class SQLServerTableHintSupport(Protocol): def supports_table_hints(self) -> bool: ... - def format_table_hint(self, hints: List[str]) -> str: ... \ No newline at end of file + def format_table_hint(self, hints: List[str]) -> Tuple[str, tuple]: ... \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/temporal.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/temporal.py index 1856ea7..2f1424c 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/temporal.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/temporal.py @@ -5,5 +5,5 @@ @runtime_checkable class SQLServerTemporalTableSupport(Protocol): def supports_system_versioning(self) -> bool: ... - def format_create_temporal_table_statement(self, expr) -> Tuple[str, tuple]: ... - def format_temporal_period_definition(self, start_column: str, end_column: str) -> str: ... \ No newline at end of file + def format_create_temporal_table_statement(self, expr: object) -> Tuple[str, tuple]: ... + def format_temporal_period_definition(self, start_column: str, end_column: str) -> Tuple[str, tuple]: ... \ No newline at end of file From 89989cbd233c9f0010c16f07091002bec163645c Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 14 Sep 2026 19:06:32 +0800 Subject: [PATCH 33/58] chore: fix .gitignore for .claude/plan*/ --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 147d7ad..9d0040e 100644 --- a/.gitignore +++ b/.gitignore @@ -208,7 +208,7 @@ marimo/_lsp/ __marimo__/ # Project specific -.claude/plan/ +.claude/plan*/ .claude/tmp/ tests/config/ test_connection.py From 8a4905061186a8bbf326c95aa780fe61af7d379b Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 14 Sep 2026 20:40:29 +0800 Subject: [PATCH 34/58] fix: update tests and dialect for signature normalization - format_limit_offset returns (str, tuple) not (None, []); fix test assertions - format_top_n_clause returns Tuple[str, tuple] not str; fix test assertions - format_table_hint_locking/format_set_identity_insert return Tuple[str, tuple] - format_create_index_statement: handle string columns via isinstance check - Import ToSQLProtocol from expression.bases for column type checking --- .../backend/impl/sqlserver/dialect.py | 18 ++++++++++++------ .../feature/backend/test_dialect.py | 4 ++-- .../backend/test_dialect_comprehensive.py | 9 ++++++--- .../backend/test_dialect_version_branches.py | 12 ++++++------ .../feature/backend/test_protocol_support.py | 8 ++++---- 5 files changed, 30 insertions(+), 21 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index 455a584..9fe2550 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -11,7 +11,7 @@ from rhosocial.activerecord.backend.dialect.base import SQLDialectBase from rhosocial.activerecord.backend.expression import bases -from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.bases import BaseExpression, ToSQLProtocol from rhosocial.activerecord.backend.dialect.protocols import ( CollationSupport, CTESupport, @@ -648,8 +648,11 @@ def format_inline_index(self, idx_def: "IndexDefinition") -> Tuple[str, tuple]: col_parts = [] for col in idx_def.columns: - col_sql, col_params = col.to_sql() - col_parts.append(col_sql) + if isinstance(col, ToSQLProtocol): + col_sql, col_params = col.to_sql() + col_parts.append(col_sql) + else: + col_parts.append(self.format_identifier(str(col))) cols_str = ', '.join(col_parts) idx_options = getattr(idx_def, "dialect_options", None) or {} @@ -706,9 +709,12 @@ def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[ col_parts = [] for col in expr.columns: - col_sql, col_params = col.to_sql() - col_parts.append(col_sql) - all_params.extend(col_params) + if isinstance(col, ToSQLProtocol): + col_sql, col_params = col.to_sql() + col_parts.append(col_sql) + all_params.extend(col_params) + else: + col_parts.append(self.format_identifier(str(col))) parts.append(f"({', '.join(col_parts)})") if expr.include: diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect.py index 4f15504..fa86b93 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect.py @@ -71,8 +71,8 @@ def test_supports_offset_fetch(self, dialect: SQLServerDialect): def test_format_limit_offset_none(self, dialect: SQLServerDialect): """Test when both limit and offset are None.""" sql, params = dialect.format_limit_offset() - assert sql is None - assert params == [] + assert sql == "" + assert params == () class TestSQLServerDialectStatements: 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 ace07b5..61c4dff 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 @@ -65,7 +65,8 @@ def test_limit_offset_no_limit(self, dialect): def test_limit_offset_none(self, dialect): sql, params = dialect.format_limit_offset() - assert sql is None + assert sql == "" + assert params == () def test_old_version_raises(self, dialect): old = SQLServerDialect((10, 0, 0)) @@ -482,12 +483,14 @@ def dialect(self): return SQLServerDialect(SQL_SERVER_2022) def test_top_n(self, dialect): - result = dialect.format_top_n_clause(10) + result, params = dialect.format_top_n_clause(10) assert result == "TOP 10" + assert params == () def test_top_n_percent(self, dialect): - result = dialect.format_top_n_clause(50, percentage=True) + result, params = dialect.format_top_n_clause(50, percentage=True) assert result == "TOP 50 PERCENT" + assert params == () class TestSQLServerDialectCTE: 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 0ffc148..895b141 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 @@ -49,7 +49,7 @@ class TestPaginationOffsetFetch: def test_offset_fetch_snapshot(self, version): sql, params = SQLServerDialect(version).format_limit_offset(10, 5) assert sql == "OFFSET 5 ROWS FETCH NEXT 10 ROWS ONLY" - assert params == [] + assert params == () @pytest.mark.parametrize("version", [(11, 0, 0), (13, 0, 0), (15, 0, 0), (16, 0, 0)]) def test_limit_only_defaults_offset_to_zero(self, version): @@ -64,7 +64,7 @@ def test_offset_only_has_no_fetch(self, version): @pytest.mark.parametrize("version", [(11, 0, 0), (13, 0, 0), (15, 0, 0), (16, 0, 0)]) def test_both_none_renders_nothing(self, version): - assert SQLServerDialect(version).format_limit_offset() == (None, []) + assert SQLServerDialect(version).format_limit_offset() == ("", ()) def test_pre_2012_raises_with_row_number_suggestion(self): with pytest.raises(UnsupportedFeatureError) as exc: @@ -272,8 +272,8 @@ def test_table_hint_single_and_multiple(self): def test_locking_hints_gain_readpast_only_on_2019_plus(self): base = SQLServerDialect((13, 0, 0)).format_table_hint_locking("UPDLOCK, READPAST") newer = SQLServerDialect((15, 0, 0)).format_table_hint_locking("UPDLOCK, READPAST") - assert base == "WITH (UPDLOCK, ROWLOCK)" # READPAST silently dropped pre-2019 - assert newer == "WITH (UPDLOCK, ROWLOCK, READPAST)" + assert base == ("WITH (UPDLOCK, ROWLOCK)", ()) # READPAST silently dropped pre-2019 + assert newer == ("WITH (UPDLOCK, ROWLOCK, READPAST)", ()) def test_for_update_clause_hint_rendering(self): from rhosocial.activerecord.backend.expression.query_parts import ForUpdateClause @@ -379,8 +379,8 @@ def test_option_query_hint_clause(self): def test_top_n_clause_variants(self): d = SQLServerDialect((16, 0, 0)) - assert d.format_top_n_clause(10) == "TOP 10" - assert d.format_top_n_clause(5, percentage=True) == "TOP 5 PERCENT" + assert d.format_top_n_clause(10) == ("TOP 10", ()) + assert d.format_top_n_clause(5, percentage=True) == ("TOP 5 PERCENT", ()) def test_lateral_renders_as_apply(self): d = SQLServerDialect((16, 0, 0)) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_protocol_support.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_protocol_support.py index 7e8bde0..e3aefa6 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_protocol_support.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_protocol_support.py @@ -381,12 +381,12 @@ def test_supports_readpast_below_2019(self): def test_format_table_hint_locking(self, dialect): assert dialect.format_table_hint_locking("UPDLOCK") == ( - "WITH (UPDLOCK, ROWLOCK)" + "WITH (UPDLOCK, ROWLOCK)", () ) def test_format_table_hint_locking_readpast(self, dialect): assert dialect.format_table_hint_locking("READPAST") == ( - "WITH (UPDLOCK, ROWLOCK, READPAST)" + "WITH (UPDLOCK, ROWLOCK, READPAST)", () ) def test_supports_try_cast(self, dialect): @@ -477,12 +477,12 @@ def test_supports_identity_insert(self, dialect): def test_format_set_identity_insert_on(self, dialect): assert dialect.format_set_identity_insert("orders", True) == ( - "SET IDENTITY_INSERT [orders] ON" + "SET IDENTITY_INSERT [orders] ON", () ) def test_format_set_identity_insert_off(self, dialect): assert dialect.format_set_identity_insert("orders", False) == ( - "SET IDENTITY_INSERT [orders] OFF" + "SET IDENTITY_INSERT [orders] OFF", () ) def test_supports_indexed_view(self, dialect): From 172374f55f4b4f994283b4ee032796112868123b Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 14 Sep 2026 23:41:33 +0800 Subject: [PATCH 35/58] fix: update tests to unpack (sql, params) tuple from format_tablesample_clause --- .../backend/test_p1_coverage_features.py | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p1_coverage_features.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p1_coverage_features.py index 637d0bc..96fcf1a 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p1_coverage_features.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p1_coverage_features.py @@ -189,26 +189,29 @@ def test_supports_select_into(self, dialect): assert dialect.supports_select_into() is True def test_tablesample_rows(self, dialect): - assert dialect.format_tablesample_clause(10) == "TABLESAMPLE (10 ROWS)" + sql, params = dialect.format_tablesample_clause(10) + assert sql == "TABLESAMPLE (10 ROWS)" + assert params == () def test_tablesample_rows_repeatable(self, dialect): - assert dialect.format_tablesample_clause(10, repeatable=42) == ( - "TABLESAMPLE (10 ROWS) REPEATABLE (42)" - ) + sql, params = dialect.format_tablesample_clause(10, repeatable=42) + assert sql == "TABLESAMPLE (10 ROWS) REPEATABLE (42)" + assert params == () def test_tablesample_percent(self, dialect): - assert dialect.format_tablesample_clause(25, percentage=True) == ( - "TABLESAMPLE (25 PERCENT)" - ) + sql, params = dialect.format_tablesample_clause(25, percentage=True) + assert sql == "TABLESAMPLE (25 PERCENT)" + assert params == () def test_tablesample_percent_repeatable(self, dialect): - assert dialect.format_tablesample_clause(25, percentage=True, repeatable=7) == ( - "TABLESAMPLE (25 PERCENT) REPEATABLE (7)" - ) + sql, params = dialect.format_tablesample_clause(25, percentage=True, repeatable=7) + assert sql == "TABLESAMPLE (25 PERCENT) REPEATABLE (7)" + assert params == () def test_tablesample_appended_to_query_sql(self, dialect): query_sql = _make_query(dialect).to_sql()[0] - combined = f"{query_sql} {dialect.format_tablesample_clause(10, repeatable=42)}" + ts_sql, _ = dialect.format_tablesample_clause(10, repeatable=42) + combined = f"{query_sql} {ts_sql}" assert combined == "SELECT [a], [b] FROM [t] TABLESAMPLE (10 ROWS) REPEATABLE (42)" def test_select_into_statement(self, dialect): From db1de28a5a82288810773c40cc2822ccb0efe1b8 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 15 Sep 2026 00:38:52 +0800 Subject: [PATCH 36/58] fix: handle BaseExpression default values in format_column_definition When DEFAULT value is a FunctionCall or other BaseExpression, call to_sql() instead of inline_sql_literal() which cannot handle expression objects. --- .../activerecord/backend/impl/sqlserver/dialect.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index 9fe2550..2294e4c 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -579,8 +579,16 @@ def format_column_definition(self, col_def: "ColumnDefinition") -> Tuple[str, tu constraint_parts.append("UNIQUE") elif constraint.constraint_type == ColumnConstraintType.DEFAULT: if constraint.default_value is not None: - default_sql = self.inline_sql_literal(constraint.default_value) - constraint_parts.append(f"DEFAULT {default_sql}") + from rhosocial.activerecord.backend.expression import bases + if isinstance(constraint.default_value, bases.BaseExpression): + default_sql, default_params = constraint.default_value.to_sql() + constraint_parts.append(f"DEFAULT {default_sql}") + params.extend(default_params) + elif isinstance(constraint.default_value, str): + escaped = self._escape_sql_string(constraint.default_value) + constraint_parts.append(f"DEFAULT '{escaped}'") + else: + constraint_parts.append(f"DEFAULT {constraint.default_value}") elif constraint.constraint_type == ColumnConstraintType.NULL: constraint_parts.append("NULL") From f16a5d188c9cd8784343b4153dfae18130dc9093 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 15 Sep 2026 08:47:15 +0800 Subject: [PATCH 37/58] refactor(ddl)!: SQL Server LIKE unsupported via CreateTableLikeExpression - remove the inline dialect_options['like_table'] guard from format_create_table_statement; SQL Server inherits supports_create_table_like() == False, so the gated generic format_create_table_like_statement raises UnsupportedFeatureError - tests migrate to CreateTableLikeExpression and assert the raise BREAKING CHANGE: CREATE TABLE ... LIKE is no longer expressed through CreateTableExpression(dialect_options={'like_table': ...}); use CreateTableLikeExpression, which raises UnsupportedFeatureError on SQL Server. --- .../backend/impl/sqlserver/dialect.py | 10 +--- .../feature/backend/test_create_table_like.py | 59 ++++++++----------- .../backend/test_dialect_version_branches.py | 13 ++-- 3 files changed, 31 insertions(+), 51 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index 2294e4c..c26702a 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -501,19 +501,15 @@ def format_create_table_statement( SQL Server doesn't support IF NOT EXISTS syntax for CREATE TABLE (until SQL Server 2016 for DROP, but not CREATE). When if_not_exists is True, we simply skip the IF NOT EXISTS clause since it's not supported. - SQL Server has no CREATE TABLE ... LIKE; ``like_table`` raises + SQL Server has no CREATE TABLE ... LIKE (``supports_create_table_like`` + stays ``False``), so the gated + ``format_create_table_like_statement`` raises ``UnsupportedFeatureError``. Temporary tables use a ``#``-prefixed table name instead of the ``TEMPORARY`` keyword. """ all_params: List[Any] = [] dialect_options = getattr(expr, "dialect_options", {}) or {} - if dialect_options.get("like_table"): - raise UnsupportedFeatureError( - self.name, - "CREATE TABLE ... LIKE", - "SQL Server has no CREATE TABLE ... LIKE syntax; use SELECT INTO or an explicit CREATE TABLE statement.", - ) parts = ["CREATE TABLE"] diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_create_table_like.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_create_table_like.py index 22b368e..385f60b 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_create_table_like.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_create_table_like.py @@ -3,13 +3,19 @@ SQL Server CREATE TABLE behavior tests. SQL Server has no CREATE TABLE ... LIKE syntax and no TEMPORARY keyword. -This module verifies that ``like_table`` raises ``UnsupportedFeatureError``, -temporary tables render ``#``-prefixed names, and identifiers use brackets. +This module verifies that ``CreateTableLikeExpression`` raises +``UnsupportedFeatureError`` (the dialect keeps +``supports_create_table_like`` at ``False``), temporary tables render +``#``-prefixed names, and identifiers use brackets. """ import pytest from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression import CreateTableExpression, ColumnDefinition -from rhosocial.activerecord.backend.expression.statements import ColumnConstraint, ColumnConstraintType +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, + ColumnConstraintType, + CreateTableLikeExpression, +) from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect @@ -17,14 +23,17 @@ class TestSQLServerCreateTableLike: """Tests for CREATE TABLE behavior on SQL Server.""" + def test_like_not_supported_by_dialect(self): + """Test the dialect does not advertise CREATE TABLE ... LIKE support.""" + assert SQLServerDialect().supports_create_table_like() is False + def test_basic_like_raises(self): """Test that CREATE TABLE ... LIKE raises UnsupportedFeatureError.""" dialect = SQLServerDialect() - create_expr = CreateTableExpression( + create_expr = CreateTableLikeExpression( dialect=dialect, table="users_copy", - columns=[], - dialect_options={'like_table': 'users'} + like_table="users", ) with pytest.raises(UnsupportedFeatureError): create_expr.to_sql() @@ -32,12 +41,11 @@ def test_basic_like_raises(self): def test_like_with_if_not_exists_raises(self): """Test CREATE TABLE ... LIKE with IF NOT EXISTS raises.""" dialect = SQLServerDialect() - create_expr = CreateTableExpression( + create_expr = CreateTableLikeExpression( dialect=dialect, table="users_copy", - columns=[], + like_table="users", if_not_exists=True, - dialect_options={'like_table': 'users'} ) with pytest.raises(UnsupportedFeatureError): create_expr.to_sql() @@ -45,12 +53,11 @@ def test_like_with_if_not_exists_raises(self): def test_like_with_temporary_raises(self): """Test CREATE TABLE ... LIKE with temporary raises.""" dialect = SQLServerDialect() - create_expr = CreateTableExpression( + create_expr = CreateTableLikeExpression( dialect=dialect, table="temp_users", - columns=[], + like_table="users", temporary=True, - dialect_options={'like_table': 'users'} ) with pytest.raises(UnsupportedFeatureError): create_expr.to_sql() @@ -58,29 +65,10 @@ def test_like_with_temporary_raises(self): def test_like_with_schema_qualified_table_raises(self): """Test CREATE TABLE ... LIKE with schema-qualified source raises.""" dialect = SQLServerDialect() - create_expr = CreateTableExpression( - dialect=dialect, - table="users_copy", - columns=[], - dialect_options={'like_table': ('production', 'users')} - ) - with pytest.raises(UnsupportedFeatureError): - create_expr.to_sql() - - def test_like_ignores_columns_raises(self): - """Test that LIKE syntax raises even when columns are present.""" - dialect = SQLServerDialect() - columns = [ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY) - ]), - ColumnDefinition(dialect, "name", VarCharType(dialect, length=255)) - ] - create_expr = CreateTableExpression( + create_expr = CreateTableLikeExpression( dialect=dialect, table="users_copy", - columns=columns, - dialect_options={'like_table': 'users'} + like_table=("production", "users"), ) with pytest.raises(UnsupportedFeatureError): create_expr.to_sql() @@ -88,13 +76,12 @@ def test_like_ignores_columns_raises(self): def test_like_with_temporary_and_if_not_exists_raises(self): """Test CREATE TABLE ... LIKE with temporary and IF NOT EXISTS raises.""" dialect = SQLServerDialect() - create_expr = CreateTableExpression( + create_expr = CreateTableLikeExpression( dialect=dialect, table="temp_users_copy", - columns=[], + like_table=("test_db", "users"), temporary=True, if_not_exists=True, - dialect_options={'like_table': ('test_db', 'users')} ) with pytest.raises(UnsupportedFeatureError): create_expr.to_sql() 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 895b141..cead7cb 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 @@ -243,20 +243,17 @@ def test_temporary_table_keeps_schema_prefix_order(self): assert expr.to_sql()[0] == "CREATE TABLE [dbo].[#t] ([id] INT)" def test_create_table_like_is_unsupported(self): - from rhosocial.activerecord.backend.expression.core import TableExpression from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, - CreateTableExpression, + CreateTableLikeExpression, ) - from rhosocial.activerecord.backend.expression.types import IntegerType d = SQLServerDialect((16, 0, 0)) - expr = CreateTableExpression( + assert d.supports_create_table_like() is False + expr = CreateTableLikeExpression( dialect=d, - table=TableExpression(d, "copy"), - columns=[ColumnDefinition(d, name="id", data_type=IntegerType(d))], + table="copy", + like_table="original", ) - expr.dialect_options["like_table"] = "original" with pytest.raises(UnsupportedFeatureError): expr.to_sql() From 4d9a4dead0ebb4b757e60d516174be610a566a3f Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 15 Sep 2026 11:30:25 +0800 Subject: [PATCH 38/58] feat(ddl): prefer typed table_options for memory_optimized/durability Renderer now reads memory_optimized/durability from typed CreateTableOptions fields first, falling back to dialect_options. --- .../activerecord/backend/impl/sqlserver/dialect.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index c26702a..361f573 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -550,8 +550,14 @@ def format_create_table_statement( parts.append(partition_sql) all_params.extend(partition_params) - if dialect_options.get("memory_optimized"): - durability = dialect_options.get("durability", "SCHEMA_ONLY") + table_options = getattr(expr, "table_options", None) + memory_optimized = getattr(table_options, "memory_optimized", None) if table_options else None + if memory_optimized is None: + memory_optimized = dialect_options.get("memory_optimized") + if memory_optimized: + durability = getattr(table_options, "durability", None) if table_options else None + if durability is None: + durability = dialect_options.get("durability", "SCHEMA_ONLY") parts.append(self.format_memory_optimized_option(durability)) return ' '.join(parts), tuple(all_params) From ef4050012ec6c590fb578f7c0984f4b1945bdd03 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 15 Sep 2026 12:29:55 +0800 Subject: [PATCH 39/58] fix(ddl): fix DROP TABLE CASCADE/RESTRICT and remove override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add supports_drop_table_cascade() → False - Add supports_drop_table_restrict() → False - Remove format_drop_table_statement override (use generic impl) --- .../activerecord/backend/impl/sqlserver/dialect.py | 14 -------------- .../backend/impl/sqlserver/mixins/table.py | 8 ++++++++ 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index 361f573..ac80a7e 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -681,20 +681,6 @@ def format_inline_index(self, idx_def: "IndexDefinition") -> Tuple[str, tuple]: return ' '.join(parts), () - def format_drop_table_statement( - self, expr: "DropTableExpression" - ) -> Tuple[str, tuple]: - """Format DROP TABLE statement for SQL Server.""" - parts = ["DROP TABLE"] - - if expr.if_exists and self.supports_if_exists_table(): - parts.append("IF EXISTS") - - table_sql, table_params = expr.table.to_sql() - parts.append(table_sql) - - return ' '.join(parts), table_params - def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[str, tuple]: """Format CREATE INDEX statement for SQL Server. diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/table.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/table.py index 231f9f5..9f78da2 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/table.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/table.py @@ -21,6 +21,14 @@ def supports_if_exists_table(self) -> bool: """SQL Server supports DROP TABLE IF EXISTS (2016+).""" return self.version >= SQL_SERVER_2016 + def supports_drop_table_cascade(self) -> bool: + """SQL Server does not support CASCADE for DROP TABLE.""" + return False + + def supports_drop_table_restrict(self) -> bool: + """SQL Server does not support RESTRICT for DROP TABLE.""" + return False + def supports_add_column(self) -> bool: """SQL Server supports ALTER TABLE ADD COLUMN.""" return True From 3eff57e102f3782b138b75f72b09ec341bef472b Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 15 Sep 2026 12:38:50 +0800 Subject: [PATCH 40/58] feat(ddl): add supports_multi_action_alter_table(False) SQL Server requires one action per ALTER TABLE statement. --- .../activerecord/backend/impl/sqlserver/mixins/table.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/table.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/table.py index 9f78da2..6d169f0 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/table.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/table.py @@ -29,6 +29,10 @@ def supports_drop_table_restrict(self) -> bool: """SQL Server does not support RESTRICT for DROP TABLE.""" return False + def supports_multi_action_alter_table(self) -> bool: + """SQL Server requires one action per ALTER TABLE statement.""" + return False + def supports_add_column(self) -> bool: """SQL Server supports ALTER TABLE ADD COLUMN.""" return True From d8c6b7e8a7e194623085f8d9bee9c2e006075ba8 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 15 Sep 2026 13:26:23 +0800 Subject: [PATCH 41/58] =?UTF-8?q?refactor(ddl):=20rename=20table.py=20?= =?UTF-8?q?=E2=86=92=20ddl=5Ftable.py=20in=20mixins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py | 2 +- .../activerecord/backend/impl/sqlserver/mixins/__init__.py | 2 +- .../backend/impl/sqlserver/mixins/{table.py => ddl_table.py} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename src/rhosocial/activerecord/backend/impl/sqlserver/mixins/{table.py => ddl_table.py} (100%) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index ac80a7e..1605496 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -138,7 +138,7 @@ from .mixins.index import SQLServerIndexMixin from .mixins.generated_column import SQLServerGeneratedColumnMixin from .mixins.set_operation import SQLServerSetOperationMixin -from .mixins.table import SQLServerTableMixin +from .mixins.ddl_table import SQLServerTableMixin from .mixins.identifier import SQLServerIdentifierMixin from .mixins.transaction import SQLServerTransactionMixin from .mixins.function import SQLServerFunctionMixin diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py index 33e8586..bad86ae 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py @@ -31,7 +31,7 @@ from .index import SQLServerIndexMixin from .generated_column import SQLServerGeneratedColumnMixin from .set_operation import SQLServerSetOperationMixin -from .table import SQLServerTableMixin +from .ddl_table import SQLServerTableMixin from .identifier import SQLServerIdentifierMixin from .transaction import SQLServerTransactionMixin from .function import SQLServerFunctionMixin diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/table.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_table.py similarity index 100% rename from src/rhosocial/activerecord/backend/impl/sqlserver/mixins/table.py rename to src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_table.py From ce245a1cbd8abdc7b89fcefec3bdfe06904bbd7d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 15 Sep 2026 18:42:31 +0800 Subject: [PATCH 42/58] feat: add SQL Server DDL improvements with capability gating - Add SQLServerDatabaseMixin for CREATE/DROP/ALTER DATABASE support - Add SQLServerViewMixin with capability gating - Add test_sqlserver_ddl_improvements.py with 5 tests - Rename view.py -> ddl_view.py --- .../backend/impl/sqlserver/mixins/__init__.py | 2 +- .../impl/sqlserver/mixins/ddl_database.py | 81 +++++++++++++++++++ .../sqlserver/mixins/{view.py => ddl_view.py} | 10 ++- .../test_sqlserver_ddl_improvements.py | 47 +++++++++++ 4 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_database.py rename src/rhosocial/activerecord/backend/impl/sqlserver/mixins/{view.py => ddl_view.py} (87%) create mode 100644 tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_ddl_improvements.py diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py index bad86ae..1d2aaaa 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py @@ -26,7 +26,7 @@ from .explain import SQLServerExplainMixin from .dql import SQLServerDQLMixin from .dml import SQLServerDMLMixin -from .view import SQLServerViewMixin +from .ddl_view import SQLServerViewMixin from .schema import SQLServerSchemaMixin from .index import SQLServerIndexMixin from .generated_column import SQLServerGeneratedColumnMixin diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_database.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_database.py new file mode 100644 index 0000000..abc28e9 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_database.py @@ -0,0 +1,81 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_database.py +"""SQL Server database DDL mixin.""" +from __future__ import annotations + +from typing import Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.expression.statements.ddl_database import ( + AlterDatabaseExpression, + CreateDatabaseExpression, + DropDatabaseExpression, + ) + + +class SQLServerDatabaseMixin: + """SQL Server database DDL support. + + SQL Server supports CREATE/DROP/ALTER DATABASE with AUTHORIZATION, + COLLATE, file groups, and rich ALTER options. + """ + + def supports_database(self) -> bool: + return True + + def supports_create_database(self) -> bool: + return True + + def supports_drop_database(self) -> bool: + return True + + def supports_alter_database(self) -> bool: + return True + + def supports_database_owner(self) -> bool: + """SQL Server supports AUTHORIZATION.""" + return True + + def supports_database_collation(self) -> bool: + """SQL Server supports COLLATE.""" + return True + + def supports_database_tablespace(self) -> bool: + """SQL Server supports FILEGROUP.""" + return True + + def format_create_database_statement( + self, expr: CreateDatabaseExpression + ) -> Tuple[str, tuple]: + parts = ["CREATE DATABASE"] + parts.append(self.format_identifier(expr.database_name)) + if expr.collation: + parts.append(f"COLLATE {expr.collation}") + if expr.owner: + parts.append(f"AUTHORIZATION {self.format_identifier(expr.owner)}") + return " ".join(parts), () + + def format_drop_database_statement( + self, expr: DropDatabaseExpression + ) -> Tuple[str, tuple]: + parts = ["DROP DATABASE"] + parts.append(self.format_identifier(expr.database_name)) + return " ".join(parts), () + + def format_alter_database_statement( + self, expr: AlterDatabaseExpression + ) -> Tuple[str, tuple]: + from rhosocial.activerecord.backend.expression.statements.ddl_database import AlterDatabaseAction + parts = ["ALTER DATABASE"] + parts.append(self.format_identifier(expr.database_name)) + if expr.action == AlterDatabaseAction.RENAME_TO: + parts.append(f"MODIFY NAME = {self.format_identifier(expr.target)}") + elif expr.action == AlterDatabaseAction.OWNER_TO: + parts.append(f"SET AUTHORIZATION TO {self.format_identifier(expr.target)}") + elif expr.action == AlterDatabaseAction.COLLATION: + parts.append(f"COLLATE {expr.target}") + return " ".join(parts), () + + +__all__ = ['SQLServerDatabaseMixin'] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/view.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_view.py similarity index 87% rename from src/rhosocial/activerecord/backend/impl/sqlserver/mixins/view.py rename to src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_view.py index 5ad4fad..fe73ebe 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/view.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_view.py @@ -1,4 +1,4 @@ -# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/view.py +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_view.py from typing import Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError @@ -21,6 +21,14 @@ def supports_indexed_view(self) -> bool: """SQL Server supports indexed views (similar to materialized views).""" return self.version >= SQL_SERVER_2005 + def supports_create_or_replace_view(self) -> bool: + """SQL Server does not support CREATE OR REPLACE VIEW (uses CREATE OR ALTER).""" + return False + + def supports_if_not_exists_view(self) -> bool: + """SQL Server does not support IF NOT EXISTS for views.""" + return False + def supports_if_exists_view(self) -> bool: """SQL Server supports DROP VIEW IF EXISTS (2016+).""" return self.version >= SQL_SERVER_2016 diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_ddl_improvements.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_ddl_improvements.py new file mode 100644 index 0000000..b9c1eb9 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_ddl_improvements.py @@ -0,0 +1,47 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_ddl_improvements.py +"""Tests for SQL Server DDL improvements: capability gating, UnsupportedFeatureError.""" +import pytest +from unittest.mock import patch, PropertyMock + +from rhosocial.activerecord.backend.expression import ( + Column, + TableExpression, + QueryExpression, + CreateViewExpression, + DropViewExpression, +) +from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + +class TestSQLServerViewCapabilityGating: + """Tests for SQL Server VIEW DDL capability gating.""" + + def test_create_or_replace_view_not_supported(self): + """SQL Server does not support CREATE OR REPLACE VIEW.""" + dialect = SQLServerDialect() + assert dialect.supports_create_or_replace_view() is False + + def test_drop_view_if_exists_supported(self): + """SQL Server supports DROP VIEW IF EXISTS.""" + dialect = SQLServerDialect() + assert dialect.supports_if_exists_view() is True + + def test_materialized_view_not_supported(self): + """SQL Server does not support materialized views (use indexed views).""" + dialect = SQLServerDialect() + assert dialect.supports_materialized_view() is False + + +class TestSQLServerSchemaCapabilityGating: + """Tests for SQL Server SCHEMA DDL capability gating.""" + + def test_create_schema_supported(self): + """SQL Server supports CREATE SCHEMA.""" + dialect = SQLServerDialect() + assert dialect.supports_create_schema() is True + + def test_drop_schema_supported(self): + """SQL Server supports DROP SCHEMA.""" + dialect = SQLServerDialect() + assert dialect.supports_drop_schema() is True From 3c6937098d4b8666f922f4921ea5e0936cd7a486 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 15 Sep 2026 23:07:36 +0800 Subject: [PATCH 43/58] refactor: remove ReturningMixin import (merged into DMLMixin in core) --- src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index 1605496..59917af 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -68,7 +68,6 @@ CTEMixin, WindowFunctionMixin, JSONMixin, - ReturningMixin, AdvancedGroupingMixin, ArrayMixin, ExplainMixin, @@ -253,7 +252,6 @@ class SQLServerDialect( CTEMixin, WindowFunctionMixin, JSONMixin, - ReturningMixin, AdvancedGroupingMixin, ArrayMixin, ExplainMixin, From e252dda8643e5d335ec8ae6f0ced2e6968da2e5c Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 16 Sep 2026 00:57:31 +0800 Subject: [PATCH 44/58] feat: improve OUTPUT clause (per-expression prefix, INTO, validation) - Per-expression virtual-table prefix (Column(table='DELETED'|'INSERTED')) - ReturningClause.output_into renders OUTPUT ... INTO - Raise UnsupportedFeatureError for non-column expressions, wildcards, aliases - supports_returning_alias/wildcard False, supports_returning_into True - Fix stale .mixins.view import (renamed to ddl_view) - Add OUTPUT tests --- .../backend/impl/sqlserver/dialect.py | 2 +- .../impl/sqlserver/mixins/returning.py | 66 +++++++++++++-- .../backend/dialect/test_returning_output.py | 84 +++++++++++++++++++ .../feature/backend/test_dialect.py | 6 +- 4 files changed, 148 insertions(+), 10 deletions(-) create mode 100644 tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_returning_output.py diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index 59917af..4e14f15 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -132,7 +132,7 @@ from .mixins.explain import SQLServerExplainMixin from .mixins.dql import SQLServerDQLMixin from .mixins.dml import SQLServerDMLMixin -from .mixins.view import SQLServerViewMixin +from .mixins.ddl_view import SQLServerViewMixin from .mixins.schema import SQLServerSchemaMixin from .mixins.index import SQLServerIndexMixin from .mixins.generated_column import SQLServerGeneratedColumnMixin diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/returning.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/returning.py index bdbc324..899a0c5 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/returning.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/returning.py @@ -1,6 +1,8 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/mixins/returning.py from typing import Tuple, TYPE_CHECKING +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements import ReturningClause @@ -20,32 +22,82 @@ def supports_returning_delete(self) -> bool: """SQL Server supports OUTPUT clause for DELETE.""" return True + def supports_returning_alias(self) -> bool: + """SQL Server OUTPUT does not support a clause-level ``AS`` alias.""" + return False + + def supports_returning_wildcard(self) -> bool: + """SQL Server OUTPUT requires explicit column references.""" + return False + + def supports_returning_into(self) -> bool: + """SQL Server supports ``OUTPUT ... INTO``.""" + return True + def format_returning_clause(self, clause: "ReturningClause", default_table: str = "INSERTED") -> Tuple[str, tuple]: """ Format RETURNING as OUTPUT clause for SQL Server. SQL Server OUTPUT syntax: - - INSERT: OUTPUT inserted.column, inserted.column2 - - UPDATE: OUTPUT deleted.old_column, inserted.new_column - - DELETE: OUTPUT deleted.column + - INSERT: OUTPUT INSERTED.column, INSERTED.column2 + - UPDATE: OUTPUT DELETED.old_column, INSERTED.new_column + - DELETE: OUTPUT DELETED.column + + Only INSERTED/DELETED virtual-table column references (and raw SQL + escape hatches) are legal in an OUTPUT list. The prefix for each item + comes from the expression's own ``table`` attribute when set + (e.g. ``Column(dialect, "name", table="DELETED")``), otherwise the + ``default_table`` is used. Args: clause: The ReturningClause with expressions to output. default_table: The default virtual table prefix ("INSERTED" for INSERT/UPDATE, "DELETED" for DELETE). + + Raises: + 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.operators import RawSQLExpression + + if clause.alias: + raise UnsupportedFeatureError( + self.name, + "alias in OUTPUT", + "SQL Server OUTPUT does not support a clause-level alias.", + ) + all_params = [] expr_parts = [] for expr in clause.expressions: - if hasattr(expr, 'table') and expr.table: + if isinstance(expr, WildcardExpression): + raise UnsupportedFeatureError( + self.name, + "wildcard in OUTPUT", + "SQL Server OUTPUT requires explicit column references; '*' " + "is not supported.", + ) + if isinstance(expr, Column): + 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}") + elif isinstance(expr, RawSQLExpression): expr_sql, expr_params = expr.to_sql() expr_parts.append(f"{default_table}.{expr_sql}") + all_params.extend(expr_params) else: - expr_sql, expr_params = expr.to_sql() - expr_parts.append(f"{default_table}.{expr_sql}") - all_params.extend(expr_params) + raise UnsupportedFeatureError( + self.name, + "expressions in OUTPUT", + "SQL Server OUTPUT only supports INSERTED/DELETED column " + "references.", + ) output_sql = f"OUTPUT {', '.join(expr_parts)}" + if clause.output_into: + output_sql += f" INTO {clause.output_into}" + return output_sql, tuple(all_params) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_returning_output.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_returning_output.py new file mode 100644 index 0000000..e3fd9fb --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_returning_output.py @@ -0,0 +1,84 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_returning_output.py +"""SQL Server OUTPUT (RETURNING) capability and formatting tests.""" + +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.core import ( + Column, + FunctionCall, + WildcardExpression, +) +from rhosocial.activerecord.backend.expression.statements import ReturningClause +from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect + +pytestmark = [pytest.mark.feature, pytest.mark.backend] + + +@pytest.fixture +def dialect(): + return SQLServerDialect(version=(16, 0, 0)) + + +class TestSQLServerReturningCapabilities: + def test_dml_returning_supported(self, dialect): + assert dialect.supports_returning_insert() is True + assert dialect.supports_returning_update() is True + assert dialect.supports_returning_delete() is True + + def test_alias_unsupported(self, dialect): + assert dialect.supports_returning_alias() is False + + def test_wildcard_unsupported(self, dialect): + assert dialect.supports_returning_wildcard() is False + + def test_into_supported(self, dialect): + assert dialect.supports_returning_into() is True + + +class TestSQLServerOutputFormatting: + def test_insert_default_prefix(self, dialect): + clause = ReturningClause(dialect, expressions=[Column(dialect, "id")]) + sql, _ = dialect.format_returning_clause(clause, default_table="INSERTED") + assert sql == "OUTPUT INSERTED.[id]" + + def test_delete_default_prefix(self, dialect): + clause = ReturningClause(dialect, expressions=[Column(dialect, "id")]) + sql, _ = dialect.format_returning_clause(clause, default_table="DELETED") + assert sql == "OUTPUT DELETED.[id]" + + def test_per_expression_prefix(self, dialect): + clause = ReturningClause( + dialect, + expressions=[ + Column(dialect, "name", table="DELETED"), + Column(dialect, "name", table="INSERTED"), + ], + ) + sql, _ = dialect.format_returning_clause(clause, default_table="INSERTED") + assert "DELETED.[name]" in sql + assert "INSERTED.[name]" in sql + + def test_output_into(self, dialect): + clause = ReturningClause( + dialect, expressions=[Column(dialect, "id")], output_into="@ids" + ) + sql, _ = dialect.format_returning_clause(clause) + assert sql == "OUTPUT INSERTED.[id] INTO @ids" + + def test_rejects_non_column_expression(self, dialect): + clause = ReturningClause( + dialect, expressions=[FunctionCall(dialect, "UPPER", Column(dialect, "name"))] + ) + with pytest.raises(UnsupportedFeatureError, match="expressions in OUTPUT"): + dialect.format_returning_clause(clause) + + def test_rejects_wildcard(self, dialect): + clause = ReturningClause(dialect, expressions=[WildcardExpression(dialect)]) + with pytest.raises(UnsupportedFeatureError, match="wildcard in OUTPUT"): + dialect.format_returning_clause(clause) + + def test_rejects_alias(self, dialect): + clause = ReturningClause(dialect, expressions=[Column(dialect, "id")], alias="r") + with pytest.raises(UnsupportedFeatureError, match="alias in OUTPUT"): + dialect.format_returning_clause(clause) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect.py index fa86b93..4cd1c66 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect.py @@ -124,8 +124,10 @@ def dialect(self): def test_supports_returning(self, dialect: SQLServerDialect): """Test that SQL Server supports OUTPUT clause (RETURNING).""" - # SQL Server supports OUTPUT via ReturningMixin - assert dialect.supports_returning_clause() is True + # SQL Server supports OUTPUT via DMLMixin / SQLServerReturningMixin + assert dialect.supports_returning_insert() is True + assert dialect.supports_returning_update() is True + assert dialect.supports_returning_delete() is True def test_supports_cte(self, dialect: SQLServerDialect): """Test that SQL Server supports CTEs.""" From d51d576eb9c4294d717ca95cede1b84b2a3f4d2c Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 16 Sep 2026 01:15:22 +0800 Subject: [PATCH 45/58] test: classify DatabaseSupport in SQL Server protocol conformance --- .../feature/backend/test_sqlserver_protocol_conformance.py | 2 ++ 1 file changed, 2 insertions(+) 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 fb72620..2827f2b 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 @@ -104,6 +104,8 @@ def get_all_generic_protocols() -> dict: # decision (move to SQLSERVER_PROTOCOLS or revert). SQLSERVER_NOT_IMPLEMENTED = [ # --- Intentional non-support --- + # The generic DatabaseSupport protocol is not composed by SQLServerDialect. + dialect_protocols.DatabaseSupport, # SQL Server's native XML/XQuery (FOR XML, OPENXML, .query()/.value()) is not # the standard SQL/XML feature set, so no SQL/XML formatters are exposed. dialect_protocols.SQLXMLSupport, From 078ff12ddfefa257e17908e955361f78b2676ffe Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 16 Sep 2026 01:34:20 +0800 Subject: [PATCH 46/58] test: pass SQL Server version in DDL capability tests to satisfy version gating --- .../feature/backend/test_sqlserver_ddl_improvements.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_ddl_improvements.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_ddl_improvements.py index b9c1eb9..d08e8c4 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_ddl_improvements.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_ddl_improvements.py @@ -19,17 +19,17 @@ class TestSQLServerViewCapabilityGating: def test_create_or_replace_view_not_supported(self): """SQL Server does not support CREATE OR REPLACE VIEW.""" - dialect = SQLServerDialect() + dialect = SQLServerDialect(version=(16, 0, 0)) assert dialect.supports_create_or_replace_view() is False def test_drop_view_if_exists_supported(self): """SQL Server supports DROP VIEW IF EXISTS.""" - dialect = SQLServerDialect() + dialect = SQLServerDialect(version=(16, 0, 0)) assert dialect.supports_if_exists_view() is True def test_materialized_view_not_supported(self): """SQL Server does not support materialized views (use indexed views).""" - dialect = SQLServerDialect() + dialect = SQLServerDialect(version=(16, 0, 0)) assert dialect.supports_materialized_view() is False @@ -38,10 +38,10 @@ class TestSQLServerSchemaCapabilityGating: def test_create_schema_supported(self): """SQL Server supports CREATE SCHEMA.""" - dialect = SQLServerDialect() + dialect = SQLServerDialect(version=(16, 0, 0)) assert dialect.supports_create_schema() is True def test_drop_schema_supported(self): """SQL Server supports DROP SCHEMA.""" - dialect = SQLServerDialect() + dialect = SQLServerDialect(version=(16, 0, 0)) assert dialect.supports_drop_schema() is True From 24dd06eb43ec98860bba45895d83f8631516781c Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 16 Sep 2026 04:02:12 +0800 Subject: [PATCH 47/58] feat(dql): WITH TIES + typed lock strength + set-op FOR UPDATE gate - format_limit_offset_clause: FETCH NEXT ... ROWS WITH TIES - supports_fetch_with_ties True; supports_nulls_first_last False - locking: gate strength (no silent ignore) - set_operation: raise on unsupported FOR UPDATE --- .../backend/impl/sqlserver/mixins/dql.py | 18 ++++++++- .../backend/impl/sqlserver/mixins/locking.py | 8 ++++ .../impl/sqlserver/mixins/set_operation.py | 10 +++++ .../backend/dialect/test_dql_capabilities.py | 39 +++++++++++++++++++ 4 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_dql_capabilities.py diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dql.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dql.py index 48472f9..1d0cb1b 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dql.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dql.py @@ -12,6 +12,14 @@ class SQLServerDQLMixin: """SQL Server SELECT / LIMIT / OFFSET formatting.""" + def supports_fetch_with_ties(self) -> bool: + """SQL Server supports FETCH NEXT ... ROWS WITH TIES (2012+).""" + return True + + def supports_nulls_first_last(self) -> bool: + """SQL Server does not support explicit NULLS FIRST/LAST ordering.""" + return False + def format_limit_offset( self, limit: Optional[int] = None, @@ -61,7 +69,15 @@ def format_limit_offset_clause(self, clause: "LimitOffsetClause") -> Tuple[str, parts.append(f"OFFSET {offset_val} ROWS") if clause.limit is not None: - parts.append(f"FETCH NEXT {clause.limit} ROWS ONLY") + if getattr(clause, "with_ties", False): + if not self.supports_fetch_with_ties(): + raise UnsupportedFeatureError( + self.name, "FETCH NEXT ... ROWS WITH TIES", + "This SQL Server dialect does not support WITH TIES." + ) + parts.append(f"FETCH NEXT {clause.limit} ROWS WITH TIES") + else: + parts.append(f"FETCH NEXT {clause.limit} ROWS ONLY") return " ".join(parts), () diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/locking.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/locking.py index b7aa030..83df763 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/locking.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/locking.py @@ -24,6 +24,14 @@ def format_for_update_clause(self, clause: "ForUpdateClause") -> Tuple[str, tupl SQL Server does not support FOR UPDATE syntax. Instead, locking is achieved through table hints: WITH (UPDLOCK, ROWLOCK) etc. """ + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + from rhosocial.activerecord.backend.expression import LockStrength + + if clause.strength != LockStrength.UPDATE: + raise UnsupportedFeatureError( + self.name, f"{clause.strength.value} (unsupported lock strength)" + ) + all_params: list = [] sql_parts = ["WITH (UPDLOCK, ROWLOCK)"] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/set_operation.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/set_operation.py index e8df38a..fb5005a 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/set_operation.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/set_operation.py @@ -1,6 +1,8 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/mixins/set_operation.py from typing import Tuple, TYPE_CHECKING +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + if TYPE_CHECKING: from rhosocial.activerecord.backend.expression import bases @@ -44,6 +46,14 @@ def format_set_operation_expression(self, expr: "bases.BaseExpression") -> Tuple alias = expr.alias order_by_clause = expr.order_by_clause limit_offset_clause = expr.limit_offset_clause + for_update_clause = expr.for_update_clause + + if for_update_clause and not self.supports_set_operation_for_update(): + raise UnsupportedFeatureError( + self.name, + "FOR UPDATE in set operations", + "SQL Server does not support FOR UPDATE in a set operation.", + ) left_sql, left_params = left.to_sql() right_sql, right_params = right.to_sql() diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_dql_capabilities.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_dql_capabilities.py new file mode 100644 index 0000000..ba5bba8 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_dql_capabilities.py @@ -0,0 +1,39 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_dql_capabilities.py +"""SQL Server DQL capability tests: WITH TIES and lock strength.""" + +import pytest + +from rhosocial.activerecord.backend.expression import ( + ForUpdateClause, + LimitOffsetClause, + LockStrength, +) +from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect + + +@pytest.fixture +def dialect(): + return SQLServerDialect(version=(16, 0, 0)) + + +def test_fetch_with_ties_supported(dialect): + assert dialect.supports_fetch_with_ties() is True + + +def test_fetch_with_ties_renders(dialect): + sql, _ = dialect.format_limit_offset_clause(LimitOffsetClause(dialect, limit=5, with_ties=True)) + assert sql == "OFFSET 0 ROWS FETCH NEXT 5 ROWS WITH TIES" + + +def test_fetch_without_ties_renders_only(dialect): + sql, _ = dialect.format_limit_offset_clause(LimitOffsetClause(dialect, limit=5)) + assert sql == "OFFSET 0 ROWS FETCH NEXT 5 ROWS ONLY" + + +def test_nulls_first_last_unsupported(dialect): + assert dialect.supports_nulls_first_last() is False + + +def test_lock_strength_share_raises(dialect): + with pytest.raises(Exception): + ForUpdateClause(dialect, strength=LockStrength.SHARE).to_sql() From b9e09d52fa7b3e803183f321f228c2e87d0e2559 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 16 Sep 2026 09:47:47 +0800 Subject: [PATCH 48/58] refactor: remove core DQL/Expression mixin references (merged into DQLMixin/ExpressionMixin) --- .../backend/impl/sqlserver/dialect.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index 4e14f15..1e8d2ea 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -68,14 +68,13 @@ CTEMixin, WindowFunctionMixin, JSONMixin, - AdvancedGroupingMixin, + ArrayMixin, ExplainMixin, GraphMixin, - LockingMixin, + MergeMixin, - OrderedSetAggregationMixin, - QualifyClauseMixin, + TemporalTableMixin, UpsertMixin, LateralJoinMixin, @@ -96,7 +95,7 @@ DMLMixin, DDLColumnMixin, DDLTypeMixin, - FilterClauseMixin, + TransactionControlMixin, AutoIncrementMixin, GeneratedColumnMixin, @@ -252,14 +251,13 @@ class SQLServerDialect( CTEMixin, WindowFunctionMixin, JSONMixin, - AdvancedGroupingMixin, + ArrayMixin, ExplainMixin, GraphMixin, - LockingMixin, + MergeMixin, - OrderedSetAggregationMixin, - QualifyClauseMixin, + TemporalTableMixin, UpsertMixin, LateralJoinMixin, @@ -274,7 +272,7 @@ class SQLServerDialect( ConstraintMixin, IntrospectionMixin, # Newly added global mixins (previously missing from inheritance) - FilterClauseMixin, + TransactionControlMixin, AutoIncrementMixin, GeneratedColumnMixin, From aaee15a14a2df390b490c7536e552e4ba7e52ad8 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 16 Sep 2026 12:22:35 +0800 Subject: [PATCH 49/58] feat(ddl): render generated columns via core GeneratedColumnExpression --- src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index 1e8d2ea..95e620e 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -596,6 +596,11 @@ def format_column_definition(self, col_def: "ColumnDefinition") -> Tuple[str, tu if constraint_parts: parts.append(' '.join(constraint_parts)) + if col_def.generated_expression is not None: + gen_sql, gen_params = col_def.generated_expression.to_sql() + parts.append(gen_sql.lstrip()) + params.extend(gen_params) + return ' '.join(parts), tuple(params) def format_table_constraint(self, t_const: "TableConstraint") -> Tuple[str, tuple]: From 88964d02a441b0ec322b377f1792d3e0de4ad70b Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 16 Sep 2026 12:48:38 +0800 Subject: [PATCH 50/58] chore: add scripts/preflight.sh and optional git hook installer --- scripts/install-git-hooks.sh | 25 +++++++++++ scripts/preflight.sh | 86 ++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 scripts/install-git-hooks.sh create mode 100644 scripts/preflight.sh diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh new file mode 100644 index 0000000..e06f0cb --- /dev/null +++ b/scripts/install-git-hooks.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# scripts/install-git-hooks.sh +# +# Installs an optional pre-commit hook that runs scripts/preflight.sh so a +# broken import/lint is caught before the commit (and before a CI round-trip). +# Bypass a single commit with: git commit --no-verify +set -euo pipefail + +cd "$(dirname "$0")/.." + +HOOK=".git/hooks/pre-commit" +if [ ! -d .git/hooks ]; then + echo "install-git-hooks: .git/hooks not found (not a git repo?)" >&2 + exit 2 +fi + +cat > "$HOOK" <<'HOOK_BODY' +#!/usr/bin/env bash +# Installed by scripts/install-git-hooks.sh — runs the local pre-flight. +# Skip with: git commit --no-verify +exec "$(git rev-parse --show-toplevel)/scripts/preflight.sh" +HOOK_BODY +chmod +x "$HOOK" + +echo "install-git-hooks: installed $HOOK -> scripts/preflight.sh" diff --git a/scripts/preflight.sh b/scripts/preflight.sh new file mode 100644 index 0000000..fa6004e --- /dev/null +++ b/scripts/preflight.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# scripts/preflight.sh +# +# Fast local pre-flight so a trivial ImportError/lint break is caught in +# seconds-to-a-minute instead of after a full CI round-trip. +# +# Steps: +# 1. ruff check report-only by default (STRICT_LINT=1 to gate) +# 2. import smoke FATAL: catches broken src imports +# 3. pytest --collect-only FATAL: imports every test module -> ImportError +# +# `--collect-only` does NOT execute tests, but it does import conftest.py, so a +# missing local DB may print connection warnings. Collection still succeeds. +# +# Overridable env vars: +# VENV= venv dir (default: .venv3.14-ubuntu26.04) +# IMPORT_MODULE= package to import (repo-specific default below) +# PYTEST_TARGET= collection target (default: tests) +# RUFF_TARGET= lint target (default: src) +# STRICT_LINT=1 make ruff failures fatal (default: report-only) +# SKIP_LINT=1 / SKIP_IMPORT=1 / SKIP_COLLECT=1 +set -uo pipefail + +cd "$(dirname "$0")/.." + +VENV="${VENV:-.venv3.14-ubuntu26.04}" +PY="$VENV/bin/python" +RUFF="$VENV/bin/ruff" +IMPORT_MODULE="${IMPORT_MODULE:-rhosocial.activerecord.backend.impl.sqlserver}" +PYTEST_TARGET="${PYTEST_TARGET:-tests}" +RUFF_TARGET="${RUFF_TARGET:-src}" + +if [ ! -x "$PY" ]; then + echo "preflight: venv python not found at $VENV (set VENV=...)" >&2 + exit 2 +fi + +fail=0 + +if [ "${SKIP_LINT:-0}" != "1" ]; then + echo "==> [1/3] ruff check $RUFF_TARGET" + if [ -x "$RUFF" ]; then lint_cmd=("$RUFF"); else lint_cmd=("$PY" -m ruff); fi + if ! "${lint_cmd[@]}" check "$RUFF_TARGET"; then + if [ "${STRICT_LINT:-0}" = "1" ]; then + echo "preflight: lint failed (STRICT_LINT=1)" >&2 + fail=1 + else + echo "preflight: lint issues found (non-fatal; set STRICT_LINT=1 to gate)" + fi + fi +else + echo "==> [1/3] ruff check (skipped)" +fi + +if [ "${SKIP_IMPORT:-0}" != "1" ]; then + echo "==> [2/3] import smoke: $IMPORT_MODULE" + if ! "$PY" -c "import $IMPORT_MODULE"; then + echo "preflight: import of '$IMPORT_MODULE' failed" >&2 + fail=1 + fi +else + echo "==> [2/3] import smoke (skipped)" +fi + +if [ "${SKIP_COLLECT:-0}" != "1" ]; then + echo "==> [3/3] pytest --collect-only $PYTEST_TARGET (no tests executed)" + log="$(mktemp)" + if ! PYTHONPATH=tests "$PY" -m pytest "$PYTEST_TARGET" \ + --collect-only -q -p no:cacheprovider > "$log" 2>&1; then + echo "preflight: test collection failed (ImportError?):" >&2 + grep -E "ImportError|ModuleNotFoundError|NameError|ERROR collecting" "$log" | head -30 >&2 + fail=1 + fi + rm -f "$log" +else + echo "==> [3/3] pytest --collect-only (skipped)" +fi + +if [ "$fail" -ne 0 ]; then + echo "" + echo "preflight: FAILED — fix the above before committing/pushing." + exit 1 +fi + +echo "" +echo "preflight: OK" From 813e3f761ed0d0b19b1cfc51ea43a17213e2f1dd Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 16 Sep 2026 13:34:31 +0800 Subject: [PATCH 51/58] feat(graph): add SQL Server SQL Graph support (node/edge tables + MATCH predicate) SQL Server SQL Graph is independent from SQL/PGQ (different data model and query shape), so it lives in the SQL Server backend, not the core GraphMixin. - expression/graph.py: SQLServerGraphDirection, SQLServerGraphPseudoColumn, SQLServerGraphNodeRef, SQLServerGraphEdgeRef, SQLServerGraphPattern, SQLServerMatchPredicate, SQLServerShortestPathExpression, SQLServerGraphPathAggregate, SQLServerForPathTable - expression/ddl/graph.py: SQLServerGraphTableKind, SQLServerAsGraphTableExpression, SQLServerEdgeConstraint - protocols/graph.py: SQLServerGraphSupport (6 switches + 9 formatters) - mixins/graph.py: SQLServerGraphMixin with version gating (SQL Graph 2017+, SHORTEST_PATH / path aggregates 2019+) - dialect: compose mixin + protocol; integrate AS NODE/AS EDGE and CONNECTION edge constraints into format_create_table_statement - version_constants: add SQL_SERVER_2025 - keep core SQL/PGQ MatchClause rejection; clarify suggestion text - tests: 30 cases (switches, pattern/MATCH/SHORTEST_PATH/path aggregate, pseudo-column validation, DDL, version-gating negatives) --- .../backend/impl/sqlserver/dialect.py | 31 ++- .../impl/sqlserver/expression/__init__.py | 20 ++ .../impl/sqlserver/expression/ddl/__init__.py | 8 + .../impl/sqlserver/expression/ddl/graph.py | 69 ++++++ .../impl/sqlserver/expression/graph.py | 215 ++++++++++++++++ .../backend/impl/sqlserver/mixins/__init__.py | 2 + .../backend/impl/sqlserver/mixins/graph.py | 229 ++++++++++++++++++ .../sqlserver/mixins/version_constants.py | 1 + .../impl/sqlserver/protocols/__init__.py | 2 + .../backend/impl/sqlserver/protocols/graph.py | 87 +++++++ .../dialect/test_sqlserver_sql_graph.py | 214 ++++++++++++++++ 11 files changed, 877 insertions(+), 1 deletion(-) create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/graph.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/expression/graph.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins/graph.py create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/protocols/graph.py create mode 100644 tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_sqlserver_sql_graph.py diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index 95e620e..1bfc24a 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -55,6 +55,7 @@ SQLServerSequenceSupport, SQLServerFullTextSearchSupport, SQLServerPaginationSupport, + SQLServerGraphSupport, SQLServerMergeSupport, SQLServerPartitionSupport, SQLServerOutputSupport, @@ -110,6 +111,7 @@ from .alter_table_modifier import SQLServerAlterColumnModifierMixin from .mixins.sequence import SQLServerSequenceMixin from .mixins.pivot import SQLServerPivotMixin +from .mixins.graph import SQLServerGraphMixin from .mixins.columnstore import SQLServerColumnstoreIndexMixin from .mixins.memory_optimized import SQLServerMemoryOptimizedMixin from .mixins.routine import SQLServerRoutineMixin @@ -195,7 +197,10 @@ _SUGGESTION_ARRAY_TYPES = "SQL Server does not support native array types. Consider using JSON or comma-separated values." _SUGGESTION_JSON_TABLE = "SQL Server uses OPENJSON for JSON table functionality (2016+)." -_SUGGESTION_GRAPH_MATCH = "SQL Server does not support graph MATCH clause." +_SUGGESTION_GRAPH_MATCH = ( + "SQL Server does not support the SQL/PGQ MATCH clause. Use its own SQL Graph API " + "(CREATE TABLE ... AS NODE/AS EDGE + SQLServerMatchPredicate) instead." +) _SUGGESTION_ORDERED_SET_AGG = "SQL Server does not support ordered-set aggregate functions (WITHIN GROUP)." _SUGGESTION_QUALIFY = "SQL Server does not support QUALIFY clause. Use a subquery or CTE instead." @@ -212,6 +217,7 @@ class SQLServerDialect( SQLServerProtocolSupportMixin, # SQL Server protocol contract implementations SQLServerSequenceMixin, # NEXT VALUE FOR formatter (2012+) SQLServerPivotMixin, # PIVOT / UNPIVOT formatters (2005+) + SQLServerGraphMixin, # SQL Graph: node/edge tables + MATCH predicate (2017+/2019+) SQLServerColumnstoreIndexMixin, # columnstore index DDL (2012+/2014+/2022+) SQLServerMemoryOptimizedMixin, # In-Memory OLTP table options (2014+) SQLServerRoutineMixin, # PROCEDURE / FUNCTION DDL (2005+) @@ -320,6 +326,7 @@ class SQLServerDialect( # implementations live in SQLServerProtocolSupportMixin / SQLServerSequenceMixin) SQLServerOutputSupport, SQLServerPaginationSupport, + SQLServerGraphSupport, SQLServerFullTextSearchSupport, SQLServerTryCastSupport, SQLServerTableHintSupport, @@ -539,6 +546,14 @@ def format_create_table_statement( column_parts.append(idx_sql) all_params.extend(idx_params) + # SQL Graph edge constraints (CONNECTION) are table-level constraints. + edge_constraints = dialect_options.get("edge_constraints") + if edge_constraints: + for edge_constraint in edge_constraints: + ec_sql, ec_params = edge_constraint.to_sql() + column_parts.append(ec_sql) + all_params.extend(ec_params) + parts.append(f"({', '.join(column_parts)})") if expr.partition is not None: @@ -556,6 +571,20 @@ def format_create_table_statement( durability = dialect_options.get("durability", "SCHEMA_ONLY") parts.append(self.format_memory_optimized_option(durability)) + # SQL Graph table kind (AS NODE / AS EDGE), if requested. + graph_kind = dialect_options.get("graph_table_kind") + if graph_kind is not None: + from .expression.ddl.graph import ( + SQLServerAsGraphTableExpression, + SQLServerGraphTableKind, + ) + + if isinstance(graph_kind, SQLServerGraphTableKind): + graph_kind = SQLServerAsGraphTableExpression(self, graph_kind) + kind_sql, kind_params = graph_kind.to_sql() + parts.append(kind_sql) + all_params.extend(kind_params) + return ' '.join(parts), tuple(all_params) def format_column_definition(self, col_def: "ColumnDefinition") -> Tuple[str, tuple]: diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/__init__.py index a57c4d3..1c310bc 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/__init__.py @@ -16,6 +16,17 @@ merge_join_hint, ) from .pivot import SQLServerPivotExpression, SQLServerUnpivotExpression +from .graph import ( + SQLServerGraphDirection, + SQLServerGraphPseudoColumn, + SQLServerGraphNodeRef, + SQLServerGraphEdgeRef, + SQLServerGraphPattern, + SQLServerMatchPredicate, + SQLServerShortestPathExpression, + SQLServerGraphPathAggregate, + SQLServerForPathTable, +) from .columnstore import SQLServerColumnstoreIndexExpression from .fulltext import ( SQLServerCreateFullTextCatalogExpression, @@ -56,6 +67,15 @@ "loop_join_hint", "merge_join_hint", "SQLServerPivotExpression", + "SQLServerGraphDirection", + "SQLServerGraphPseudoColumn", + "SQLServerGraphNodeRef", + "SQLServerGraphEdgeRef", + "SQLServerGraphPattern", + "SQLServerMatchPredicate", + "SQLServerShortestPathExpression", + "SQLServerGraphPathAggregate", + "SQLServerForPathTable", "SQLServerUnpivotExpression", "SQLServerColumnstoreIndexExpression", "SQLServerCreateFullTextCatalogExpression", diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/__init__.py index 39abcdf..a7c64b7 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/__init__.py @@ -10,6 +10,11 @@ SQLServerCreateTriggerExpression, SQLServerDropTriggerExpression, ) +from .graph import ( + SQLServerGraphTableKind, + SQLServerAsGraphTableExpression, + SQLServerEdgeConstraint, +) __all__ = [ "SQLServerCreateProcedureExpression", @@ -17,4 +22,7 @@ "SQLServerDropRoutineExpression", "SQLServerCreateTriggerExpression", "SQLServerDropTriggerExpression", + "SQLServerGraphTableKind", + "SQLServerAsGraphTableExpression", + "SQLServerEdgeConstraint", ] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/graph.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/graph.py new file mode 100644 index 0000000..ed76eb5 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/graph.py @@ -0,0 +1,69 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/graph.py +"""SQL Server SQL Graph DDL expressions (SQL Server 2017+). + +Graph tables are ordinary tables annotated with ``AS NODE`` / ``AS EDGE`` +(edge tables additionally support ``CONNECTION`` edge constraints): + + CREATE TABLE dbo.Person (ID INT PRIMARY KEY, name VARCHAR(50)) AS NODE; + CREATE TABLE dbo.friend (start_date DATE) AS EDGE; + ALTER TABLE dbo.friend ADD CONSTRAINT ec_friend CONNECTION (Person TO Person); + +SQL generation is delegated to the dialect's +``format_sqlserver_graph_table_kind`` / ``format_sqlserver_edge_constraint`` +formatters. +""" + +from enum import Enum +from typing import Optional, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: # pragma: no cover + from ...dialect import SQLServerDialect + + +class SQLServerGraphTableKind(Enum): + """Graph table kind for ``CREATE TABLE ... AS NODE | AS EDGE``.""" + + NODE = "NODE" + EDGE = "EDGE" + + +class SQLServerAsGraphTableExpression(BaseExpression): + """The ``AS NODE`` / ``AS EDGE`` table-kind fragment on ``CREATE TABLE``.""" + + def __init__(self, dialect: "SQLServerDialect", kind: SQLServerGraphTableKind): + super().__init__(dialect) + self.kind = kind + + @property + def format_method(self) -> str: + return "format_sqlserver_graph_table_kind" + + +class SQLServerEdgeConstraint(BaseExpression): + """A ``CONNECTION ( TO )`` edge constraint on an edge table. + + Attributes: + from_table: Node table the edge connects from. + to_table: Node table the edge connects to. + name: Optional constraint name. + """ + + def __init__(self, dialect: "SQLServerDialect", from_table: str, to_table: str, + name: Optional[str] = None): + super().__init__(dialect) + self.from_table = from_table + self.to_table = to_table + self.name = name + + @property + def format_method(self) -> str: + return "format_sqlserver_edge_constraint" + + +__all__ = [ + "SQLServerGraphTableKind", + "SQLServerAsGraphTableExpression", + "SQLServerEdgeConstraint", +] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/graph.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/graph.py new file mode 100644 index 0000000..8236337 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/graph.py @@ -0,0 +1,215 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/expression/graph.py +"""SQL Server SQL Graph query expressions (SQL Server 2017+ / 2019+). + +These are **independent** from the core SQL/PGQ family: SQL Server models graph +data as node/edge tables and queries with a ``MATCH`` predicate using +ASCII-art patterns, ``SHORTEST_PATH`` and path aggregates. SQL generation is +delegated to the SQL Server dialect's ``format_sqlserver_*`` formatters. +""" + +from enum import Enum +from typing import List, Optional, Sequence, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLPredicate +from rhosocial.activerecord.backend.expression.core import Column + +if TYPE_CHECKING: # pragma: no cover + from ..dialect import SQLServerDialect + + +class SQLServerGraphDirection(Enum): + """Direction of an edge in a MATCH pattern.""" + + RIGHT = "->" # -(edge)-> + LEFT = "<-" # <-(edge)- + + +class SQLServerGraphPseudoColumn(Column): + """A SQL Graph pseudo-column: ``$node_id`` / ``$edge_id`` / ``$from_id`` / ``$to_id``. + + Pseudo-columns must never be quoted; the name is validated at construction. + """ + + _ALLOWED = frozenset({"$node_id", "$edge_id", "$from_id", "$to_id"}) + + def __init__(self, dialect: "SQLServerDialect", name: str, table: Optional[str] = None, + alias: Optional[str] = None): + if name not in self._ALLOWED: + raise ValueError( + f"Invalid graph pseudo-column {name!r}; allowed: {sorted(self._ALLOWED)}" + ) + super().__init__(dialect, name, table=table, alias=alias, name_need_quote=False) + + +class SQLServerGraphNodeRef(BaseExpression): + """A node reference inside a MATCH pattern. + + Attributes: + table: Node table name or alias. + alias: Optional alias used in the ASCII-art pattern. + last_node: Render as ``LAST_NODE()``. + """ + + def __init__(self, dialect: "SQLServerDialect", table: str, alias: Optional[str] = None, + last_node: bool = False): + super().__init__(dialect) + self.table = table + self.alias = alias + self.last_node = bool(last_node) + + @property + def format_method(self) -> str: + return "format_sqlserver_graph_node_ref" + + +class SQLServerGraphEdgeRef(BaseExpression): + """An edge reference inside a MATCH pattern (``-(edge)->`` / ``<-(edge)-``).""" + + def __init__(self, dialect: "SQLServerDialect", edge: str, + direction: SQLServerGraphDirection = SQLServerGraphDirection.RIGHT): + super().__init__(dialect) + self.edge = edge + self.direction = direction + + @property + def format_method(self) -> str: + return "format_sqlserver_graph_edge_ref" + + +class SQLServerGraphPattern(BaseExpression): + """A single path pattern: `` ( )*``. + + Example: + ``Person1-(friend)->Person2`` + """ + + def __init__( + self, + dialect: "SQLServerDialect", + start: SQLServerGraphNodeRef, + segments: Optional[Sequence[Tuple[SQLServerGraphEdgeRef, SQLServerGraphNodeRef]]] = None, + ): + super().__init__(dialect) + self.start = start + self.segments = list(segments) if segments else [] + + @property + def format_method(self) -> str: + return "format_sqlserver_graph_pattern" + + +class SQLServerMatchPredicate(SQLPredicate): + """A ``MATCH ([, ...])`` predicate. + + Multiple patterns are combined with ``AND`` or ``,`` (the two forms SQL + Server accepts). ``OR`` / ``NOT`` are rejected at construction. + """ + + _COMBINATORS = ("AND", ",") + + def __init__(self, dialect: "SQLServerDialect", patterns, combinator: str = "AND"): + super().__init__(dialect) + if isinstance(patterns, BaseExpression): + patterns = [patterns] + if not patterns: + raise ValueError("MATCH requires at least one pattern.") + comb = combinator.upper() if isinstance(combinator, str) else combinator + if comb not in self._COMBINATORS: + raise ValueError( + f"Invalid MATCH combinator {combinator!r}; SQL Server accepts 'AND' or ','." + ) + self.patterns = list(patterns) + self.combinator = comb + + @property + def format_method(self) -> str: + return "format_sqlserver_match_predicate" + + +class SQLServerShortestPathExpression(BaseExpression): + """A ``SHORTEST_PATH (...){quantifier}`` traversal (SQL Server 2019+). + + SQL Server restricts the quantifier to ``+`` or ``{1,n}``; ``minimum`` must + therefore be ``1``. + """ + + def __init__( + self, + dialect: "SQLServerDialect", + pattern, + minimum: int = 1, + maximum: Optional[int] = None, + extra_patterns: Optional[List] = None, + combinator: str = "AND", + ): + super().__init__(dialect) + if minimum != 1: + raise ValueError( + "SQL Server SHORTEST_PATH quantifier must be '+' or '{1,n}'; " + "minimum must be 1." + ) + if maximum is not None and maximum < 1: + raise ValueError("maximum must be >= 1.") + self.patterns = [pattern] + list(extra_patterns or []) + comb = combinator.upper() if isinstance(combinator, str) else combinator + if comb not in ("AND", ","): + raise ValueError( + f"Invalid SHORTEST_PATH combinator {combinator!r}; use 'AND' or ','." + ) + self.combinator = comb + self.minimum = minimum + self.maximum = maximum + + @property + def format_method(self) -> str: + return "format_sqlserver_shortest_path" + + +class SQLServerGraphPathAggregate(BaseExpression): + """A path aggregate: ``([, ]) WITHIN GROUP (GRAPH PATH)``. + + Example: + ``STRING_AGG(Person2.name, '->') WITHIN GROUP (GRAPH PATH) AS Friends`` + """ + + def __init__(self, dialect: "SQLServerDialect", function: str, expression: BaseExpression, + separator: Optional[str] = None, alias: Optional[str] = None): + super().__init__(dialect) + self.function = function + self.expression = expression + self.separator = separator + self.alias = alias + + @property + def format_method(self) -> str: + return "format_sqlserver_graph_path_aggregate" + + +class SQLServerForPathTable(BaseExpression): + """A `` FOR PATH [AS ]`` FROM item. + + Used with ``SHORTEST_PATH`` so intermediate nodes/edges can be projected by + path aggregates. + """ + + def __init__(self, dialect: "SQLServerDialect", table: str, alias: Optional[str] = None): + super().__init__(dialect) + self.table = table + self.alias = alias + + @property + def format_method(self) -> str: + return "format_sqlserver_for_path_table" + + +__all__ = [ + "SQLServerGraphDirection", + "SQLServerGraphPseudoColumn", + "SQLServerGraphNodeRef", + "SQLServerGraphEdgeRef", + "SQLServerGraphPattern", + "SQLServerMatchPredicate", + "SQLServerShortestPathExpression", + "SQLServerGraphPathAggregate", + "SQLServerForPathTable", +] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py index 1d2aaaa..2846ba4 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py @@ -5,6 +5,7 @@ from .partition import SQLServerPartitionMixin from .sequence import SQLServerSequenceMixin from .pivot import SQLServerPivotMixin +from .graph import SQLServerGraphMixin from .columnstore import SQLServerColumnstoreIndexMixin from .memory_optimized import SQLServerMemoryOptimizedMixin from .routine import SQLServerRoutineMixin @@ -43,6 +44,7 @@ "SQLServerPartitionMixin", "SQLServerSequenceMixin", "SQLServerPivotMixin", + "SQLServerGraphMixin", "SQLServerColumnstoreIndexMixin", "SQLServerMemoryOptimizedMixin", "SQLServerRoutineMixin", diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/graph.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/graph.py new file mode 100644 index 0000000..65e72be --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/graph.py @@ -0,0 +1,229 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/graph.py +"""SQL Server SQL Graph mixin (SQL Server 2017+ / 2019+). + +Implements the ``SQLServerGraphSupport`` contract: node/edge graph tables, +the ``MATCH`` predicate (ASCII-art patterns), ``SHORTEST_PATH`` (2019+), +path aggregates, graph pseudo-columns and ``FOR PATH``. + +Kept **independent** of the core SQL/PGQ family; ``supports_graph_match`` +stays ``False`` and the core ``MatchClause`` remains rejected. +""" + +from typing import Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +from .version_constants import SQL_SERVER_2017, SQL_SERVER_2019 + +if TYPE_CHECKING: # pragma: no cover + from ..expression.graph import ( + SQLServerGraphEdgeRef, + SQLServerGraphNodeRef, + SQLServerGraphPathAggregate, + SQLServerGraphPattern, + SQLServerMatchPredicate, + SQLServerShortestPathExpression, + SQLServerForPathTable, + ) + from ..expression.ddl.graph import ( + SQLServerAsGraphTableExpression, + SQLServerEdgeConstraint, + ) + + +class SQLServerGraphMixin: + """SQL Server SQL Graph capability and formatting implementation.""" + + # region capability switches + + def supports_sql_graph(self) -> bool: + """SQL Graph (node/edge tables + MATCH predicate) requires 2017+.""" + return self.version >= SQL_SERVER_2017 + + def supports_graph_table_kind(self) -> bool: + """``CREATE TABLE ... AS NODE | AS EDGE`` requires SQL Server 2017+.""" + return self.version >= SQL_SERVER_2017 + + def supports_graph_pseudo_columns(self) -> bool: + """Graph pseudo-columns (``$node_id`` etc.) require SQL Server 2017+.""" + return self.version >= SQL_SERVER_2017 + + def supports_shortest_path(self) -> bool: + """``SHORTEST_PATH`` requires SQL Server 2019+.""" + return self.version >= SQL_SERVER_2019 + + def supports_graph_path_aggregates(self) -> bool: + """Path aggregates require SQL Server 2019+.""" + return self.version >= SQL_SERVER_2019 + + def supports_edge_constraints(self) -> bool: + """``CONNECTION`` edge constraints require SQL Server 2017+.""" + return self.version >= SQL_SERVER_2017 + + # endregion + + # region DDL formatting + + def format_sqlserver_graph_table_kind( + self, expr: "SQLServerAsGraphTableExpression" + ) -> Tuple[str, tuple]: + """Format the ``AS NODE`` / ``AS EDGE`` table-kind fragment.""" + if not self.supports_graph_table_kind(): + raise UnsupportedFeatureError( + self.name, + "CREATE TABLE ... AS NODE/EDGE", + "SQL Graph tables require SQL Server 2017 or later.", + ) + return f"AS {expr.kind.value}", () + + def format_sqlserver_edge_constraint( + self, constraint: "SQLServerEdgeConstraint" + ) -> Tuple[str, tuple]: + """Format a ``[CONSTRAINT name] CONNECTION ( TO )`` edge constraint.""" + if not self.supports_edge_constraints(): + raise UnsupportedFeatureError( + self.name, + "CONNECTION edge constraint", + "Edge constraints require SQL Server 2017 or later.", + ) + parts = [] + if constraint.name: + parts.append(f"CONSTRAINT {self.format_identifier(constraint.name)}") + from_table = self.format_identifier(constraint.from_table) + to_table = self.format_identifier(constraint.to_table) + parts.append(f"CONNECTION ({from_table} TO {to_table})") + return " ".join(parts), () + + # endregion + + # region query formatting + + def format_sqlserver_graph_node_ref( + self, ref: "SQLServerGraphNodeRef" + ) -> Tuple[str, tuple]: + """Format a node reference (optionally ``LAST_NODE(...)``).""" + if not self.supports_sql_graph(): + raise UnsupportedFeatureError( + self.name, "SQL Graph MATCH pattern", + "SQL Graph requires SQL Server 2017 or later.", + ) + name = self.format_identifier(ref.alias or ref.table) + if ref.last_node: + if not self.supports_shortest_path(): + raise UnsupportedFeatureError( + self.name, "LAST_NODE", + "LAST_NODE requires SQL Server 2019 or later.", + ) + return f"LAST_NODE({name})", () + return name, () + + def format_sqlserver_graph_edge_ref( + self, ref: "SQLServerGraphEdgeRef" + ) -> Tuple[str, tuple]: + """Format an edge reference (``-(edge)->`` / ``<-(edge)-``).""" + if not self.supports_sql_graph(): + raise UnsupportedFeatureError( + self.name, "SQL Graph MATCH pattern", + "SQL Graph requires SQL Server 2017 or later.", + ) + from ..expression.graph import SQLServerGraphDirection + + edge = self.format_identifier(ref.edge) + if ref.direction == SQLServerGraphDirection.LEFT: + return f"<-({edge})-", () + return f"-({edge})->", () + + def format_sqlserver_graph_pattern( + self, pattern: "SQLServerGraphPattern" + ) -> Tuple[str, tuple]: + """Format a single path pattern (ASCII-art node/edge chain).""" + if not self.supports_sql_graph(): + raise UnsupportedFeatureError( + self.name, "SQL Graph MATCH pattern", + "SQL Graph requires SQL Server 2017 or later.", + ) + parts, params = [], [] + start_sql, start_params = pattern.start.to_sql() + parts.append(start_sql) + params.extend(start_params) + for edge_ref, node_ref in pattern.segments: + edge_sql, edge_params = edge_ref.to_sql() + node_sql, node_params = node_ref.to_sql() + parts.append(edge_sql) + parts.append(node_sql) + params.extend(edge_params) + params.extend(node_params) + return "".join(parts), tuple(params) + + def format_sqlserver_match_predicate( + self, predicate: "SQLServerMatchPredicate" + ) -> Tuple[str, tuple]: + """Format a ``MATCH (...)`` predicate.""" + if not self.supports_sql_graph(): + raise UnsupportedFeatureError( + self.name, "SQL Graph MATCH predicate", + "SQL Graph requires SQL Server 2017 or later.", + ) + separator = " AND " if predicate.combinator == "AND" else ", " + parts, params = [], [] + for pattern in predicate.patterns: + sql, pattern_params = pattern.to_sql() + parts.append(sql) + params.extend(pattern_params) + return f"MATCH({separator.join(parts)})", tuple(params) + + def format_sqlserver_shortest_path( + self, expr: "SQLServerShortestPathExpression" + ) -> Tuple[str, tuple]: + """Format a ``SHORTEST_PATH (...){quantifier}`` expression.""" + if not self.supports_shortest_path(): + raise UnsupportedFeatureError( + self.name, "SHORTEST_PATH", + "SHORTEST_PATH requires SQL Server 2019 or later.", + ) + separator = " AND " if expr.combinator == "AND" else ", " + parts, params = [], [] + for pattern in expr.patterns: + sql, pattern_params = pattern.to_sql() + parts.append(sql) + params.extend(pattern_params) + quantifier = "+" if expr.maximum is None else "{{1,{0}}}".format(expr.maximum) + inner = separator.join(parts) + return f"SHORTEST_PATH({inner}){quantifier}", tuple(params) + + def format_sqlserver_graph_path_aggregate( + self, aggregate: "SQLServerGraphPathAggregate" + ) -> Tuple[str, tuple]: + """Format a ``([, ]) WITHIN GROUP (GRAPH PATH)`` aggregate.""" + if not self.supports_graph_path_aggregates(): + raise UnsupportedFeatureError( + self.name, "path aggregate", + "Path aggregates require SQL Server 2019 or later.", + ) + expr_sql, params = aggregate.expression.to_sql() + args = [expr_sql] + if aggregate.separator is not None: + args.append(self.format_literal(aggregate.separator)) + sql = f"{aggregate.function.upper()}({', '.join(args)}) WITHIN GROUP (GRAPH PATH)" + if aggregate.alias: + sql += f" AS {self.format_identifier(aggregate.alias)}" + return sql, tuple(params) + + def format_sqlserver_for_path_table( + self, table: "SQLServerForPathTable" + ) -> Tuple[str, tuple]: + """Format a ``
FOR PATH [AS ]`` FROM item.""" + if not self.supports_shortest_path(): + raise UnsupportedFeatureError( + self.name, "FOR PATH", + "FOR PATH requires SQL Server 2019 or later.", + ) + sql = f"{self.format_identifier(table.table)} FOR PATH" + if table.alias: + sql += f" AS {self.format_identifier(table.alias)}" + return sql, () + + # endregion + + +__all__ = ["SQLServerGraphMixin"] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/version_constants.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/version_constants.py index ceeabf3..34ad838 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/version_constants.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/version_constants.py @@ -9,3 +9,4 @@ SQL_SERVER_2017 = (14, 0, 0) SQL_SERVER_2019 = (15, 0, 0) SQL_SERVER_2022 = (16, 0, 0) +SQL_SERVER_2025 = (17, 0, 0) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/__init__.py index 00e542c..574e578 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/__init__.py @@ -13,6 +13,7 @@ from .fulltext import SQLServerFullTextSearchSupport from .try_cast import SQLServerTryCastSupport from .pagination import SQLServerPaginationSupport +from .graph import SQLServerGraphSupport from .merge import SQLServerMergeSupport from .partition import SQLServerPartitionSupport @@ -31,6 +32,7 @@ "SQLServerFullTextSearchSupport", "SQLServerTryCastSupport", "SQLServerPaginationSupport", + "SQLServerGraphSupport", "SQLServerMergeSupport", "SQLServerPartitionSupport", ] \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/graph.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/graph.py new file mode 100644 index 0000000..96013a2 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/graph.py @@ -0,0 +1,87 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/protocols/graph.py +"""SQL Server SQL Graph support protocol (SQL Server 2017+ / 2019+). + +SQL Server's SQL Graph is **not** SQL/PGQ: it models graph data as node/edge +**tables** (``CREATE TABLE ... AS NODE | AS EDGE``), queries with a ``MATCH`` +**predicate** using ASCII-art patterns, and (2019+) traverses with +``SHORTEST_PATH`` and path aggregates. This protocol is therefore kept separate +from the generic ``GraphSupport`` / ``GraphTableSupport``. +""" + +from typing import Any, Protocol, Tuple, runtime_checkable + + +@runtime_checkable +class SQLServerGraphSupport(Protocol): + """Contract for SQL Server SQL Graph capability and formatting.""" + + # region capability switches + + def supports_sql_graph(self) -> bool: + """Whether SQL Graph (node/edge tables + MATCH predicate) is supported.""" + ... # pragma: no cover + + def supports_graph_table_kind(self) -> bool: + """Whether ``CREATE TABLE ... AS NODE | AS EDGE`` is supported.""" + ... # pragma: no cover + + def supports_graph_pseudo_columns(self) -> bool: + """Whether graph pseudo-columns (``$node_id`` etc.) are usable.""" + ... # pragma: no cover + + def supports_shortest_path(self) -> bool: + """Whether ``SHORTEST_PATH`` is supported (SQL Server 2019+).""" + ... # pragma: no cover + + def supports_graph_path_aggregates(self) -> bool: + """Whether path aggregates (``... WITHIN GROUP (GRAPH PATH)``) are supported.""" + ... # pragma: no cover + + def supports_edge_constraints(self) -> bool: + """Whether ``CONNECTION`` edge constraints are supported.""" + ... # pragma: no cover + + # endregion + + # region formatting + + def format_sqlserver_graph_table_kind(self, expr: Any) -> Tuple[str, tuple]: + """Format the ``AS NODE`` / ``AS EDGE`` table kind fragment.""" + ... # pragma: no cover + + def format_sqlserver_edge_constraint(self, constraint: Any) -> Tuple[str, tuple]: + """Format a ``CONNECTION (from TO to)`` edge constraint.""" + ... # pragma: no cover + + def format_sqlserver_graph_node_ref(self, ref: Any) -> Tuple[str, tuple]: + """Format a node reference in a pattern (optionally ``LAST_NODE(...)``).""" + ... # pragma: no cover + + def format_sqlserver_graph_edge_ref(self, ref: Any) -> Tuple[str, tuple]: + """Format an edge reference in a pattern (``-(edge)->`` / ``<-(edge)-``).""" + ... # pragma: no cover + + def format_sqlserver_graph_pattern(self, pattern: Any) -> Tuple[str, tuple]: + """Format a graph search pattern (ASCII-art node/edge chain).""" + ... # pragma: no cover + + def format_sqlserver_match_predicate(self, predicate: Any) -> Tuple[str, tuple]: + """Format a ``MATCH ()`` predicate.""" + ... # pragma: no cover + + def format_sqlserver_shortest_path(self, expr: Any) -> Tuple[str, tuple]: + """Format a ``SHORTEST_PATH ({1,n})`` expression.""" + ... # pragma: no cover + + def format_sqlserver_graph_path_aggregate(self, aggregate: Any) -> Tuple[str, tuple]: + """Format a path aggregate (``(...) WITHIN GROUP (GRAPH PATH)``).""" + ... # pragma: no cover + + def format_sqlserver_for_path_table(self, table: Any) -> Tuple[str, tuple]: + """Format a ``
FOR PATH [AS alias]`` FROM item.""" + ... # pragma: no cover + + # endregion + + +__all__ = ["SQLServerGraphSupport"] diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_sqlserver_sql_graph.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_sqlserver_sql_graph.py new file mode 100644 index 0000000..d0cea08 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_sqlserver_sql_graph.py @@ -0,0 +1,214 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_sqlserver_sql_graph.py +"""SQL Server SQL Graph tests (node/edge tables, MATCH predicate, SHORTEST_PATH). + +SQL Server SQL Graph is independent from SQL/PGQ; see the plan +``.claude/plan/2026-09-16/pgq-expression-layer.md`` §4.3. +""" + +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + CreateTableExpression, +) +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 import ( + SQLServerForPathTable, + SQLServerGraphDirection, + SQLServerGraphEdgeRef, + SQLServerGraphNodeRef, + SQLServerGraphPathAggregate, + SQLServerGraphPattern, + SQLServerGraphPseudoColumn, + SQLServerMatchPredicate, + SQLServerShortestPathExpression, +) +from rhosocial.activerecord.backend.impl.sqlserver.expression.ddl import ( + SQLServerAsGraphTableExpression, + SQLServerEdgeConstraint, + SQLServerGraphTableKind, +) + + +@pytest.fixture +def dialect(): + return SQLServerDialect(version=(17, 0, 0)) + + +@pytest.fixture +def dialect_2017(): + return SQLServerDialect(version=(14, 0, 0)) + + +def _path(dialect, edge="friend", direction=SQLServerGraphDirection.RIGHT): + return SQLServerGraphPattern( + dialect, + SQLServerGraphNodeRef(dialect, "Person", alias="Person1"), + [(SQLServerGraphEdgeRef(dialect, edge, direction), SQLServerGraphNodeRef(dialect, "Person", alias="Person2"))], + ) + + +class TestCapabilitySwitches: + def test_sql_graph_since_2017(self, dialect_2017): + assert dialect_2017.supports_sql_graph() is True + assert dialect_2017.supports_graph_table_kind() is True + assert dialect_2017.supports_graph_pseudo_columns() is True + assert dialect_2017.supports_edge_constraints() is True + + def test_shortest_path_since_2019(self, dialect_2017, dialect): + assert dialect_2017.supports_shortest_path() is False + assert dialect_2017.supports_graph_path_aggregates() is False + assert dialect.supports_shortest_path() is True + assert dialect.supports_graph_path_aggregates() is True + + def test_protocol_isinstance(self, dialect): + from rhosocial.activerecord.backend.impl.sqlserver.protocols import SQLServerGraphSupport + + assert isinstance(dialect, SQLServerGraphSupport) + + +class TestPatternRendering: + def test_right_direction(self, dialect): + sql, _ = _path(dialect).to_sql() + assert sql == "[Person1]-([friend])->[Person2]" + + def test_left_direction(self, dialect): + sql, _ = _path(dialect, direction=SQLServerGraphDirection.LEFT).to_sql() + assert sql == "[Person1]<-([friend])-[Person2]" + + def test_match_predicate_wraps_pattern(self, dialect): + sql, _ = SQLServerMatchPredicate(dialect, _path(dialect)).to_sql() + assert sql == "MATCH([Person1]-([friend])->[Person2])" + + def test_match_combines_with_and(self, dialect): + sql, _ = SQLServerMatchPredicate(dialect, [_path(dialect), _path(dialect)]).to_sql() + assert " AND " in sql + assert sql.startswith("MATCH(") and sql.endswith(")") + + def test_match_combines_with_comma(self, dialect): + sql, _ = SQLServerMatchPredicate(dialect, [_path(dialect), _path(dialect)], combinator=",").to_sql() + assert ", " in sql + + def test_match_rejects_other_combinator(self, dialect): + with pytest.raises(ValueError): + SQLServerMatchPredicate(dialect, [_path(dialect)], combinator="OR") + + def test_match_requires_pattern(self, dialect): + with pytest.raises(ValueError): + SQLServerMatchPredicate(dialect, []) + + +class TestShortestPath: + def test_plus_quantifier(self, dialect): + expr = SQLServerShortestPathExpression(dialect, _path(dialect, edge="fo")) + sql, _ = expr.to_sql() + assert sql.endswith(")" + "+") + assert sql.startswith("SHORTEST_PATH(") + + def test_bounded_quantifier(self, dialect): + expr = SQLServerShortestPathExpression(dialect, _path(dialect, edge="fo"), maximum=3) + sql, _ = expr.to_sql() + assert sql.endswith("{1,3}") + + def test_minimum_must_be_one(self, dialect): + with pytest.raises(ValueError): + SQLServerShortestPathExpression(dialect, _path(dialect), minimum=2) + + def test_raises_before_2019(self, dialect_2017): + expr = SQLServerShortestPathExpression(dialect_2017, _path(dialect_2017)) + with pytest.raises(UnsupportedFeatureError, match="SHORTEST_PATH"): + expr.to_sql() + + def test_for_path_raises_before_2019(self, dialect_2017): + with pytest.raises(UnsupportedFeatureError, match="FOR PATH"): + SQLServerForPathTable(dialect_2017, "friendOf", alias="fo").to_sql() + + def test_for_path_renders(self, dialect): + sql, _ = SQLServerForPathTable(dialect, "friendOf", alias="fo").to_sql() + assert sql == "[friendOf] FOR PATH AS [fo]" + + +class TestPathAggregate: + def test_renders(self, dialect): + agg = SQLServerGraphPathAggregate( + dialect, "STRING_AGG", SQLServerGraphNodeRef(dialect, "Person", alias="Person2"), + separator="->", alias="Friends", + ) + sql, params = agg.to_sql() + assert sql == "STRING_AGG([Person2], '->') WITHIN GROUP (GRAPH PATH) AS [Friends]" + assert params == () + + def test_raises_before_2019(self, dialect_2017): + agg = SQLServerGraphPathAggregate( + dialect_2017, "STRING_AGG", SQLServerGraphNodeRef(dialect_2017, "Person", alias="P"), + separator="->", + ) + with pytest.raises(UnsupportedFeatureError, match="path aggregate"): + agg.to_sql() + + +class TestPseudoColumn: + def test_renders_unquoted(self, dialect): + sql, _ = SQLServerGraphPseudoColumn(dialect, "$node_id").to_sql() + assert sql == "$node_id" + + @pytest.mark.parametrize("name", ["$edge_id", "$from_id", "$to_id"]) + def test_allowed_names(self, dialect, name): + sql, _ = SQLServerGraphPseudoColumn(dialect, name).to_sql() + assert sql == name + + def test_rejects_unknown_name(self, dialect): + with pytest.raises(ValueError, match="pseudo-column"): + SQLServerGraphPseudoColumn(dialect, "id") + + +class TestDdl: + def test_as_node(self, dialect): + sql, _ = SQLServerAsGraphTableExpression(dialect, SQLServerGraphTableKind.NODE).to_sql() + assert sql == "AS NODE" + + def test_as_edge(self, dialect): + sql, _ = SQLServerAsGraphTableExpression(dialect, SQLServerGraphTableKind.EDGE).to_sql() + assert sql == "AS EDGE" + + def test_edge_constraint(self, dialect): + sql, _ = SQLServerEdgeConstraint(dialect, "Person", "Person", name="ec_friend").to_sql() + assert sql == "CONSTRAINT [ec_friend] CONNECTION ([Person] TO [Person])" + + def test_create_table_as_node(self, dialect): + expr = CreateTableExpression( + dialect, table="Person", + columns=[ColumnDefinition(dialect, "ID", IntegerType(dialect))], + dialect_options={"graph_table_kind": SQLServerGraphTableKind.NODE}, + ) + sql, _ = expr.to_sql() + assert sql.endswith(") AS NODE") + + def test_create_table_edge_with_constraint(self, dialect): + expr = CreateTableExpression( + dialect, table="friend", + columns=[ColumnDefinition(dialect, "start_date", VarCharType(dialect))], + dialect_options={ + "graph_table_kind": SQLServerGraphTableKind.EDGE, + "edge_constraints": [SQLServerEdgeConstraint(dialect, "Person", "Person", name="ec_friend")], + }, + ) + sql, _ = expr.to_sql() + assert "CONNECTION ([Person] TO [Person])" in sql + assert sql.endswith(") AS EDGE") or sql.endswith(" AS EDGE") + + +class TestCorePgqStillRejected: + """Core SQL/PGQ ``MatchClause`` must remain rejected (independent families).""" + + def test_core_match_clause_rejected(self, dialect): + from rhosocial.activerecord.backend.expression.graph import GraphVertex, MatchClause + + clause = MatchClause(dialect, GraphVertex(dialect, "p", "Person")) + with pytest.raises(UnsupportedFeatureError, match="graph MATCH clause"): + dialect.format_match_clause(clause) + + def test_supports_graph_match_false(self, dialect): + assert dialect.supports_graph_match() is False From 245a45d1b82e3416995f68a1ea313bd655ab5b19 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 16 Sep 2026 13:47:52 +0800 Subject: [PATCH 52/58] fix(graph): align SQLServerShortestPathExpression attrs with ctor params Store 'pattern'/'extra_patterns' so the introspection-based get_params() serialization round-trip can reconstruct the expression; expose 'patterns' as a derived property. --- .../backend/impl/sqlserver/expression/graph.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/graph.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/graph.py index 8236337..281d278 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/graph.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/graph.py @@ -150,16 +150,24 @@ def __init__( ) if maximum is not None and maximum < 1: raise ValueError("maximum must be >= 1.") - self.patterns = [pattern] + list(extra_patterns or []) comb = combinator.upper() if isinstance(combinator, str) else combinator if comb not in ("AND", ","): raise ValueError( f"Invalid SHORTEST_PATH combinator {combinator!r}; use 'AND' or ','." ) + # Attribute names mirror the constructor parameters so that the + # introspection-based get_params() / serialization round-trip works. + self.pattern = pattern + self.extra_patterns = list(extra_patterns or []) self.combinator = comb self.minimum = minimum self.maximum = maximum + @property + def patterns(self) -> List: + """All patterns: the primary ``pattern`` followed by ``extra_patterns``.""" + return [self.pattern] + list(self.extra_patterns) + @property def format_method(self) -> str: return "format_sqlserver_shortest_path" From 72ecc80dac84de8c65bc3dfbb41a636349179e60 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 17 Sep 2026 00:54:37 +0800 Subject: [PATCH 53/58] chore(ci): update rhosocial-activerecord install source to release/v1.0.0.dev30 --- .github/workflows/test.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a57e7f3..cd77f33 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -59,7 +59,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/parameter-inline" + 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/activerecord-derived-ddl" - name: Create test database @@ -149,7 +149,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/parameter-inline" + 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/activerecord-derived-ddl" - name: Create test database @@ -239,7 +239,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/parameter-inline" + 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/activerecord-derived-ddl" - name: Create test database @@ -329,7 +329,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/parameter-inline" + 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/activerecord-derived-ddl" - name: Create test database @@ -419,7 +419,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/parameter-inline" + 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/activerecord-derived-ddl" - name: Create test database @@ -509,7 +509,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/parameter-inline" + 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/activerecord-derived-ddl" - name: Create test database @@ -600,7 +600,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/parameter-inline" + 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/activerecord-derived-ddl" - name: Create test database @@ -690,7 +690,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/parameter-inline" + 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/activerecord-derived-ddl" - name: Create test database @@ -789,7 +789,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/parameter-inline" + 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/activerecord-derived-ddl" - name: Create test database From 99317293ba969532da4eebd59ff96f5265ddc152 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 17 Sep 2026 14:51:41 +0800 Subject: [PATCH 54/58] fix(sqlserver): correct pagination, database ownership and temporal formatting --- .../impl/sqlserver/mixins/ddl_database.py | 8 +-- .../backend/impl/sqlserver/mixins/dql.py | 20 ++++---- .../impl/sqlserver/mixins/protocol_support.py | 3 +- .../backend/dialect/test_dql_capabilities.py | 18 +++++-- .../feature/backend/test_dialect.py | 50 +++++++++++++++++++ .../feature/backend/test_protocol_support.py | 14 ++++++ 6 files changed, 91 insertions(+), 22 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_database.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_database.py index abc28e9..a833819 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_database.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_database.py @@ -4,8 +4,6 @@ from typing import Tuple, TYPE_CHECKING -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements.ddl_database import ( AlterDatabaseExpression, @@ -67,12 +65,14 @@ def format_alter_database_statement( self, expr: AlterDatabaseExpression ) -> Tuple[str, tuple]: from rhosocial.activerecord.backend.expression.statements.ddl_database import AlterDatabaseAction + if expr.action == AlterDatabaseAction.OWNER_TO: + database = self.format_identifier(expr.database_name) + owner = self.format_identifier(expr.target) + return f"ALTER AUTHORIZATION ON DATABASE::{database} TO {owner}", () parts = ["ALTER DATABASE"] parts.append(self.format_identifier(expr.database_name)) if expr.action == AlterDatabaseAction.RENAME_TO: parts.append(f"MODIFY NAME = {self.format_identifier(expr.target)}") - elif expr.action == AlterDatabaseAction.OWNER_TO: - parts.append(f"SET AUTHORIZATION TO {self.format_identifier(expr.target)}") elif expr.action == AlterDatabaseAction.COLLATION: parts.append(f"COLLATE {expr.target}") return " ".join(parts), () diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dql.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dql.py index 1d0cb1b..3f3552f 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dql.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dql.py @@ -13,8 +13,8 @@ class SQLServerDQLMixin: """SQL Server SELECT / LIMIT / OFFSET formatting.""" def supports_fetch_with_ties(self) -> bool: - """SQL Server supports FETCH NEXT ... ROWS WITH TIES (2012+).""" - return True + """SQL Server supports WITH TIES only with TOP, not OFFSET FETCH.""" + return False def supports_nulls_first_last(self) -> bool: """SQL Server does not support explicit NULLS FIRST/LAST ordering.""" @@ -53,6 +53,12 @@ def format_limit_offset( def format_limit_offset_clause(self, clause: "LimitOffsetClause") -> Tuple[str, tuple]: """Format LIMIT/OFFSET clause for SQL Server using OFFSET FETCH syntax.""" + if getattr(clause, "with_ties", False): + raise UnsupportedFeatureError( + self.name, "FETCH NEXT ... ROWS WITH TIES", + "SQL Server supports WITH TIES only with TOP, not OFFSET FETCH." + ) + if clause.limit is None and clause.offset is None: return "", () @@ -69,15 +75,7 @@ def format_limit_offset_clause(self, clause: "LimitOffsetClause") -> Tuple[str, parts.append(f"OFFSET {offset_val} ROWS") if clause.limit is not None: - if getattr(clause, "with_ties", False): - if not self.supports_fetch_with_ties(): - raise UnsupportedFeatureError( - self.name, "FETCH NEXT ... ROWS WITH TIES", - "This SQL Server dialect does not support WITH TIES." - ) - parts.append(f"FETCH NEXT {clause.limit} ROWS WITH TIES") - else: - parts.append(f"FETCH NEXT {clause.limit} ROWS ONLY") + parts.append(f"FETCH NEXT {clause.limit} ROWS ONLY") return " ".join(parts), () 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 4e918c8..ecf29f0 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/protocol_support.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/protocol_support.py @@ -314,10 +314,9 @@ def format_temporal_period_definition(self, start_column: str, end_column: str) """Format PERIOD FOR SYSTEM_TIME (start, end).""" from ..expression.temporal import SQLServerTemporalPeriodDefinition - sql = SQLServerTemporalPeriodDefinition( + return SQLServerTemporalPeriodDefinition( self, start_column, end_column ).to_sql() - return sql, () def format_create_temporal_table_statement(self, expr: Any) -> Tuple[str, tuple]: """Format CREATE TABLE with temporal options - not yet implemented.""" diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_dql_capabilities.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_dql_capabilities.py index ba5bba8..89789df 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_dql_capabilities.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_dql_capabilities.py @@ -3,6 +3,7 @@ import pytest +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression import ( ForUpdateClause, LimitOffsetClause, @@ -16,13 +17,20 @@ def dialect(): return SQLServerDialect(version=(16, 0, 0)) -def test_fetch_with_ties_supported(dialect): - assert dialect.supports_fetch_with_ties() is True +@pytest.mark.parametrize("version", [(10, 0, 0), (11, 0, 0), (16, 0, 0), (17, 0, 0)]) +def test_fetch_with_ties_unsupported(version): + assert SQLServerDialect(version=version).supports_fetch_with_ties() is False -def test_fetch_with_ties_renders(dialect): - sql, _ = dialect.format_limit_offset_clause(LimitOffsetClause(dialect, limit=5, with_ties=True)) - assert sql == "OFFSET 0 ROWS FETCH NEXT 5 ROWS WITH TIES" +@pytest.mark.parametrize("version", [(10, 0, 0), (11, 0, 0), (16, 0, 0), (17, 0, 0)]) +@pytest.mark.parametrize("limit, offset", [(5, None), (5, 2), (None, None)]) +def test_fetch_with_ties_raises(version, limit, offset): + dialect = SQLServerDialect(version=version) + clause = LimitOffsetClause(dialect, limit=limit, offset=offset, with_ties=True) + with pytest.raises(UnsupportedFeatureError, match="WITH TIES"): + dialect.format_limit_offset_clause(clause) + with pytest.raises(UnsupportedFeatureError, match="WITH TIES"): + clause.to_sql() def test_fetch_without_ties_renders_only(dialect): diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect.py index 4cd1c66..2e84175 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect.py @@ -6,6 +6,7 @@ """ import pytest from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect +from rhosocial.activerecord.backend.impl.sqlserver.mixins.ddl_database import SQLServerDatabaseMixin class TestSQLServerDialect: @@ -82,6 +83,55 @@ class TestSQLServerDialectStatements: def dialect(self): return SQLServerDialect(version=(16, 0, 0)) + @pytest.fixture + def database_dialect(self): + class DatabaseDialect(SQLServerDatabaseMixin, SQLServerDialect): + pass + + return DatabaseDialect(version=(16, 0, 0)) + + @pytest.mark.parametrize( + "database, owner, expected", + [ + ("app_db", "app_owner", "ALTER AUTHORIZATION ON DATABASE::[app_db] TO [app_owner]"), + ("app db", "new owner", "ALTER AUTHORIZATION ON DATABASE::[app db] TO [new owner]"), + ("app]db", "owner]name", "ALTER AUTHORIZATION ON DATABASE::[app]]db] TO [owner]]name]"), + ], + ) + def test_format_alter_database_owner(self, database_dialect, database, owner, expected): + from rhosocial.activerecord.backend.expression.statements.ddl_database import ( + AlterDatabaseAction, + AlterDatabaseExpression, + ) + + expr = AlterDatabaseExpression( + database_dialect, database, AlterDatabaseAction.OWNER_TO, target=owner + ) + assert database_dialect.format_alter_database_statement(expr) == (expected, ()) + assert expr.to_sql() == (expected, ()) + + @pytest.mark.parametrize( + "action, target, expected", + [ + ("RENAME_TO", "new_db", "ALTER DATABASE [app_db] MODIFY NAME = [new_db]"), + ( + "COLLATION", + "Latin1_General_CI_AS", + "ALTER DATABASE [app_db] COLLATE Latin1_General_CI_AS", + ), + ], + ) + def test_format_alter_database_other_actions(self, database_dialect, action, target, expected): + from rhosocial.activerecord.backend.expression.statements.ddl_database import ( + AlterDatabaseAction, + AlterDatabaseExpression, + ) + + expr = AlterDatabaseExpression( + database_dialect, "app_db", AlterDatabaseAction[action], target=target + ) + assert expr.to_sql() == (expected, ()) + def test_format_create_index_basic(self, dialect: SQLServerDialect): """Test basic CREATE INDEX formatting.""" from rhosocial.activerecord.backend.expression.statements import CreateIndexExpression diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_protocol_support.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_protocol_support.py index e3aefa6..f6fcd22 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_protocol_support.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_protocol_support.py @@ -469,6 +469,20 @@ def test_format_temporal_period_definition(self, dialect): assert sql == "PERIOD FOR SYSTEM_TIME ([SysStart], [SysEnd])" assert params == () + @pytest.mark.parametrize( + "start, end, expected", + [ + ("SysStart", "SysEnd", "PERIOD FOR SYSTEM_TIME ([SysStart], [SysEnd])"), + ("Start Time", "End]Time", "PERIOD FOR SYSTEM_TIME ([Start Time], [End]]Time])"), + ], + ) + def test_dialect_format_temporal_period_definition(self, dialect, start, end, expected): + sql, params = dialect.format_temporal_period_definition(start, end) + assert isinstance(sql, str) + assert sql == expected + assert params == () + assert (sql, params) == SQLServerTemporalPeriodDefinition(dialect, start, end).to_sql() + def test_supports_sequence_as_data_type(self, dialect): assert dialect.supports_sequence_as_data_type() is False From 9f7823d6105147aeb5ab90b1641c29c3ae87058b Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 17 Sep 2026 16:04:52 +0800 Subject: [PATCH 55/58] fix(graph): correct shortest path grouping and edge constraint gating --- .../impl/sqlserver/expression/ddl/graph.py | 2 +- .../impl/sqlserver/expression/graph.py | 2 +- .../backend/impl/sqlserver/mixins/graph.py | 28 +- .../dialect/test_sqlserver_sql_graph.py | 296 +++++++++++++++--- 4 files changed, 280 insertions(+), 48 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/graph.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/graph.py index ed76eb5..e46e420 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/graph.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/graph.py @@ -2,7 +2,7 @@ """SQL Server SQL Graph DDL expressions (SQL Server 2017+). Graph tables are ordinary tables annotated with ``AS NODE`` / ``AS EDGE`` -(edge tables additionally support ``CONNECTION`` edge constraints): +(edge tables additionally support ``CONNECTION`` edge constraints from SQL Server 2019): CREATE TABLE dbo.Person (ID INT PRIMARY KEY, name VARCHAR(50)) AS NODE; CREATE TABLE dbo.friend (start_date DATE) AS EDGE; diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/graph.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/graph.py index 281d278..2093d7e 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/graph.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/graph.py @@ -127,7 +127,7 @@ def format_method(self) -> str: class SQLServerShortestPathExpression(BaseExpression): - """A ``SHORTEST_PATH (...){quantifier}`` traversal (SQL Server 2019+). + """A ``SHORTEST_PATH(())`` traversal (SQL Server 2019+). SQL Server restricts the quantifier to ``+`` or ``{1,n}``; ``minimum`` must therefore be ``1``. diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/graph.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/graph.py index 65e72be..c80d0b3 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/graph.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/graph.py @@ -57,8 +57,8 @@ def supports_graph_path_aggregates(self) -> bool: return self.version >= SQL_SERVER_2019 def supports_edge_constraints(self) -> bool: - """``CONNECTION`` edge constraints require SQL Server 2017+.""" - return self.version >= SQL_SERVER_2017 + """``CONNECTION`` edge constraints require SQL Server 2019+.""" + return self.version >= SQL_SERVER_2019 # endregion @@ -84,7 +84,7 @@ def format_sqlserver_edge_constraint( raise UnsupportedFeatureError( self.name, "CONNECTION edge constraint", - "Edge constraints require SQL Server 2017 or later.", + "Edge constraints require SQL Server 2019 or later.", ) parts = [] if constraint.name: @@ -175,21 +175,29 @@ def format_sqlserver_match_predicate( def format_sqlserver_shortest_path( self, expr: "SQLServerShortestPathExpression" ) -> Tuple[str, tuple]: - """Format a ``SHORTEST_PATH (...){quantifier}`` expression.""" + """Format ``SHORTEST_PATH(())``.""" if not self.supports_shortest_path(): raise UnsupportedFeatureError( self.name, "SHORTEST_PATH", "SHORTEST_PATH requires SQL Server 2019 or later.", ) separator = " AND " if expr.combinator == "AND" else ", " + quantifier = "+" if expr.maximum is None else "{{1,{0}}}".format(expr.maximum) parts, params = [], [] for pattern in expr.patterns: - sql, pattern_params = pattern.to_sql() - parts.append(sql) - params.extend(pattern_params) - quantifier = "+" if expr.maximum is None else "{{1,{0}}}".format(expr.maximum) - inner = separator.join(parts) - return f"SHORTEST_PATH({inner}){quantifier}", tuple(params) + if not pattern.segments: + raise ValueError("SHORTEST_PATH requires at least one segment per pattern.") + start_sql, start_params = pattern.start.to_sql() + params.extend(start_params) + segments = [] + for edge_ref, node_ref in pattern.segments: + edge_sql, edge_params = edge_ref.to_sql() + node_sql, node_params = node_ref.to_sql() + segments.extend((edge_sql, node_sql)) + params.extend(edge_params) + params.extend(node_params) + parts.append(f"{start_sql}({''.join(segments)}){quantifier}") + return f"SHORTEST_PATH({separator.join(parts)})", tuple(params) def format_sqlserver_graph_path_aggregate( self, aggregate: "SQLServerGraphPathAggregate" diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_sqlserver_sql_graph.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_sqlserver_sql_graph.py index d0cea08..6cfa8c8 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_sqlserver_sql_graph.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_sqlserver_sql_graph.py @@ -5,6 +5,10 @@ ``.claude/plan/2026-09-16/pgq-expression-layer.md`` §4.3. """ +import os +from unittest.mock import Mock +from uuid import uuid4 + import pytest from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError @@ -12,7 +16,7 @@ ColumnDefinition, CreateTableExpression, ) -from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType +from rhosocial.activerecord.backend.expression.types import IntegerType from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect from rhosocial.activerecord.backend.impl.sqlserver.expression import ( SQLServerForPathTable, @@ -55,7 +59,10 @@ def test_sql_graph_since_2017(self, dialect_2017): assert dialect_2017.supports_sql_graph() is True assert dialect_2017.supports_graph_table_kind() is True assert dialect_2017.supports_graph_pseudo_columns() is True - assert dialect_2017.supports_edge_constraints() is True + + @pytest.mark.parametrize("major, supported", [(14, False), (15, True), (16, True), (17, True)]) + def test_edge_constraints_since_2019(self, major, supported): + assert SQLServerDialect(version=(major, 0, 0)).supports_edge_constraints() is supported def test_shortest_path_since_2019(self, dialect_2017, dialect): assert dialect_2017.supports_shortest_path() is False @@ -78,9 +85,19 @@ def test_left_direction(self, dialect): sql, _ = _path(dialect, direction=SQLServerGraphDirection.LEFT).to_sql() assert sql == "[Person1]<-([friend])-[Person2]" - def test_match_predicate_wraps_pattern(self, dialect): - sql, _ = SQLServerMatchPredicate(dialect, _path(dialect)).to_sql() - assert sql == "MATCH([Person1]-([friend])->[Person2])" + @pytest.mark.parametrize("major", [14, 15, 16, 17]) + def test_match_predicate_wraps_pattern(self, major): + dialect = SQLServerDialect(version=(major, 0, 0)) + assert SQLServerMatchPredicate(dialect, _path(dialect)).to_sql() == ( + "MATCH([Person1]-([friend])->[Person2])", () + ) + + def test_match_rejected_before_2017(self): + dialect = SQLServerDialect(version=(13, 0, 0)) + expr = SQLServerMatchPredicate(dialect, _path(dialect)) + with pytest.raises(UnsupportedFeatureError, match="MATCH") as exc: + expr.to_sql() + assert "2017" in exc.value.suggestion def test_match_combines_with_and(self, dialect): sql, _ = SQLServerMatchPredicate(dialect, [_path(dialect), _path(dialect)]).to_sql() @@ -101,16 +118,106 @@ def test_match_requires_pattern(self, dialect): class TestShortestPath: - def test_plus_quantifier(self, dialect): - expr = SQLServerShortestPathExpression(dialect, _path(dialect, edge="fo")) - sql, _ = expr.to_sql() - assert sql.endswith(")" + "+") - assert sql.startswith("SHORTEST_PATH(") + @pytest.mark.parametrize( + "direction, maximum, expected", + [ + (SQLServerGraphDirection.RIGHT, None, "SHORTEST_PATH([Person1](-([fo])->[Person2])+)"), + (SQLServerGraphDirection.RIGHT, 3, "SHORTEST_PATH([Person1](-([fo])->[Person2]){1,3})"), + (SQLServerGraphDirection.LEFT, None, "SHORTEST_PATH([Person1](<-([fo])-[Person2])+)"), + (SQLServerGraphDirection.LEFT, 3, "SHORTEST_PATH([Person1](<-([fo])-[Person2]){1,3})"), + ], + ) + def test_quantifier(self, dialect, direction, maximum, expected): + expr = SQLServerShortestPathExpression( + dialect, _path(dialect, edge="fo", direction=direction), maximum=maximum + ) + assert expr.to_sql() == (expected, ()) + + @pytest.mark.parametrize( + "maximum, expected", + [ + (None, "SHORTEST_PATH([Person1](-([fo])->[Person2]<-([works])-[Company])+)"), + (3, "SHORTEST_PATH([Person1](-([fo])->[Person2]<-([works])-[Company]){1,3})"), + ], + ) + def test_multi_segment_repeat_group(self, dialect, maximum, expected): + pattern = _path(dialect, edge="fo") + pattern.segments.append( + ( + SQLServerGraphEdgeRef(dialect, "works", SQLServerGraphDirection.LEFT), + SQLServerGraphNodeRef(dialect, "Company"), + ) + ) + assert SQLServerShortestPathExpression(dialect, pattern, maximum=maximum).to_sql() == ( + expected, () + ) - def test_bounded_quantifier(self, dialect): - expr = SQLServerShortestPathExpression(dialect, _path(dialect, edge="fo"), maximum=3) - sql, _ = expr.to_sql() - assert sql.endswith("{1,3}") + @pytest.mark.parametrize("maximum, quantifier", [(None, "+"), (3, "{1,3}")]) + @pytest.mark.parametrize("combinator, separator", [("AND", " AND "), (",", ", ")]) + def test_multiple_extra_patterns(self, dialect, maximum, quantifier, combinator, separator): + second = SQLServerGraphPattern( + dialect, + SQLServerGraphNodeRef(dialect, "Person", alias="Person3"), + [ + ( + SQLServerGraphEdgeRef(dialect, "works", SQLServerGraphDirection.LEFT), + SQLServerGraphNodeRef(dialect, "Company"), + ) + ], + ) + third = SQLServerGraphPattern( + dialect, + SQLServerGraphNodeRef(dialect, "City"), + [ + ( + SQLServerGraphEdgeRef(dialect, "located"), + SQLServerGraphNodeRef(dialect, "Country"), + ) + ], + ) + expr = SQLServerShortestPathExpression( + dialect, _path(dialect, edge="fo"), maximum=maximum, + extra_patterns=[second, third], combinator=combinator, + ) + assert expr.to_sql() == ( + f"SHORTEST_PATH([Person1](-([fo])->[Person2]){quantifier}" + f"{separator}[Person3](<-([works])-[Company]){quantifier}" + f"{separator}[City](-([located])->[Country]){quantifier})", + (), + ) + + @pytest.mark.parametrize("maximum, quantifier", [(None, "+"), (3, "{1,3}")]) + @pytest.mark.parametrize("combinator, separator", [("AND", " AND "), (",", ", ")]) + def test_params_flattened_once_in_pattern_order(self, dialect, maximum, quantifier, combinator, separator): + refs = [Mock() for _ in range(10)] + for index, ref in enumerate(refs): + ref.to_sql.return_value = (f"marker{index}", (f"param{index}a", f"param{index}b")) + first = SQLServerGraphPattern(dialect, refs[0], [(refs[1], refs[2]), (refs[3], refs[4])]) + second = SQLServerGraphPattern(dialect, refs[5], [(refs[6], refs[7]), (refs[8], refs[9])]) + expr = SQLServerShortestPathExpression( + dialect, first, maximum=maximum, extra_patterns=[second], combinator=combinator + ) + assert expr.to_sql() == ( + f"SHORTEST_PATH(marker0(marker1marker2marker3marker4){quantifier}" + f"{separator}marker5(marker6marker7marker8marker9){quantifier})", + ( + "param0a", "param0b", "param1a", "param1b", "param2a", "param2b", + "param3a", "param3b", "param4a", "param4b", "param5a", "param5b", + "param6a", "param6b", "param7a", "param7b", "param8a", "param8b", + "param9a", "param9b", + ), + ) + for ref in refs: + ref.to_sql.assert_called_once_with() + + @pytest.mark.parametrize("empty_extra", [False, True]) + def test_empty_segments_rejected(self, dialect, empty_extra): + empty = SQLServerGraphPattern(dialect, SQLServerGraphNodeRef(dialect, "Person"), []) + with pytest.raises(ValueError, match="at least one segment"): + SQLServerShortestPathExpression( + dialect, _path(dialect) if empty_extra else empty, + extra_patterns=[empty] if empty_extra else None, + ).to_sql() def test_minimum_must_be_one(self, dialect): with pytest.raises(ValueError): @@ -165,39 +272,156 @@ def test_rejects_unknown_name(self, dialect): class TestDdl: - def test_as_node(self, dialect): - sql, _ = SQLServerAsGraphTableExpression(dialect, SQLServerGraphTableKind.NODE).to_sql() - assert sql == "AS NODE" - - def test_as_edge(self, dialect): - sql, _ = SQLServerAsGraphTableExpression(dialect, SQLServerGraphTableKind.EDGE).to_sql() - assert sql == "AS EDGE" - - def test_edge_constraint(self, dialect): - sql, _ = SQLServerEdgeConstraint(dialect, "Person", "Person", name="ec_friend").to_sql() - assert sql == "CONSTRAINT [ec_friend] CONNECTION ([Person] TO [Person])" - - def test_create_table_as_node(self, dialect): + @pytest.mark.parametrize("major", [14, 15, 16, 17]) + @pytest.mark.parametrize( + "kind, expected", [(SQLServerGraphTableKind.NODE, "AS NODE"), (SQLServerGraphTableKind.EDGE, "AS EDGE")] + ) + def test_graph_table_kind(self, major, kind, expected): + dialect = SQLServerDialect(version=(major, 0, 0)) + assert SQLServerAsGraphTableExpression(dialect, kind).to_sql() == (expected, ()) + + @pytest.mark.parametrize("kind", [SQLServerGraphTableKind.NODE, SQLServerGraphTableKind.EDGE]) + @pytest.mark.parametrize("create_table", [False, True]) + def test_graph_table_rejected_before_2017(self, kind, create_table): + dialect = SQLServerDialect(version=(13, 0, 0)) + expr = SQLServerAsGraphTableExpression(dialect, kind) + if create_table: + expr = CreateTableExpression( + dialect, table="graph_table", + columns=[ColumnDefinition(dialect, "ID", IntegerType(dialect))], + dialect_options={"graph_table_kind": kind}, + ) + with pytest.raises(UnsupportedFeatureError, match="AS NODE/EDGE") as exc: + expr.to_sql() + assert "2017" in exc.value.suggestion + + @pytest.mark.parametrize("major", [14, 15, 16, 17]) + @pytest.mark.parametrize( + "name, expected", + [ + (None, "CONNECTION ([Person] TO [Person])"), + ("ec_friend", "CONSTRAINT [ec_friend] CONNECTION ([Person] TO [Person])"), + ], + ) + def test_edge_constraint(self, major, name, expected): + dialect = SQLServerDialect(version=(major, 0, 0)) + expr = SQLServerEdgeConstraint(dialect, "Person", "Person", name=name) + if major == 14: + with pytest.raises(UnsupportedFeatureError, match="CONNECTION") as exc: + expr.to_sql() + assert "2019" in exc.value.suggestion + else: + assert expr.to_sql() == (expected, ()) + + @pytest.mark.parametrize("major", [14, 15, 16, 17]) + @pytest.mark.parametrize( + "kind, expected", + [ + (SQLServerGraphTableKind.NODE, "CREATE TABLE [graph_table] ([ID] INT) AS NODE"), + (SQLServerGraphTableKind.EDGE, "CREATE TABLE [graph_table] ([ID] INT) AS EDGE"), + ], + ) + def test_create_graph_table(self, major, kind, expected): + dialect = SQLServerDialect(version=(major, 0, 0)) expr = CreateTableExpression( - dialect, table="Person", + dialect, table="graph_table", columns=[ColumnDefinition(dialect, "ID", IntegerType(dialect))], - dialect_options={"graph_table_kind": SQLServerGraphTableKind.NODE}, + dialect_options={"graph_table_kind": kind}, ) - sql, _ = expr.to_sql() - assert sql.endswith(") AS NODE") + assert expr.to_sql() == (expected, ()) - def test_create_table_edge_with_constraint(self, dialect): + @pytest.mark.parametrize("major", [14, 15, 16, 17]) + def test_create_table_edge_with_constraint(self, major): + dialect = SQLServerDialect(version=(major, 0, 0)) expr = CreateTableExpression( dialect, table="friend", - columns=[ColumnDefinition(dialect, "start_date", VarCharType(dialect))], + columns=[ColumnDefinition(dialect, "ID", IntegerType(dialect))], dialect_options={ "graph_table_kind": SQLServerGraphTableKind.EDGE, "edge_constraints": [SQLServerEdgeConstraint(dialect, "Person", "Person", name="ec_friend")], }, ) - sql, _ = expr.to_sql() - assert "CONNECTION ([Person] TO [Person])" in sql - assert sql.endswith(") AS EDGE") or sql.endswith(" AS EDGE") + if major == 14: + with pytest.raises(UnsupportedFeatureError, match="CONNECTION") as exc: + expr.to_sql() + assert "2019" in exc.value.suggestion + else: + assert expr.to_sql() == ( + "CREATE TABLE [friend] ([ID] INT, " + "CONSTRAINT [ec_friend] CONNECTION ([Person] TO [Person])) AS EDGE", + (), + ) + + +@pytest.mark.requires_sqlserver +@pytest.mark.skipif( + not os.environ.get("SQLSERVER_GRAPH_TEST_CONNECTION_STRING"), + reason="SQLSERVER_GRAPH_TEST_CONNECTION_STRING must target a disposable SQL Server 2019+ database", +) +class TestGraphExecution: + @pytest.mark.parametrize("maximum", [None, 3]) + @pytest.mark.parametrize("direction", [SQLServerGraphDirection.RIGHT, SQLServerGraphDirection.LEFT]) + @pytest.mark.parametrize("segment_count", [1, 2]) + def test_shortest_path_and_edge_constraint(self, maximum, direction, segment_count): + import pyodbc + + connection = pyodbc.connect( + os.environ["SQLSERVER_GRAPH_TEST_CONNECTION_STRING"], autocommit=False, timeout=5 + ) + try: + cursor = connection.cursor() + major = int(cursor.execute("SELECT SERVERPROPERTY('ProductMajorVersion')").fetchone()[0]) + if major < 15: + pytest.skip("SQL Graph shortest paths and edge constraints require SQL Server 2019+") + dialect = SQLServerDialect(version=(major, 0, 0)) + suffix = uuid4().hex + node_name, edge_name = f"graph_node_{suffix}", f"graph_edge_{suffix}" + node = dialect.format_identifier(node_name) + edge = dialect.format_identifier(edge_name) + node_kind = SQLServerAsGraphTableExpression(dialect, SQLServerGraphTableKind.NODE).to_sql()[0] + edge_kind = SQLServerAsGraphTableExpression(dialect, SQLServerGraphTableKind.EDGE).to_sql()[0] + constraint_sql, params = SQLServerEdgeConstraint(dialect, node_name, node_name).to_sql() + assert params == () + cursor.execute(f"CREATE TABLE {node} (ID INT NOT NULL) {node_kind}") + cursor.execute(f"CREATE TABLE {edge} ({constraint_sql}) {edge_kind}") + cursor.execute(f"INSERT INTO {node} (ID) VALUES (1), (2), (3), (4), (5)") + cursor.execute( + f"INSERT INTO {edge} ($from_id, $to_id) " + f"SELECT a.$node_id, b.$node_id FROM {node} a, {node} b WHERE b.ID = a.ID + 1" + ) + segments, tables = [], [f"{node} AS [Person1]"] + for index in range(segment_count): + edge_alias, node_alias = f"fo{index}", f"Person{index + 2}" + segments.append(( + SQLServerGraphEdgeRef(dialect, edge_alias, direction), + SQLServerGraphNodeRef(dialect, node_name, alias=node_alias), + )) + tables.extend([ + SQLServerForPathTable(dialect, edge_name, alias=edge_alias).to_sql()[0], + SQLServerForPathTable(dialect, node_name, alias=node_alias).to_sql()[0], + ]) + pattern = SQLServerGraphPattern( + dialect, SQLServerGraphNodeRef(dialect, node_name, alias="Person1"), segments + ) + predicate = SQLServerMatchPredicate( + dialect, SQLServerShortestPathExpression(dialect, pattern, maximum=maximum) + ) + match_sql, params = predicate.to_sql() + start_id = 1 if direction == SQLServerGraphDirection.RIGHT else 5 + cursor.execute( + f"SELECT LAST_VALUE([Person{segment_count + 1}].ID) WITHIN GROUP (GRAPH PATH) " + f"FROM {', '.join(tables)} WHERE {match_sql} AND [Person1].ID = ?", + params + (start_id,), + ) + actual = {row[0] for row in cursor.fetchall()} + hops = min(4 // segment_count, maximum or 4) + step = segment_count if start_id == 1 else -segment_count + assert actual == {start_id + step * iteration for iteration in range(1, hops + 1)} + finally: + try: + connection.rollback() + finally: + connection.close() class TestCorePgqStillRejected: From d46f46c357a80d5967ce4cc9f6520e046e9636df Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 17 Sep 2026 17:09:00 +0800 Subject: [PATCH 56/58] test(graph): reuse scenario fixtures and protocol capability checks --- .../dialect/test_sqlserver_sql_graph.py | 62 ++++++++++--------- 1 file changed, 34 insertions(+), 28 deletions(-) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_sqlserver_sql_graph.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_sqlserver_sql_graph.py index 6cfa8c8..671ca81 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_sqlserver_sql_graph.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_sqlserver_sql_graph.py @@ -5,7 +5,7 @@ ``.claude/plan/2026-09-16/pgq-expression-layer.md`` §4.3. """ -import os +from types import SimpleNamespace from unittest.mock import Mock from uuid import uuid4 @@ -34,6 +34,8 @@ SQLServerEdgeConstraint, SQLServerGraphTableKind, ) +from rhosocial.activerecord.backend.impl.sqlserver.protocols import SQLServerGraphSupport +from rhosocial.activerecord.testsuite.utils import requires_protocol, skip_test_if_protocol_unsupported @pytest.fixture @@ -353,27 +355,32 @@ def test_create_table_edge_with_constraint(self, major): ) +@pytest.fixture +def check_graph_protocol_requirements(request, sqlserver_backend): + subject = SimpleNamespace(__backend__=sqlserver_backend) + for marker in request.node.iter_markers("requires_protocol"): + protocol_class, method_name = marker.args[0] + skip_test_if_protocol_unsupported(subject, protocol_class, method_name) + + @pytest.mark.requires_sqlserver -@pytest.mark.skipif( - not os.environ.get("SQLSERVER_GRAPH_TEST_CONNECTION_STRING"), - reason="SQLSERVER_GRAPH_TEST_CONNECTION_STRING must target a disposable SQL Server 2019+ database", -) +@pytest.mark.usefixtures("check_graph_protocol_requirements") +@requires_protocol(SQLServerGraphSupport, "supports_sql_graph") +@requires_protocol(SQLServerGraphSupport, "supports_graph_table_kind") +@requires_protocol(SQLServerGraphSupport, "supports_graph_pseudo_columns") +@requires_protocol(SQLServerGraphSupport, "supports_shortest_path") +@requires_protocol(SQLServerGraphSupport, "supports_graph_path_aggregates") +@requires_protocol(SQLServerGraphSupport, "supports_edge_constraints") class TestGraphExecution: @pytest.mark.parametrize("maximum", [None, 3]) @pytest.mark.parametrize("direction", [SQLServerGraphDirection.RIGHT, SQLServerGraphDirection.LEFT]) @pytest.mark.parametrize("segment_count", [1, 2]) - def test_shortest_path_and_edge_constraint(self, maximum, direction, segment_count): - import pyodbc - - connection = pyodbc.connect( - os.environ["SQLSERVER_GRAPH_TEST_CONNECTION_STRING"], autocommit=False, timeout=5 - ) + def test_shortest_path_and_edge_constraint(self, sqlserver_backend, maximum, direction, segment_count): + backend = sqlserver_backend + dialect = backend.dialect + transaction = backend.transaction_manager + transaction.begin() try: - cursor = connection.cursor() - major = int(cursor.execute("SELECT SERVERPROPERTY('ProductMajorVersion')").fetchone()[0]) - if major < 15: - pytest.skip("SQL Graph shortest paths and edge constraints require SQL Server 2019+") - dialect = SQLServerDialect(version=(major, 0, 0)) suffix = uuid4().hex node_name, edge_name = f"graph_node_{suffix}", f"graph_edge_{suffix}" node = dialect.format_identifier(node_name) @@ -382,10 +389,10 @@ def test_shortest_path_and_edge_constraint(self, maximum, direction, segment_cou edge_kind = SQLServerAsGraphTableExpression(dialect, SQLServerGraphTableKind.EDGE).to_sql()[0] constraint_sql, params = SQLServerEdgeConstraint(dialect, node_name, node_name).to_sql() assert params == () - cursor.execute(f"CREATE TABLE {node} (ID INT NOT NULL) {node_kind}") - cursor.execute(f"CREATE TABLE {edge} ({constraint_sql}) {edge_kind}") - cursor.execute(f"INSERT INTO {node} (ID) VALUES (1), (2), (3), (4), (5)") - cursor.execute( + backend.execute(f"CREATE TABLE {node} (ID INT NOT NULL) {node_kind}") + backend.execute(f"CREATE TABLE {edge} ({constraint_sql}) {edge_kind}") + backend.execute(f"INSERT INTO {node} (ID) VALUES (1), (2), (3), (4), (5)") + backend.execute( f"INSERT INTO {edge} ($from_id, $to_id) " f"SELECT a.$node_id, b.$node_id FROM {node} a, {node} b WHERE b.ID = a.ID + 1" ) @@ -408,20 +415,19 @@ def test_shortest_path_and_edge_constraint(self, maximum, direction, segment_cou ) match_sql, params = predicate.to_sql() start_id = 1 if direction == SQLServerGraphDirection.RIGHT else 5 - cursor.execute( - f"SELECT LAST_VALUE([Person{segment_count + 1}].ID) WITHIN GROUP (GRAPH PATH) " - f"FROM {', '.join(tables)} WHERE {match_sql} AND [Person1].ID = ?", + result = backend.execute( + f"SELECT LAST_VALUE([Person{segment_count + 1}].ID) WITHIN GROUP (GRAPH PATH) AS [endpoint_id] " + f"FROM {', '.join(tables)} WHERE {match_sql} AND [Person1].ID = {dialect.p()}", params + (start_id,), ) - actual = {row[0] for row in cursor.fetchall()} + assert result.data is not None + actual = {row["endpoint_id"] for row in result.data} hops = min(4 // segment_count, maximum or 4) step = segment_count if start_id == 1 else -segment_count assert actual == {start_id + step * iteration for iteration in range(1, hops + 1)} finally: - try: - connection.rollback() - finally: - connection.close() + if transaction.is_active: + transaction.rollback() class TestCorePgqStillRejected: From de8850a899251bf2f100de264dd09873daf4a9d1 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 17 Sep 2026 18:06:58 +0800 Subject: [PATCH 57/58] feat(sqlserver): add statement-level NOSCAN execution option --- .../backend/impl/sqlserver/__init__.py | 2 + .../backend/impl/sqlserver/async_backend.py | 21 +- .../backend/impl/sqlserver/backend.py | 30 ++- .../backend/impl/sqlserver/options.py | 9 + .../dialect/test_sqlserver_sql_graph.py | 3 + .../feature/backend/test_execution_options.py | 232 ++++++++++++++++++ 6 files changed, 293 insertions(+), 4 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/options.py create mode 100644 tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_execution_options.py diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/__init__.py index e090f10..afe338b 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/__init__.py @@ -5,6 +5,7 @@ from .config import SQLServerConnectionConfig from .collation import SQLServerCollation from .dialect import SQLServerDialect +from .options import SQLServerExecutionOptions from .transaction import SQLServerTransactionManager from .adapters import ( SQLServerUUIDAdapter, @@ -66,6 +67,7 @@ __all__ = [ "SQLServerBackend", "SQLServerConnectionConfig", + "SQLServerExecutionOptions", "SQLServerDialect", "SQLServerCollation", "SQLServerTransactionManager", diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/async_backend.py b/src/rhosocial/activerecord/backend/impl/sqlserver/async_backend.py index 7a23574..ead50ca 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/async_backend.py @@ -9,7 +9,8 @@ import asyncio import logging import time -from typing import Any, Dict, List, Optional, Set, Tuple +from contextlib import asynccontextmanager +from typing import Any, AsyncIterator, Dict, List, Optional, Set, Tuple from rhosocial.activerecord.backend.base import AsyncStorageBackend from rhosocial.activerecord.backend.base.operations import AsyncSQLOperationsMixin @@ -329,6 +330,21 @@ async def execute( return await self._execute_statement(sql, params, options) + @asynccontextmanager + async def _cursor_noscan(self, cursor: Any, noscan: Optional[bool]) -> AsyncIterator[None]: + if noscan is None: + yield + return + + impl = cursor._impl + run_operation = cursor._run_operation + previous = await run_operation(getattr, impl, "noscan") + try: + await run_operation(setattr, impl, "noscan", noscan) + yield + finally: + await run_operation(setattr, impl, "noscan", previous) + async def _execute_statement( self, sql: str, params: Optional[Tuple], options, start_time: Optional[float] = None ) -> QueryResult: @@ -366,7 +382,8 @@ async def _execute_statement( if identity_table is not None: await self.set_identity_insert(identity_table, True) try: - await cursor.execute(final_sql, final_params or ()) + async with self._cursor_noscan(cursor, getattr(options, "noscan", None)): + await cursor.execute(final_sql, final_params or ()) finally: if identity_table is not None: await self.set_identity_insert(identity_table, False) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/backend.py b/src/rhosocial/activerecord/backend/impl/sqlserver/backend.py index ea72057..1835a1d 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/backend.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/backend.py @@ -10,6 +10,7 @@ import logging import re import time +from contextvars import ContextVar from typing import Any, Dict, List, Optional, Tuple, Type import pyodbc @@ -24,6 +25,7 @@ OperationalError, QueryError, ) +from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.result import QueryResult from rhosocial.activerecord.backend.introspection.backend_mixin import IntrospectorBackendMixin from rhosocial.activerecord.backend.explain import SyncExplainBackendMixin @@ -161,6 +163,9 @@ def __init__(self, **kwargs): self._connection = None self._transaction_manager = None self._default_suggestions_cache = None + self._execution_noscan: ContextVar[Optional[bool]] = ContextVar( + "sqlserver_execution_noscan", default=None + ) self._register_sqlserver_adapters() @@ -358,7 +363,7 @@ def execute( cursor.execute(f"SET {set_clause} ON") cursor.close() try: - return super().execute(statement, params, options=options) + return self._execute_with_options(statement, params, options) finally: cursor = self._get_cursor() cursor.execute(f"SET {set_clause} OFF") @@ -368,7 +373,7 @@ def execute( if identity_table is not None: self.set_identity_insert(identity_table, True) try: - result = super().execute(sql, params, options=options) + result = self._execute_with_options(sql, params, options) finally: if identity_table is not None: self.set_identity_insert(identity_table, False) @@ -380,6 +385,27 @@ def execute( return result + def _execute_with_options( + self, sql: str, params: Optional[Tuple], options: ExecutionOptions + ) -> QueryResult: + token = self._execution_noscan.set(getattr(options, "noscan", None)) + try: + return super().execute(sql, params, options=options) + finally: + self._execution_noscan.reset(token) + + def _execute_query(self, cursor: Any, sql: str, params: Optional[Tuple]) -> Any: + noscan = self._execution_noscan.get() + if noscan is None: + return super()._execute_query(cursor, sql, params) + + previous = cursor.noscan + try: + cursor.noscan = noscan + return super()._execute_query(cursor, sql, params) + finally: + cursor.noscan = previous + def execute_many(self, sql: str, params_list: List[Tuple]) -> QueryResult: """Execute the same SQL statement multiple times with different parameters. diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/options.py b/src/rhosocial/activerecord/backend/impl/sqlserver/options.py new file mode 100644 index 0000000..28ff512 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/options.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass +from typing import Optional + +from rhosocial.activerecord.backend.options import ExecutionOptions + + +@dataclass +class SQLServerExecutionOptions(ExecutionOptions): + noscan: Optional[bool] = None diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_sqlserver_sql_graph.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_sqlserver_sql_graph.py index 671ca81..186ec5d 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_sqlserver_sql_graph.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_sqlserver_sql_graph.py @@ -34,8 +34,10 @@ SQLServerEdgeConstraint, SQLServerGraphTableKind, ) +from rhosocial.activerecord.backend.impl.sqlserver.options import SQLServerExecutionOptions from rhosocial.activerecord.backend.impl.sqlserver.protocols import SQLServerGraphSupport from rhosocial.activerecord.testsuite.utils import requires_protocol, skip_test_if_protocol_unsupported +from rhosocial.activerecord.backend.options import StatementType @pytest.fixture @@ -419,6 +421,7 @@ def test_shortest_path_and_edge_constraint(self, sqlserver_backend, maximum, dir f"SELECT LAST_VALUE([Person{segment_count + 1}].ID) WITHIN GROUP (GRAPH PATH) AS [endpoint_id] " f"FROM {', '.join(tables)} WHERE {match_sql} AND [Person1].ID = {dialect.p()}", params + (start_id,), + options=SQLServerExecutionOptions(stmt_type=StatementType.DQL, noscan=True), ) assert result.data is not None actual = {row["endpoint_id"] for row in result.data} diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_execution_options.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_execution_options.py new file mode 100644 index 0000000..1ada365 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_execution_options.py @@ -0,0 +1,232 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from rhosocial.activerecord.backend.base import StorageBackend +from rhosocial.activerecord.backend.errors import DatabaseError +from rhosocial.activerecord.backend.impl.sqlserver import ( + AsyncSQLServerBackend, + SQLServerBackend, + SQLServerExecutionOptions, +) +from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig +from rhosocial.activerecord.backend.options import ExecutionOptions, StatementType + + +SQL = "SELECT {fn UCASE(?)} AS [value], '?' AS [literal]" +PARAMS = ("{not an ODBC escape}",) +OPTION_CASES = [ + pytest.param(SQLServerExecutionOptions(StatementType.DQL, noscan=True), id="true"), + pytest.param(SQLServerExecutionOptions(StatementType.DQL, noscan=False), id="false"), + pytest.param(SQLServerExecutionOptions(StatementType.DQL, noscan=None), id="none"), + pytest.param(ExecutionOptions(StatementType.DQL), id="core"), +] + + +@pytest.fixture(scope="session", autouse=True) +def _prepare_read_committed_snapshot(): + return None + + +@pytest.fixture(autouse=True) +def block_connections(monkeypatch): + monkeypatch.setattr( + SQLServerBackend, "connect", MagicMock(side_effect=AssertionError("Unexpected database connection")) + ) + monkeypatch.setattr( + AsyncSQLServerBackend, "connect", AsyncMock(side_effect=AssertionError("Unexpected database connection")) + ) + + +class NativeCursor: + def __init__(self, noscan): + self._noscan = noscan + self.accesses = [] + + @property + def noscan(self): + self.accesses.append(("get",)) + return self._noscan + + @noscan.setter + def noscan(self, value): + self.accesses.append(("set", value)) + self._noscan = value + + +class AsyncCursor: + def __init__(self, previous, error=None): + self._impl = NativeCursor(previous) + self.noscan = "wrapper must not change" + self.operations = [] + self.executions = [] + self.error = error + self.description = None + self.rowcount = 0 + self.lastrowid = None + self.exit_value = None + + async def _run_operation(self, operation, *args): + self.operations.append((operation, args)) + return operation(*args) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + self.exit_value = self._impl._noscan + + async def execute(self, sql, params): + self.executions.append((sql, params, self._impl._noscan, self.noscan)) + if self.error is not None: + raise self.error + + +def make_backend(backend_class): + config = SQLServerConnectionConfig(host="localhost", database="master", username="sa", password="") + backend = backend_class(connection_config=config) + backend._connection = object() + return backend + + +def expected_accesses(noscan, previous): + return [] if noscan is None else [("get",), ("set", noscan), ("set", previous)] + + +def test_execution_options_default(): + options = SQLServerExecutionOptions(StatementType.DQL) + assert isinstance(options, ExecutionOptions) + assert options.noscan is None + + +@pytest.mark.parametrize("options", OPTION_CASES) +@pytest.mark.parametrize("fails", [False, True], ids=["success", "error"]) +@pytest.mark.parametrize("outer", [None, True, False]) +def test_sync_execution_restores_cursor_and_context(monkeypatch, options, fails, outer): + backend = make_backend(SQLServerBackend) + assert backend._execution_noscan.get() is None + noscan = getattr(options, "noscan", None) + previous = noscan is not True + cursor = NativeCursor(previous) + error = RuntimeError("execution failed") + executions = [] + delegated = [] + + def execute(sql, params): + executions.append((sql, params, cursor._noscan)) + if fails: + raise error + + cursor.execute = execute + + def core_execute(self, sql, params, *, options): + delegated.append((sql, params, options, self._execution_noscan.get())) + return self._execute_query(cursor, sql, params) + + monkeypatch.setattr(StorageBackend, "execute", core_execute) + token = backend._execution_noscan.set(outer) + try: + if fails: + with pytest.raises(RuntimeError, match="execution failed") as caught: + backend.execute(SQL, PARAMS, options=options) + assert caught.value is error + else: + assert backend.execute(SQL, PARAMS, options=options) is cursor + assert backend._execution_noscan.get() is outer + assert cursor._noscan is previous + assert cursor.accesses == expected_accesses(noscan, previous) + assert delegated == [(SQL, PARAMS, options, noscan)] + assert delegated[0][2] is options + assert executions == [(SQL, PARAMS, previous if noscan is None else noscan)] + finally: + backend._execution_noscan.reset(token) + + assert backend._execution_noscan.get() is None + fails = False + cursor.accesses.clear() + assert backend.execute(SQL, PARAMS) is cursor + assert backend._execution_noscan.get() is None + assert executions[-1] == (SQL, PARAMS, previous) + assert len(executions) == 2 + assert type(delegated[-1][2]) is ExecutionOptions + assert delegated[-1][3] is None + assert cursor.accesses == [] + + +@pytest.mark.parametrize("options", OPTION_CASES) +@pytest.mark.parametrize("fails", [False, True], ids=["success", "error"]) +@pytest.mark.parametrize("previous", [False, True]) +@pytest.mark.asyncio +async def test_async_statement_uses_native_noscan(monkeypatch, options, fails, previous): + backend = make_backend(AsyncSQLServerBackend) + noscan = getattr(options, "noscan", None) + cursor = AsyncCursor(previous, RuntimeError("execution failed") if fails else None) + monkeypatch.setattr(backend, "_get_cursor", AsyncMock(return_value=cursor)) + monkeypatch.setattr(backend, "_handle_auto_commit_if_needed", AsyncMock()) + + if fails: + with pytest.raises(DatabaseError, match="execution failed"): + await backend.execute(SQL, PARAMS, options=options) + else: + result = await backend.execute(SQL, PARAMS, options=options) + assert result.affected_rows == 0 + assert result.data is None + + assert cursor.executions == [ + (SQL, PARAMS, previous if noscan is None else noscan, "wrapper must not change") + ] + assert cursor._impl._noscan is previous + assert cursor.exit_value is previous + assert cursor.noscan == "wrapper must not change" + assert cursor._impl.accesses == expected_accesses(noscan, previous) + assert cursor.operations == ([] if noscan is None else [ + (getattr, (cursor._impl, "noscan")), + (setattr, (cursor._impl, "noscan", noscan)), + (setattr, (cursor._impl, "noscan", previous)), + ]) + + cursor.error = None + cursor.operations.clear() + cursor._impl.accesses.clear() + await backend.execute(SQL, PARAMS) + assert cursor.executions[-1] == (SQL, PARAMS, previous, "wrapper must not change") + assert len(cursor.executions) == 2 + assert cursor.operations == [] + assert cursor._impl.accesses == [] + + +@pytest.mark.parametrize("noscan", [True, False, None]) +@pytest.mark.parametrize("fails", [False, True], ids=["success", "error"]) +@pytest.mark.asyncio +async def test_async_cursor_noscan_context_restores_native_value(noscan, fails): + backend = make_backend(AsyncSQLServerBackend) + previous = noscan is not True + cursor = AsyncCursor(previous) + error = RuntimeError("context failed") + + async def use_context(): + async with backend._cursor_noscan(cursor, noscan): + assert cursor._impl._noscan is (previous if noscan is None else noscan) + assert cursor.noscan == "wrapper must not change" + if fails: + raise error + + if fails: + with pytest.raises(RuntimeError, match="context failed") as caught: + await use_context() + assert caught.value is error + else: + await use_context() + assert cursor._impl._noscan is previous + assert cursor.noscan == "wrapper must not change" + assert cursor._impl.accesses == expected_accesses(noscan, previous) + + +@pytest.mark.asyncio +async def test_async_cursor_noscan_none_does_not_require_native_cursor(): + backend = make_backend(AsyncSQLServerBackend) + cursor = SimpleNamespace() + async with backend._cursor_noscan(cursor, None): + assert vars(cursor) == {} + assert vars(cursor) == {} From b03d0097c84d6464a2416ce01fc7bfe03320f830 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 17 Sep 2026 19:26:42 +0800 Subject: [PATCH 58/58] chore: add changelog fragment for graph support and NOSCAN option --- changelog.d/3.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/3.added.md diff --git a/changelog.d/3.added.md b/changelog.d/3.added.md new file mode 100644 index 0000000..92fcb2b --- /dev/null +++ b/changelog.d/3.added.md @@ -0,0 +1 @@ +Add SQL Server SQL Graph support (node/edge tables, MATCH predicate, SHORTEST_PATH) and statement-level NOSCAN execution option