diff --git a/.github/PULL_REQUEST_TEMPLATE/feature_template.md b/.github/PULL_REQUEST_TEMPLATE/feature_template.md index 0c75cfb..e78be93 100644 --- a/.github/PULL_REQUEST_TEMPLATE/feature_template.md +++ b/.github/PULL_REQUEST_TEMPLATE/feature_template.md @@ -1,8 +1,8 @@ --- name: "Feature Pull Request" -about: Propose a new feature for the rhosocial-activerecord ecosystem, targeting a pre-release branch (e.g., release/vX.Y.Z). +about: Propose a new feature for the rhosocial-activerecord SQL Server backend, targeting a pre-release branch (e.g., release/vX.Y.Z). title: "feat(): " -labels: "feature" +labels: "enhancement" assignees: "" --- @@ -40,18 +40,22 @@ Does this PR introduce a breaking change? - If NO, please indicate N/A. --> -- [ ] Yes -- [ ] No +- [ ] Yes, for end-users (backward-incompatible API changes) +- [ ] Yes, for backend developers (internal architectural changes that may affect custom implementations) +- [ ] No breaking changes (If Yes, please describe the impact and migration path below): -**Applicable `rhosocial-activerecord` Version Range:** - +**Impact on End-Users:** + + +**Impact on Backend Developers (Custom Implementations):** + ## Related Repositories/Packages @@ -79,12 +83,12 @@ Go over all the following points, and put an `x` in all the boxes that apply. If you're unsure about any of these, don't hesitate to ask. We're here to help! --> -- [ ] My code follows the project's code style guidelines. +- [ ] My code follows the project's code style guidelines in `.claude/code_style.md`. - [ ] I have performed a self-review of my own code. - [ ] I have commented my code, particularly in hard-to-understand areas. - [ ] I have made corresponding changes to the documentation (Docstrings, `README.md`, etc.). - [ ] My changes generate no new warnings. -- [ ] I have added a Changelog fragment (`changelog.d/..md`). +- [ ] I have added a Changelog fragment as described in `.claude/version_control.md` (`changelog.d/..md`). - [ ] I have verified that my changes do not introduce SQL injection vulnerabilities. - [ ] I have checked for potential performance regressions. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cd77f33..a850841 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -60,7 +60,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Create test database run: | @@ -150,7 +150,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Create test database run: | @@ -240,7 +240,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Create test database run: | @@ -330,7 +330,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Create test database run: | @@ -420,7 +420,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Create test database run: | @@ -510,7 +510,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Create test database run: | @@ -601,7 +601,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Create test database run: | @@ -691,7 +691,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Create test database run: | @@ -790,7 +790,7 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Create test database run: | diff --git a/CLAUDE.md b/CLAUDE.md index 0e2966c..f273f49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,3 +35,9 @@ This project is in early development. Key features being implemented: ## Version Control and Changelog This project adheres to the same version control, branching, commit message, and changelog management standards as the main `python-activerecord` project. + +## Search Scope + +- Use `rg` for repository searches; never use `grep`. +- Limit searches to this `python-activerecord` repository and its related testsuite/backend directories. +- Exclude `.git/`, `.venv*/`, `__pycache__/`, `build/`, `dist/`, and other cache directories. diff --git a/changelog.d/4.added.md b/changelog.d/4.added.md new file mode 100644 index 0000000..276b2d7 --- /dev/null +++ b/changelog.d/4.added.md @@ -0,0 +1 @@ +Adapt the SQL Server backend to derived DDL with typed options: `SQLServerColumnDefinition`, `SQLServerCreateTableOptions`, `SQLServerIndexDefinition` with typed hash-index fields, typed `ALTER COLUMN` options, a graph table expression, and typed `MERGE` / `SELECT INTO` / partition scheme. Add type DDL support, classify `CommentSupport` and `ColumnAttributeSupport` for conformance, and cover `DDLSource` collection, table inheritance, tablespace and inline-index rendering. diff --git a/changelog.d/4.fixed.md b/changelog.d/4.fixed.md new file mode 100644 index 0000000..938efb3 --- /dev/null +++ b/changelog.d/4.fixed.md @@ -0,0 +1 @@ +Stop silently discarding declared DDL in the SQL Server backend: a declared table or column comment now raises instead of being dropped, declared DDL options are no longer discarded, unsupported table declarations are rejected rather than omitted, and `CREATE VIEW ... WITH CHECK OPTION` is gated on `supports_view_check_option`. diff --git a/changelog.d/4.internal.md b/changelog.d/4.internal.md new file mode 100644 index 0000000..0896154 --- /dev/null +++ b/changelog.d/4.internal.md @@ -0,0 +1 @@ +Install core and testsuite from release branches in CI instead of the derived-DDL feature branch, repair TYPE_CHECKING imports broken by the expression namespace split, register the pytest markers the suite already uses, and stop the pre-commit hook from blocking commits on revisions that predate `scripts/preflight.sh`. diff --git a/changelog.d/4.removed.md b/changelog.d/4.removed.md new file mode 100644 index 0000000..9709a24 --- /dev/null +++ b/changelog.d/4.removed.md @@ -0,0 +1 @@ +Remove the reserved `dialect_options` bag from data-type value objects and partition clauses, the legacy identity fields on column classes, the Firebird clause parameters inherited by the SQL Server create-table subclass, and the dead `format_cte` method. diff --git a/pyproject.toml b/pyproject.toml index 52f31e6..f23194c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,6 +91,9 @@ markers = [ "requires_capability: marks tests requiring specific backend capabilities", "requires_protocol: marks tests requiring specific backend protocol support", "requires_inner_join: marks tests requiring INNER JOIN support", + "feature: category label for feature-area tests; changes collection grouping only, not ordering", + "benchmark_relation: marks relation benchmarks; changes collection grouping only, not ordering", + ] [tool.coverage.run] diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh index e06f0cb..81b3dda 100644 --- a/scripts/install-git-hooks.sh +++ b/scripts/install-git-hooks.sh @@ -4,21 +4,42 @@ # 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 +# +# Re-run this after pulling, and re-run it from the repository root. To remove +# the hook, delete .git/hooks/pre-commit (or the core.hooksPath directory). 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 +# git ignores .git/hooks entirely once core.hooksPath is set, so a hook written +# there would never run while this script still reported success. +HOOKS_PATH="$(git config core.hooksPath || true)" +if [ -n "$HOOKS_PATH" ]; then + echo "install-git-hooks: core.hooksPath is set to '$HOOKS_PATH'." >&2 + echo "install-git-hooks: .git/hooks is ignored by git; writing the hook to" >&2 + echo " '$HOOKS_PATH/pre-commit' instead." >&2 + HOOK="$HOOKS_PATH/pre-commit" + mkdir -p "$HOOKS_PATH" +else + 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 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" +# +# The guard below matters on older revisions: this hook is a local, untracked +# artifact, so it survives a checkout of a commit that predates +# scripts/preflight.sh. Without the guard, exec fails with a bare +# "No such file or directory" and blocks the commit with nothing to act on. +PREFLIGHT="$(git rev-parse --show-toplevel)/scripts/preflight.sh" +[ -x "$PREFLIGHT" ] || exit 0 +exec "$PREFLIGHT" HOOK_BODY chmod +x "$HOOK" diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/__init__.py index afe338b..bf75230 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/__init__.py @@ -36,6 +36,12 @@ SQLServerTryConvertExpression, SQLServerContainsPredicate, SQLServerFreetextPredicate, + SQLServerTypeNullability, + SQLServerAliasTypeDefinition, + SQLServerTableTypeDefinition, + SQLServerClrTypeDefinition, + SQLServerDropTypeExpression, + SQLServerRenameTypeExpression, ) from .expression.types import ( @@ -54,7 +60,9 @@ SQLServerBackendMixin, SQLServerConcurrencyMixin, SQLServerTypeSupportMixin, + SQLServerTypeDDLMixin, ) +from .protocols import SQLServerUserDefinedTypeSupport from .schema import SQLServerSchemaDiffer @@ -96,6 +104,12 @@ "SQLServerTryConvertExpression", "SQLServerContainsPredicate", "SQLServerFreetextPredicate", + "SQLServerTypeNullability", + "SQLServerAliasTypeDefinition", + "SQLServerTableTypeDefinition", + "SQLServerClrTypeDefinition", + "SQLServerDropTypeExpression", + "SQLServerRenameTypeExpression", # DDL DataType subclasses "SQLServerNVarCharType", "SQLServerNCharType", @@ -110,6 +124,8 @@ "SQLServerBackendMixin", "SQLServerConcurrencyMixin", "SQLServerTypeSupportMixin", + "SQLServerTypeDDLMixin", + "SQLServerUserDefinedTypeSupport", # Schema differ "SQLServerSchemaDiffer", # Type compatibility 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 70cf1ae..3fdd5fd 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/alter_table_modifier.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/alter_table_modifier.py @@ -72,7 +72,7 @@ def format_alter_column_action(self, action: Any) -> Tuple[str, tuple]: standard SET/DROP subclauses. The ``operation`` value selects the variant: - - ``SET DATA TYPE`` (or ``dialect_options["data_type"]``) renders the + - ``SET DATA TYPE`` (or a typed ``data_type`` field) renders the T-SQL ``ALTER COLUMN col type [COLLATE ...] [NULL | NOT NULL] [SPARSE]`` form via ``format_alter_column_type_action``. - ``ADD MASKED`` renders dynamic data masking via @@ -81,20 +81,19 @@ def format_alter_column_action(self, action: Any) -> Tuple[str, tuple]: """ operation = action.operation op_str = operation.value if hasattr(operation, "value") else str(operation) - dialect_options = getattr(action, "dialect_options", None) or {} - if op_str == "ADD MASKED" or "masked_function" in dialect_options: + if op_str == "ADD MASKED" or getattr(action, "masked_function", None) is not None: return self.format_add_masked_action(action) - if op_str == "DROP MASKED" or dialect_options.get("drop_masked"): + if op_str == "DROP MASKED" or getattr(action, "drop_masked", False): return self.format_drop_masked_action(action) - if op_str == "SET DATA TYPE" or "data_type" in dialect_options: + if op_str == "SET DATA TYPE" or getattr(action, "data_type", None) is not None: return self.format_alter_column_type_action(action) raise UnsupportedFeatureError( self.name, f"ALTER COLUMN {op_str}", "SQL Server requires re-specifying the full column. Use " - "operation='SET DATA TYPE' with dialect_options to add " + "operation='SET DATA TYPE' with a SQLServerAlterColumn to add " "NULL/NOT NULL, COLLATE, SPARSE, or MASKED.", ) @@ -105,12 +104,11 @@ def format_alter_column_type_action(self, action: Any) -> Tuple[str, tuple]: ALTER COLUMN column data_type [COLLATE collation] [NULL | NOT NULL] [SPARSE] - The data type comes from ``dialect_options["data_type"]`` or the + The data type comes from the typed ``data_type`` field or the action's ``new_value``. ``not_null``, ``collate``, and ``sparse`` - are carried in ``dialect_options``. + are typed fields on ``SQLServerAlterColumn``. """ - dialect_options = getattr(action, "dialect_options", None) or {} - data_type = dialect_options.get("data_type") or action.new_value + data_type = getattr(action, "data_type", None) or action.new_value if data_type is None: raise ValueError("data type is required for ALTER COLUMN") @@ -120,15 +118,15 @@ def format_alter_column_type_action(self, action: Any) -> Tuple[str, tuple]: str(data_type), ] - collate = dialect_options.get("collate") + collate = getattr(action, "collate", None) if collate: parts.append(f"COLLATE {collate}") - not_null = dialect_options.get("not_null") + not_null = getattr(action, "not_null", None) if not_null is not None: parts.append("NOT NULL" if not_null else "NULL") - if dialect_options.get("sparse"): + if getattr(action, "sparse", False): if self.version < _SQL_SERVER_SPARSE_COLUMN_VERSION: # type: ignore[attr-defined] raise UnsupportedFeatureError( self.name, @@ -145,7 +143,7 @@ def format_add_masked_action(self, action: Any) -> Tuple[str, tuple]: SQL Syntax: ALTER COLUMN column ADD MASKED WITH (FUNCTION = 'mask_function') - The mask function is carried in ``dialect_options["masked_function"]`` + The mask function is carried in the typed ``masked_function`` field or the action's ``new_value``. """ if self.version < _SQL_SERVER_MASKED_COLUMN_VERSION: # type: ignore[attr-defined] @@ -155,8 +153,7 @@ def format_add_masked_action(self, action: Any) -> Tuple[str, tuple]: suggestion="requires SQL Server 2016+.", ) - dialect_options = getattr(action, "dialect_options", None) or {} - function = dialect_options.get("masked_function") or action.new_value + function = getattr(action, "masked_function", None) or action.new_value if function is None: raise ValueError("masked_function is required for ADD MASKED") diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/async_backend.py b/src/rhosocial/activerecord/backend/impl/sqlserver/async_backend.py index ead50ca..9843219 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/async_backend.py @@ -82,6 +82,7 @@ def __init__(self, **kwargs): trusted_connection: Use Windows Authentication (default: False) driver: ODBC driver name (default: ODBC Driver 17 for SQL Server) version: Expected SQL Server version tuple (default: (16, 0, 0)) + deployment_target: Deployment target label for capability gates """ if aioodbc is None: raise ImportError( @@ -90,13 +91,14 @@ def __init__(self, **kwargs): ) version = kwargs.pop('version', None) or (16, 0, 0) + deployment_target = kwargs.pop('deployment_target', None) connection_config = kwargs.get('connection_config') if connection_config is None: config_params = {} sqlserver_params = [ - 'host', 'port', 'database', 'username', 'password', + 'host', 'port', 'database', 'deployment_target', 'username', 'password', 'trusted_connection', 'driver', 'encrypt', 'trust_server_certificate', 'timeout', 'query_timeout', 'autocommit', 'charset', 'pool_size', 'pool_timeout', @@ -105,6 +107,8 @@ def __init__(self, **kwargs): for param in sqlserver_params: if param in kwargs: config_params[param] = kwargs[param] + if deployment_target is not None: + config_params["deployment_target"] = deployment_target if 'host' not in config_params: config_params['host'] = 'localhost' @@ -115,8 +119,17 @@ def __init__(self, **kwargs): super().__init__(**kwargs) + configured_target = getattr(self.config, "deployment_target", None) + if deployment_target is not None: + configured_target = deployment_target + self.deployment_target = ( + configured_target if configured_target is not None else "sqlserver" + ) self._version = version - self._dialect = SQLServerUnicodeDialect(version) + self._dialect = SQLServerUnicodeDialect( + version, + deployment_target=self.deployment_target, + ) self._connection = None self._transaction_manager = None self._default_suggestions_cache = None @@ -546,7 +559,10 @@ async def introspect_and_adapt(self) -> None: actual_version = await self.get_server_version() if self._version != actual_version: self._version = actual_version - self._dialect = SQLServerUnicodeDialect(actual_version) + self._dialect = SQLServerUnicodeDialect( + actual_version, + deployment_target=self.deployment_target, + ) self.log(logging.INFO, f"Adapted to SQL Server version {actual_version}") async def executescript(self, sql_script: str) -> None: diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/backend.py b/src/rhosocial/activerecord/backend/impl/sqlserver/backend.py index 1835a1d..b3cda41 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/backend.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/backend.py @@ -8,7 +8,6 @@ """ import logging -import re import time from contextvars import ContextVar from typing import Any, Dict, List, Optional, Tuple, Type @@ -53,26 +52,6 @@ def format_data_type_varchar(self, data_type): def format_data_type_char(self, data_type): return (f"NCHAR({data_type.length})" if data_type.length is not None else "NCHAR(1)"), () - def format_cte(self, name, query_sql, columns=None, recursive=False, materialized=None, dialect_options=None): - """Format a CTE, working around SQL Server's ``ORDER BY`` restriction. - - SQL Server rejects ``ORDER BY`` inside a CTE body unless ``TOP``, - ``OFFSET`` or ``FOR XML`` is also present (error 1033). The shared - testsuite builds CTE bodies with plain ``ORDER BY`` (valid on MySQL / - PostgreSQL), so inject ``TOP (100) PERCENT`` before the sort when no - ``TOP`` is already specified. - """ - if re.search(r"\bORDER\s+BY\b", query_sql, re.IGNORECASE) and not re.search( - r"\bTOP\s*\(?\d+\)?", query_sql, re.IGNORECASE - ): - match = re.match(r"(?is)^(\s*SELECT\s+)((?:DISTINCT\s+|ALL\s+)?)(.*)$", query_sql) - if match: - query_sql = f"{match.group(1)}TOP (100) PERCENT {match.group(2)}{match.group(3)}" - return super().format_cte( - name, query_sql, columns=columns, recursive=recursive, - materialized=materialized, dialect_options=dialect_options, - ) - class SQLServerBackend( SyncExplainBackendMixin, @@ -130,16 +109,18 @@ def __init__(self, **kwargs): trusted_connection: Use Windows Authentication (default: False) driver: ODBC driver name (default: ODBC Driver 17 for SQL Server) version: Expected SQL Server version tuple (default: (16, 0, 0)) + deployment_target: Deployment target label for capability gates **kwargs: Additional connection options """ version = kwargs.pop('version', None) or (16, 0, 0) + deployment_target = kwargs.pop('deployment_target', None) connection_config = kwargs.get('connection_config') if connection_config is None: config_params = {} sqlserver_params = [ - 'host', 'port', 'database', 'username', 'password', + 'host', 'port', 'database', 'deployment_target', 'username', 'password', 'trusted_connection', 'driver', 'encrypt', 'trust_server_certificate', 'timeout', 'query_timeout', 'autocommit', 'charset', 'pool_size', 'pool_timeout', @@ -148,6 +129,8 @@ def __init__(self, **kwargs): for param in sqlserver_params: if param in kwargs: config_params[param] = kwargs[param] + if deployment_target is not None: + config_params["deployment_target"] = deployment_target if 'host' not in config_params: config_params['host'] = 'localhost' @@ -158,8 +141,17 @@ def __init__(self, **kwargs): super().__init__(**kwargs) + configured_target = getattr(self.config, "deployment_target", None) + if deployment_target is not None: + configured_target = deployment_target + self.deployment_target = ( + configured_target if configured_target is not None else "sqlserver" + ) self._version = version - self._dialect = SQLServerUnicodeDialect(version) + self._dialect = SQLServerUnicodeDialect( + version, + deployment_target=self.deployment_target, + ) self._connection = None self._transaction_manager = None self._default_suggestions_cache = None @@ -532,7 +524,10 @@ def introspect_and_adapt(self) -> None: actual_version = self.get_server_version() if self._version != actual_version: self._version = actual_version - self._dialect = SQLServerUnicodeDialect(actual_version) + self._dialect = SQLServerUnicodeDialect( + actual_version, + deployment_target=self.deployment_target, + ) self.log(logging.INFO, f"Adapted to SQL Server version {actual_version}") def executescript(self, sql_script: str) -> None: diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/config.py b/src/rhosocial/activerecord/backend/impl/sqlserver/config.py index 56c90ad..895db27 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/config.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/config.py @@ -22,6 +22,7 @@ class SQLServerConnectionConfig(ConnectionConfig): host: Database server hostname or IP address port: Database server port (default: 1433) database: Database name + deployment_target: Deployment target label used for capability gates username: Username for SQL Server Authentication password: Password for SQL Server Authentication trusted_connection: Use Windows Authentication @@ -82,6 +83,7 @@ class SQLServerConnectionConfig(ConnectionConfig): pool_timeout: int = 30 options: Dict[str, Any] = field(default_factory=dict) + deployment_target: str = "sqlserver" def __post_init__(self): super().__post_init__() diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index 1bfc24a..f995172 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -44,6 +44,7 @@ TransactionControlSupport, SQLFunctionSupport, DDLTypeSupport, + UserDefinedTypeSupport, ) from .protocols import ( SQLServerTableSupport, @@ -63,6 +64,7 @@ SQLServerTryCastSupport, SQLServerIdentitySupport, SQLServerIndexedViewSupport, + SQLServerUserDefinedTypeSupport, ) from rhosocial.activerecord.backend.dialect.mixins import ( CollationMixin, @@ -96,6 +98,7 @@ DMLMixin, DDLColumnMixin, DDLTypeMixin, + UserDefinedTypeMixin, TransactionControlMixin, AutoIncrementMixin, @@ -114,6 +117,7 @@ from .mixins.graph import SQLServerGraphMixin from .mixins.columnstore import SQLServerColumnstoreIndexMixin from .mixins.memory_optimized import SQLServerMemoryOptimizedMixin +from .mixins.ddl_type import SQLServerTypeDDLMixin from .mixins.routine import SQLServerRoutineMixin from .mixins.trigger import SQLServerTriggerDdlMixin from .mixins.protocol_support import SQLServerProtocolSupportMixin @@ -134,6 +138,7 @@ from .mixins.dql import SQLServerDQLMixin from .mixins.dml import SQLServerDMLMixin from .mixins.ddl_view import SQLServerViewMixin +from .mixins.ddl_database import SQLServerDatabaseMixin from .mixins.schema import SQLServerSchemaMixin from .mixins.index import SQLServerIndexMixin from .mixins.generated_column import SQLServerGeneratedColumnMixin @@ -176,6 +181,7 @@ TableConstraint, IndexDefinition, CreateTableExpression, + CreateIndexExpression, DropTableExpression, AlterTableExpression, CreateSchemaExpression, @@ -222,8 +228,10 @@ class SQLServerDialect( SQLServerMemoryOptimizedMixin, # In-Memory OLTP table options (2014+) SQLServerRoutineMixin, # PROCEDURE / FUNCTION DDL (2005+) SQLServerTriggerDdlMixin, # TRIGGER DDL (2005+) + SQLServerTypeDDLMixin, DDLColumnMixin, DDLTypeMixin, + UserDefinedTypeMixin, SQLServerIdentifierMixin, SQLServerCollationMixin, CollationMixin, @@ -243,6 +251,7 @@ class SQLServerDialect( SQLServerDMLMixin, SQLServerViewMixin, SQLServerSchemaMixin, + SQLServerDatabaseMixin, SQLServerIndexMixin, SQLServerGeneratedColumnMixin, SQLServerSetOperationMixin, @@ -322,6 +331,8 @@ class SQLServerDialect( SQLFunctionSupport, # DataType Support Protocol DDLTypeSupport, + SQLServerUserDefinedTypeSupport, + UserDefinedTypeSupport, # SQL Server-specific protocols (marker classes for isinstance() checks; # implementations live in SQLServerProtocolSupportMixin / SQLServerSequenceMixin) SQLServerOutputSupport, @@ -428,7 +439,12 @@ def format_identifier(self, identifier: str, need_quote: bool = True) -> str: """ return SQLServerIdentifierMixin.format_identifier(self, identifier, need_quote) - def __init__(self, version: Optional[Tuple[int, int, int]] = None): + def __init__( + self, + version: Optional[Tuple[int, int, int]] = None, + *, + deployment_target: str = "sqlserver", + ) -> None: """ Initialize SQL Server dialect with specific version. @@ -437,9 +453,12 @@ def __init__(self, version: Optional[Tuple[int, int, int]] = None): If None, the dialect must be adapted via backend.introspect_and_adapt() before version-dependent features can be used. + deployment_target: Deployment target label used for CLR capability + gating; the default is on-premises SQL Server. """ super().__init__() self._reserved_words = SQLSERVER_RESERVED_WORDS + self.deployment_target = deployment_target if version is not None: self.version = version @@ -507,12 +526,42 @@ def format_create_table_statement( SQL Server has no CREATE TABLE ... LIKE (``supports_create_table_like`` stays ``False``), so the gated ``format_create_table_like_statement`` raises - ``UnsupportedFeatureError``. Temporary tables use a ``#``-prefixed + ``UnsupportedFeatureError``. Temporary tables use a ``#``-prefixed table name instead of the ``TEMPORARY`` keyword. """ - all_params: List[Any] = [] + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - dialect_options = getattr(expr, "dialect_options", {}) or {} + if getattr(expr, "tablespace", None): + raise UnsupportedFeatureError( + self.name, + "TABLESPACE", + "SQL Server does not support table tablespaces.", + ) + if getattr(expr, "inherits", None): + raise UnsupportedFeatureError( + self.name, + "table INHERITS", + "SQL Server does not support table inheritance.", + ) + if getattr(getattr(expr, "table_options", None), "comment", None): + # SQL Server has no inline table comment (its native + # mechanism is sp_addextendedproperty, not implemented here); a + # comment on the table options is never silently dropped. + raise UnsupportedFeatureError( + self.name, "TABLE COMMENT", + "SQL Server has no inline table comment; comments are " + "annotated through sp_addextendedproperty (not implemented).", + ) + if_not_exists_guard = "" + if expr.if_not_exists: + # SQL Server has no CREATE TABLE IF NOT EXISTS syntax; the + # idempotent form is the IF OBJECT_ID(...) IS NULL batch guard. + target = expr.table.name + if expr.table.schema_name: + target = f"{expr.table.schema_name}.{target}" + escaped_target = target.replace("'", "''") + if_not_exists_guard = f"IF OBJECT_ID(N'{escaped_target}', N'U') IS NULL\n" + all_params: List[Any] = [] parts = ["CREATE TABLE"] @@ -547,7 +596,7 @@ def format_create_table_statement( all_params.extend(idx_params) # SQL Graph edge constraints (CONNECTION) are table-level constraints. - edge_constraints = dialect_options.get("edge_constraints") + edge_constraints = getattr(expr, "edge_constraints", None) if edge_constraints: for edge_constraint in edge_constraints: ec_sql, ec_params = edge_constraint.to_sql() @@ -561,18 +610,14 @@ def format_create_table_statement( parts.append(partition_sql) all_params.extend(partition_params) + from .expression.table_options import SQLServerCreateTableOptions 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") + if isinstance(table_options, SQLServerCreateTableOptions) and table_options.memory_optimized: + durability = table_options.durability or "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") + graph_kind = getattr(expr, "graph_table_kind", None) if graph_kind is not None: from .expression.ddl.graph import ( SQLServerAsGraphTableExpression, @@ -585,13 +630,42 @@ def format_create_table_statement( parts.append(kind_sql) all_params.extend(kind_params) - return ' '.join(parts), tuple(all_params) + return if_not_exists_guard + ' '.join(parts), tuple(all_params) + + def format_identity_clause(self, expr) -> Tuple[str, tuple]: + """SQL Server renders identity as ``IDENTITY(seed, increment)``.""" + seed = expr.start if expr.start is not None else 1 + increment = expr.increment if expr.increment is not None else 1 + return f" IDENTITY({seed}, {increment})", () - def format_column_definition(self, col_def: "ColumnDefinition") -> Tuple[str, tuple]: - """Format a column definition for SQL Server.""" + def format_column_definition( + self, + col_def: "ColumnDefinition", + *, + memory_optimized: bool = False, + ) -> Tuple[str, tuple]: + """Format a column definition for SQL Server. + + Accepts both the generic ``ColumnDefinition`` and the SQL Server + ``SQLServerColumnDefinition``; the latter's SQL Server-only attributes + (``sparse`` / ``rowguidcol``) are rendered here. + """ from rhosocial.activerecord.backend.expression.statements import ( ColumnConstraintType, ) + from rhosocial.activerecord.backend.impl.sqlserver.expression.column import ( + SQLServerColumnDefinition, + ) + if getattr(col_def, "comment", None): + # SQL Server has no inline column comment (its native + # mechanism is sp_addextendedproperty, not implemented here); a + # comment on a column definition is never silently dropped. + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + raise UnsupportedFeatureError( + self.name, "COLUMN COMMENT", + "SQL Server has no inline column comment; comments are " + "annotated through sp_addextendedproperty (not implemented).", + ) 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) @@ -599,11 +673,13 @@ def format_column_definition(self, col_def: "ColumnDefinition") -> Tuple[str, tu constraint_parts = [] for constraint in col_def.constraints: if constraint.constraint_type == ColumnConstraintType.PRIMARY_KEY: - constraint_parts.append("PRIMARY KEY") + constraint_parts.append( + "PRIMARY KEY NONCLUSTERED" if memory_optimized else "PRIMARY KEY" + ) elif constraint.constraint_type == ColumnConstraintType.NOT_NULL: constraint_parts.append("NOT NULL") elif constraint.constraint_type == ColumnConstraintType.UNIQUE: - constraint_parts.append("UNIQUE") + constraint_parts.append("UNIQUE NONCLUSTERED" if memory_optimized else "UNIQUE") elif constraint.constraint_type == ColumnConstraintType.DEFAULT: if constraint.default_value is not None: from rhosocial.activerecord.backend.expression import bases @@ -618,6 +694,12 @@ def format_column_definition(self, col_def: "ColumnDefinition") -> Tuple[str, tu constraint_parts.append(f"DEFAULT {constraint.default_value}") elif constraint.constraint_type == ColumnConstraintType.NULL: constraint_parts.append("NULL") + elif constraint.constraint_type == ColumnConstraintType.CHECK: + if constraint.check_condition is None: + raise ValueError("CHECK constraint must have a check condition") + check_sql, check_params = constraint.check_condition.to_sql() + constraint_parts.append(f"CHECK ({check_sql})") + params.extend(check_params) if constraint.is_auto_increment: constraint_parts.append("IDENTITY(1,1)") @@ -625,14 +707,30 @@ def format_column_definition(self, col_def: "ColumnDefinition") -> Tuple[str, tu if constraint_parts: parts.append(' '.join(constraint_parts)) + attr_sql, attr_params = self.format_column_attributes(col_def) + if attr_sql: + parts.append(attr_sql.strip()) + params.extend(attr_params) + 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) + if isinstance(col_def, SQLServerColumnDefinition): + if col_def.rowguidcol: + parts.append("ROWGUIDCOL") + if col_def.sparse: + parts.append("SPARSE") + return ' '.join(parts), tuple(params) - def format_table_constraint(self, t_const: "TableConstraint") -> Tuple[str, tuple]: + def format_table_constraint( + self, + t_const: "TableConstraint", + *, + memory_optimized: bool = False, + ) -> Tuple[str, tuple]: """Format a table constraint for SQL Server.""" from rhosocial.activerecord.backend.expression.statements import ( TableConstraintType, @@ -649,11 +747,17 @@ def format_table_constraint(self, t_const: "TableConstraint") -> Tuple[str, tupl if t_const.constraint_type == TableConstraintType.PRIMARY_KEY: if t_const.columns: cols_str = ', '.join(self.format_identifier(c) for c in t_const.columns) - parts.append(f"PRIMARY KEY ({cols_str})") + if memory_optimized: + parts.append(f"PRIMARY KEY NONCLUSTERED ({cols_str})") + else: + parts.append(f"PRIMARY KEY ({cols_str})") elif t_const.constraint_type == TableConstraintType.UNIQUE: if t_const.columns: cols_str = ', '.join(self.format_identifier(c) for c in t_const.columns) - parts.append(f"UNIQUE ({cols_str})") + if memory_optimized: + parts.append(f"UNIQUE NONCLUSTERED ({cols_str})") + else: + parts.append(f"UNIQUE ({cols_str})") elif t_const.constraint_type == TableConstraintType.FOREIGN_KEY: if t_const.columns and t_const.foreign_key_table and t_const.foreign_key_columns: cols_str = ', '.join(self.format_identifier(c) for c in t_const.columns) @@ -678,6 +782,20 @@ def format_table_constraint(self, t_const: "TableConstraint") -> Tuple[str, tupl def format_inline_index(self, idx_def: "IndexDefinition") -> Tuple[str, tuple]: """Format an inline index definition for SQL Server.""" + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + for option, value in ( + ("if_not_exists", getattr(idx_def, "if_not_exists", False)), + ("tablespace", getattr(idx_def, "tablespace", None)), + ("include_columns", getattr(idx_def, "include_columns", None)), + ("partial_condition", getattr(idx_def, "partial_condition", None)), + ): + if value: + raise UnsupportedFeatureError( + self.name, f"inline index {option}", + "SQL Server's inline index definition does not render " + f"'{option}'; use a standalone CREATE INDEX statement.", + ) parts = [] if idx_def.unique: @@ -695,14 +813,13 @@ def format_inline_index(self, idx_def: "IndexDefinition") -> Tuple[str, tuple]: col_parts.append(self.format_identifier(str(col))) cols_str = ', '.join(col_parts) - idx_options = getattr(idx_def, "dialect_options", None) or {} - if idx_options.get("hash_index"): + if getattr(idx_def, "hash_index", False): self.check_feature_support( "supports_memory_optimized_tables", "NONCLUSTERED HASH index", "requires SQL Server 2014+ (memory-optimized tables).", ) - bucket_count = idx_options.get("bucket_count") + bucket_count = getattr(idx_def, "bucket_count", None) if bucket_count is None: raise ValueError("bucket_count is required for a NONCLUSTERED HASH index") parts.append(f"NONCLUSTERED HASH ({cols_str}) WITH (BUCKET_COUNT = {bucket_count})") @@ -714,9 +831,21 @@ def format_inline_index(self, idx_def: "IndexDefinition") -> Tuple[str, tuple]: def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[str, tuple]: """Format CREATE INDEX statement for SQL Server. - SQL Server doesn't support IF NOT EXISTS for CREATE INDEX. - We ignore the if_not_exists flag and generate standard CREATE INDEX. + SQL Server doesn't support IF NOT EXISTS for CREATE INDEX, and has no + index TABLESPACE concept; declared values are never silently dropped. """ + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + if getattr(expr, "if_not_exists", False): + raise UnsupportedFeatureError( + self.name, "CREATE INDEX IF NOT EXISTS", + "SQL Server has no CREATE INDEX IF NOT EXISTS syntax.", + ) + if getattr(expr, "tablespace", None): + raise UnsupportedFeatureError( + self.name, "index TABLESPACE", + "SQL Server has no index tablespace; use a filegroup instead.", + ) all_params = [] parts = ["CREATE"] @@ -765,6 +894,18 @@ def format_create_sequence_statement(self, expr: "CreateSequenceExpression") -> - CYCLE | NO CYCLE, CACHE, NO ORDER (ORDER not supported) - No IF NOT EXISTS support, no OWNED BY """ + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + if getattr(expr, "if_not_exists", False): + raise UnsupportedFeatureError( + self.name, "CREATE SEQUENCE IF NOT EXISTS", + "SQL Server has no CREATE SEQUENCE IF NOT EXISTS syntax.", + ) + if getattr(expr, "owned_by", None): + raise UnsupportedFeatureError( + self.name, "SEQUENCE OWNED BY", + "SQL Server sequences have no OWNED BY clause.", + ) parts = ["CREATE SEQUENCE"] parts.append(self.format_identifier(expr.sequence_name)) if expr.start is not None: @@ -839,10 +980,9 @@ def format_merge_statement(self, expr: "MergeExpression") -> Tuple[str, tuple]: on_sql, on_params = expr.on_condition.to_sql() all_params.extend(on_params) - dialect_options = getattr(expr, "dialect_options", {}) or {} - output_columns = dialect_options.get("output") - output_action = dialect_options.get("output_action", False) - holdlock = dialect_options.get("holdlock", False) + output_columns = getattr(expr, "output", None) + output_action = getattr(expr, "output_action", False) + holdlock = getattr(expr, "holdlock", False) parts = [ f"MERGE INTO {target_sql}", @@ -920,7 +1060,7 @@ def format_alter_table_statement(self, expr: "AlterTableExpression") -> Tuple[st - Single action per ALTER TABLE statement """ all_params: list = [] - parts = [f"ALTER TABLE {self.format_identifier(expr.table_name)}"] + table_sql = self.format_identifier(expr.table_name) action_parts = [] for action in expr.actions: @@ -928,10 +1068,15 @@ def format_alter_table_statement(self, expr: "AlterTableExpression") -> Tuple[st action_parts.append(action_part) all_params.extend(action_params) - if action_parts: - parts.append(" ".join(action_parts)) + if not action_parts: + return f"ALTER TABLE {table_sql}", () - return " ".join(parts), tuple(all_params) + if self.supports_multi_action_alter_table(): + return f"ALTER TABLE {table_sql} {', '.join(action_parts)}", tuple(all_params) + + # SQL Server requires one action per statement: emit a statement each. + stmts = [f"ALTER TABLE {table_sql} {part}" for part in action_parts] + return "; ".join(stmts), tuple(all_params) def format_function_call(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: """Format a function call, mapping MySQL/generic function names to diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/ddl/create_table.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/ddl/create_table.py index acf6c0d..3ed666f 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/ddl/create_table.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/ddl/create_table.py @@ -83,7 +83,6 @@ columns=columns, indexes=indexes, if_not_exists=True, - dialect_options={}, ) sql, params = create_expr.to_sql() diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/insert/with_output.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/insert/with_output.py index 0ef3450..4470d48 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/insert/with_output.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/insert/with_output.py @@ -77,7 +77,6 @@ source=ValuesSource(dialect, [[Literal(dialect, 'Alice')]]), columns=['name'], returning=ReturningClause(dialect, [Column(dialect, 'id')]), - dialect_options={}, ) sql, params = insert_expr.to_sql() diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/__init__.py index 1c310bc..d2a3448 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/__init__.py @@ -27,6 +27,12 @@ SQLServerGraphPathAggregate, SQLServerForPathTable, ) +from .column import SQLServerColumnDefinition, SQLServerColumnOptions +from .alter_column import SQLServerAlterColumn +from .table_options import SQLServerCreateTableOptions +from .index import SQLServerIndexDefinition +from .create_table import SQLServerCreateTableExpression +from .dml import SQLServerMergeExpression, SQLServerSelectIntoExpression from .columnstore import SQLServerColumnstoreIndexExpression from .fulltext import ( SQLServerCreateFullTextCatalogExpression, @@ -40,6 +46,12 @@ SQLServerDropRoutineExpression, SQLServerCreateTriggerExpression, SQLServerDropTriggerExpression, + SQLServerTypeNullability, + SQLServerAliasTypeDefinition, + SQLServerTableTypeDefinition, + SQLServerClrTypeDefinition, + SQLServerDropTypeExpression, + SQLServerRenameTypeExpression, ) from .functions import ( SQLServerTryCastExpression, @@ -59,6 +71,8 @@ "SQLServerOpenJsonExpression", "OpenJsonColumn", "SQLServerNextValueForExpression", + "SQLServerMergeExpression", + "SQLServerSelectIntoExpression", "SQLServerOptionHintClause", "recompile_hint", "maxdop_hint", @@ -77,6 +91,12 @@ "SQLServerGraphPathAggregate", "SQLServerForPathTable", "SQLServerUnpivotExpression", + "SQLServerCreateTableOptions", + "SQLServerColumnDefinition", + "SQLServerColumnOptions", + "SQLServerAlterColumn", + "SQLServerIndexDefinition", + "SQLServerCreateTableExpression", "SQLServerColumnstoreIndexExpression", "SQLServerCreateFullTextCatalogExpression", "SQLServerDropFullTextCatalogExpression", @@ -87,6 +107,12 @@ "SQLServerDropRoutineExpression", "SQLServerCreateTriggerExpression", "SQLServerDropTriggerExpression", + "SQLServerTypeNullability", + "SQLServerAliasTypeDefinition", + "SQLServerTableTypeDefinition", + "SQLServerClrTypeDefinition", + "SQLServerDropTypeExpression", + "SQLServerRenameTypeExpression", "SQLServerTryCastExpression", "SQLServerTryConvertExpression", "SQLServerContainsPredicate", diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/alter_column.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/alter_column.py new file mode 100644 index 0000000..8c1b0a5 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/alter_column.py @@ -0,0 +1,69 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/expression/alter_column.py +"""SQL Server-specific ALTER TABLE column actions. + +SQL Server re-specifies the full column rather than applying the standard +``SET``/``DROP`` subclauses, so ``ALTER COLUMN`` carries attributes with no +generic equivalent: + +* ``data_type`` — the new type for ``ALTER COLUMN col ``. +* ``collate`` — the ``COLLATE `` clause. +* ``not_null`` — ``NULL`` / ``NOT NULL`` (``None`` = omit). +* ``sparse`` — the ``SPARSE`` storage clause. +* ``masked_function`` / ``drop_masked`` — dynamic data masking. + +They live on ``SQLServerAlterColumn`` (deriving the generic ``AlterColumn``) +and are rendered by the SQL Server ``format_alter_column_action`` override. +SQL Server does **not** share them with any other backend. +""" + +from typing import Any, Optional, Union, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.statements import ( + AlterColumn, + ColumnAlterOperation, +) + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "SQLServerAlterColumn", +] + + +class SQLServerAlterColumn(AlterColumn): + """A SQL Server ``ALTER COLUMN`` action extending the generic one. + + Adds the SQL Server-only ``data_type``, ``collate``, ``not_null``, + ``sparse``, ``masked_function`` and ``drop_masked`` fields. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + column_name: str, + operation: Union[ColumnAlterOperation, str] = "SET DATA TYPE", + *, + new_value: Any = None, + cascade: bool = False, + data_type: Optional[str] = None, + collate: Optional[str] = None, + not_null: Optional[bool] = None, + sparse: bool = False, + masked_function: Optional[str] = None, + drop_masked: bool = False, + ): + super().__init__( + dialect, + column_name, + operation, + new_value=new_value, + cascade=cascade, + ) + self.data_type = data_type + self.collate = collate + self.not_null = not_null + self.sparse = sparse + self.masked_function = masked_function + self.drop_masked = drop_masked diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/column.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/column.py new file mode 100644 index 0000000..d1f7944 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/column.py @@ -0,0 +1,88 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/expression/column.py +"""SQL Server-specific column definition expressions. + +SQL Server extends the standard column definition with per-column attributes +that have no generic equivalent: + +* ``SPARSE`` — optimized storage for columns with many NULLs. +* ``ROWGUIDCOL`` — marks a ``uniqueidentifier`` column as the row GUID column. + +These live on ``SQLServerColumnDefinition`` (deriving the generic +``ColumnDefinition``) and are rendered by the SQL Server +``format_column_definition`` override. They are declared through +``SQLServerColumnOptions`` (deriving the generic ``ColumnOptions``). +""" + +from typing import Optional, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition +from rhosocial.activerecord.base import ColumnOptions + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "SQLServerColumnDefinition", + "SQLServerColumnOptions", +] + + +class SQLServerColumnDefinition(ColumnDefinition): + """A SQL Server column definition extending the generic one. + + Adds the SQL Server-only ``sparse`` and ``rowguidcol`` attributes. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + data_type, + constraints=None, + comment: Optional[str] = None, + generated_expression=None, + attributes=None, + *, + sparse: Optional[bool] = None, + rowguidcol: Optional[bool] = None, + ): + super().__init__( + dialect, + name, + data_type, + constraints=constraints, + comment=comment, + generated_expression=generated_expression, + attributes=attributes, + ) + self.sparse = sparse + self.rowguidcol = rowguidcol + + +class SQLServerColumnOptions(ColumnOptions): + """SQL Server per-column options declaration.""" + + def __init__( + self, + *, + sparse: Optional[bool] = None, + rowguidcol: Optional[bool] = None, + ): + super().__init__() + self.sparse = sparse + self.rowguidcol = rowguidcol + + def column_definition_class(self): + """Build a ``SQLServerColumnDefinition`` for these options.""" + return SQLServerColumnDefinition + + def apply_to(self, column) -> None: + """Transfer the SQL Server-only fields onto the column definition.""" + if not isinstance(column, SQLServerColumnDefinition): + raise TypeError( + "SQLServerColumnOptions.apply_to requires a SQLServerColumnDefinition, " + f"got {type(column).__name__}" + ) + column.sparse = self.sparse + column.rowguidcol = self.rowguidcol diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/create_table.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/create_table.py new file mode 100644 index 0000000..d685255 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/create_table.py @@ -0,0 +1,73 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/expression/create_table.py +"""SQL Server-specific CREATE TABLE expression. + +SQL Server adds table-level graph features with no generic equivalent: + +* ``graph_table_kind`` — ``AS NODE`` / ``AS EDGE`` (SQL Server 2017+). +* ``edge_constraints`` — ``CONSTRAINT CONNECTION (...)`` (SQL Server + 2019+). + +They live on ``SQLServerCreateTableExpression`` (deriving the generic +``CreateTableExpression``) and are rendered by the SQL Server +``format_create_table_statement`` override. +""" + +from typing import Any, List, Optional, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + ColumnDefinition, + CreateTableExpression, + CreateTableOptions, + IndexDefinition, + StorageOptionsExpression, + TableConstraint, +) + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + from rhosocial.activerecord.backend.expression.statements.ddl_partition import ( + PartitionClause, + ) + + +class SQLServerCreateTableExpression(CreateTableExpression): + """A SQL Server CREATE TABLE statement extending the generic one. + + Adds the SQL Server-only ``graph_table_kind`` / ``edge_constraints`` table + features. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + table: Any, + columns: List[ColumnDefinition], + indexes: Optional[List[IndexDefinition]] = None, + table_constraints: Optional[List[TableConstraint]] = None, + temporary: bool = False, + if_not_exists: bool = False, + inherits: Optional[List[str]] = None, + tablespace: Optional[str] = None, + storage_options: Optional["StorageOptionsExpression"] = None, + *, + partition: Optional["PartitionClause"] = None, + table_options: Optional["CreateTableOptions"] = None, + graph_table_kind: Any = None, + edge_constraints: Optional[List[Any]] = None, + ): + super().__init__( + dialect, + table, + columns, + indexes=indexes, + table_constraints=table_constraints, + temporary=temporary, + if_not_exists=if_not_exists, + inherits=inherits, + tablespace=tablespace, + storage_options=storage_options, + partition=partition, + table_options=table_options, + ) + self.graph_table_kind = graph_table_kind + self.edge_constraints = edge_constraints or [] 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 a7c64b7..28e80ad 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/__init__.py @@ -15,6 +15,14 @@ SQLServerAsGraphTableExpression, SQLServerEdgeConstraint, ) +from .type import ( + SQLServerTypeNullability, + SQLServerAliasTypeDefinition, + SQLServerTableTypeDefinition, + SQLServerClrTypeDefinition, + SQLServerDropTypeExpression, + SQLServerRenameTypeExpression, +) __all__ = [ "SQLServerCreateProcedureExpression", @@ -25,4 +33,10 @@ "SQLServerGraphTableKind", "SQLServerAsGraphTableExpression", "SQLServerEdgeConstraint", + "SQLServerTypeNullability", + "SQLServerAliasTypeDefinition", + "SQLServerTableTypeDefinition", + "SQLServerClrTypeDefinition", + "SQLServerDropTypeExpression", + "SQLServerRenameTypeExpression", ] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/type.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/type.py new file mode 100644 index 0000000..1b2e446 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/type.py @@ -0,0 +1,226 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/type.py +"""SQL Server schema-level user-defined TYPE expressions.""" + +from __future__ import annotations + +from enum import Enum +from typing import Any, Dict, Optional, Sequence, Union, cast + +from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.serialization import ExpressionRegistry +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + ColumnDefinition, + IndexDefinition, + TableConstraint, +) +from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + DropTypeExpression, + TypeDefinition, +) +from rhosocial.activerecord.backend.expression.types import DataType +from ..column import SQLServerColumnDefinition +from ..index import SQLServerIndexDefinition + + +class SQLServerTypeNullability(Enum): + """Nullability declaration accepted by a SQL Server alias type.""" + + NULL = "NULL" + NULLABLE = "NULL" + NOT_NULL = "NOT NULL" + + +def _validate_name(value: str, field_name: str) -> None: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field_name} must be a non-empty string") + + +def _validate_dotted_name(value: str, field_name: str) -> None: + _validate_name(value, field_name) + if any(not part.strip() for part in value.split(".")): + raise ValueError(f"{field_name} must contain non-empty identifier segments") + + +def _normalise_nullability(value: Any) -> Optional[bool]: + if value is None or isinstance(value, bool): + return value + if isinstance(value, Enum): + value = value.value + if not isinstance(value, str): + raise TypeError("nullability must be a bool, string, or nullability enum") + normalized = " ".join(value.strip().upper().split()) + if normalized in {"NULL", "NULLABLE"}: + return False + if normalized in {"NOT NULL", "NOT_NULL"}: + return True + if normalized == "UNSPECIFIED": + return None + raise ValueError("nullability must be NULL, NOT NULL, or unspecified") + + +def _require_data_type(value: DataType, field_name: str) -> None: + if not isinstance(value, DataType): + raise TypeError( + f"{field_name} must be a DataType instance, got {type(value).__name__}" + ) + + +class SQLServerAliasTypeDefinition(TypeDefinition): + """SQL Server alias TYPE definition.""" + + definition_kind = "alias" + + def __init__( + self, + dialect: Any, + base_type: DataType, + nullability: Optional[Union[bool, str, SQLServerTypeNullability]] = None, + ) -> None: + super().__init__(dialect) + _require_data_type(base_type, "base_type") + self.base_type = base_type + self.nullability = _normalise_nullability(nullability) + + +class SQLServerTableTypeDefinition(TypeDefinition): + """SQL Server user-defined table TYPE definition.""" + + definition_kind = "table" + + def __init__( + self, + dialect: Any, + columns: Sequence[ColumnDefinition], + constraints: Optional[Sequence[TableConstraint]] = None, + indexes: Optional[Sequence[IndexDefinition]] = None, + memory_optimized: bool = False, + *, + table_constraints: Optional[Sequence[TableConstraint]] = None, + ) -> None: + super().__init__(dialect) + if constraints is not None and table_constraints is not None: + raise ValueError("constraints and table_constraints are mutually exclusive") + column_list = list(columns or []) + if not column_list: + raise ValueError("table TYPE requires at least one column") + constraint_list = list( + constraints if constraints is not None else (table_constraints or []) + ) + index_list = list(indexes or []) + + if any(not isinstance(column, ColumnDefinition) for column in column_list): + raise TypeError("columns must contain ColumnDefinition instances") + if any(not isinstance(constraint, TableConstraint) for constraint in constraint_list): + raise TypeError("constraints must contain TableConstraint instances") + if any(not isinstance(index, IndexDefinition) for index in index_list): + raise TypeError("indexes must contain IndexDefinition instances") + if not isinstance(memory_optimized, bool): + raise TypeError("memory_optimized must be a bool") + self.columns = column_list + self.constraints = constraint_list + self.table_constraints = constraint_list + self.indexes = index_list + self.memory_optimized = memory_optimized + + def get_params(self) -> Dict[str, Any]: + params = cast(Dict[str, Any], super().get_params()) + params.pop("table_constraints", None) + params["constraints"] = self.constraints + return params + + +class SQLServerClrTypeDefinition(TypeDefinition): + """SQL Server CLR user-defined TYPE definition.""" + + definition_kind = "clr" + + def __init__( + self, + dialect: Any, + assembly_name: str, + class_name: Optional[str] = None, + ) -> None: + super().__init__(dialect) + _validate_dotted_name(assembly_name, "assembly_name") + if class_name is not None: + _validate_dotted_name(class_name, "class_name") + self.assembly_name = assembly_name + self.class_name = class_name + + +class SQLServerDropTypeExpression(DropTypeExpression): + """SQL Server DROP TYPE expression with explicit unsupported behavior flags.""" + + def __init__( + self, + dialect: Any, + type_name: str, + *, + schema_name: Optional[str] = None, + if_exists: bool = False, + cascade: bool = False, + restrict: bool = False, + ) -> None: + if not isinstance(cascade, bool) or not isinstance(restrict, bool): + raise TypeError("cascade and restrict must be bools") + if cascade and restrict: + raise ValueError("CASCADE and RESTRICT are mutually exclusive") + super().__init__( + dialect, + type_name, + schema_name=schema_name, + if_exists=if_exists, + ) + self.cascade = cascade + self.restrict = restrict + + +class SQLServerRenameTypeExpression(BaseExpression): + """SQL Server TYPE rename statement using ``sp_rename``.""" + + @property + def format_method(self) -> str: + return "format_rename_type_statement" + + def __init__( + self, + dialect: Any, + type_name: str, + new_name: str, + *, + schema_name: Optional[str] = None, + ) -> None: + super().__init__(dialect) + _validate_name(type_name, "type_name") + _validate_name(new_name, "new_name") + if "." in new_name: + raise ValueError("new_name must be a one-part identifier") + if schema_name is not None: + _validate_dotted_name(schema_name, "schema_name") + self.type_name = type_name + self.new_name = new_name + self.schema_name = schema_name + + +_SQLSERVER_TYPE_EXPRESSIONS = ( + SQLServerAliasTypeDefinition, + SQLServerTableTypeDefinition, + SQLServerClrTypeDefinition, + SQLServerDropTypeExpression, + SQLServerRenameTypeExpression, + SQLServerColumnDefinition, + SQLServerIndexDefinition, +) + +for _expression_class in _SQLSERVER_TYPE_EXPRESSIONS: + ExpressionRegistry.register(_expression_class) + + +__all__ = [ + "SQLServerTypeNullability", + "SQLServerAliasTypeDefinition", + "SQLServerTableTypeDefinition", + "SQLServerClrTypeDefinition", + "SQLServerDropTypeExpression", + "SQLServerRenameTypeExpression", +] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/dml.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/dml.py new file mode 100644 index 0000000..43a627b --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/dml.py @@ -0,0 +1,94 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/expression/dml.py +"""SQL Server-specific DML expression classes.""" + +from typing import TYPE_CHECKING, List, Optional + +from rhosocial.activerecord.backend.expression.statements import ( + MergeExpression, + QueryExpression, +) + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + from rhosocial.activerecord.backend.expression.bases import BaseExpression + + +class SQLServerMergeExpression(MergeExpression): + """A SQL Server MERGE statement extending the generic one. + + SQL Server adds the ``WITH (HOLDLOCK)`` table hint and the ``OUTPUT`` + clause whose columns/action have no generic equivalent. These live here as + typed fields and are rendered by the SQL Server ``format_merge_statement`` + override. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + target_table, + source, + on_condition, + when_matched=None, + when_not_matched=None, + when_not_matched_by_source=None, + *, + output: Optional[List] = None, + output_action: bool = False, + holdlock: bool = False, + ): + super().__init__( + dialect, + target_table=target_table, + source=source, + on_condition=on_condition, + when_matched=when_matched, + when_not_matched=when_not_matched, + when_not_matched_by_source=when_not_matched_by_source, + ) + self.output = output + self.output_action = output_action + self.holdlock = holdlock + + +class SQLServerSelectIntoExpression(QueryExpression): + """A SQL Server ``SELECT ... INTO `` query. + + SQL Server's ``SELECT INTO`` creates a new table from a query result. The + target table has no generic equivalent and lives here as a typed field; the + SQL Server ``format_select_into_statement`` reads it. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + select: List["BaseExpression"], + from_=None, + where=None, + group_by_having=None, + order_by=None, + qualify=None, + limit_offset=None, + for_update=None, + select_modifier=None, + *, + select_into_table: Optional[str] = None, + ): + super().__init__( + dialect, + select=select, + from_=from_, + where=where, + group_by_having=group_by_having, + order_by=order_by, + qualify=qualify, + limit_offset=limit_offset, + for_update=for_update, + select_modifier=select_modifier, + ) + self.select_into_table = select_into_table + + +__all__ = [ + "SQLServerMergeExpression", + "SQLServerSelectIntoExpression", +] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/index.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/index.py new file mode 100644 index 0000000..1567358 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/index.py @@ -0,0 +1,45 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/expression/index.py +"""SQL Server-specific index definition. + +SQL Server memory-optimized tables use ``NONCLUSTERED HASH`` indexes with a +``BUCKET_COUNT``, which has no generic equivalent. It lives on +``SQLServerIndexDefinition`` (deriving the generic ``IndexDefinition``) and is +rendered inline by the SQL Server ``format_index_definition`` override. +""" + +from typing import Any, Optional + +from rhosocial.activerecord.backend.expression.statements.ddl_table import IndexDefinition + + +class SQLServerIndexDefinition(IndexDefinition): + """A SQL Server index definition extending the generic one. + + Adds the SQL Server-only ``hash_index`` / ``bucket_count`` options for + memory-optimized (in-memory) tables. + """ + + def __init__( + self, + dialect: Any, + name: str, + columns: list, + *, + unique: bool = False, + type: Optional[str] = None, + partial_condition: Any = None, + include_columns: Optional[list] = None, + hash_index: bool = False, + bucket_count: Optional[int] = None, + ): + super().__init__( + dialect, + name=name, + columns=columns, + unique=unique, + type=type, + partial_condition=partial_condition, + include_columns=include_columns, + ) + self.hash_index = hash_index + self.bucket_count = bucket_count diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/partition.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/partition.py index c1b5c4c..c9edb62 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/partition.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/partition.py @@ -13,7 +13,7 @@ """ from enum import Enum -from typing import Any, Dict, List, Optional, Sequence, TYPE_CHECKING +from typing import Any, Optional, Sequence, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLQueryAndParams from rhosocial.activerecord.backend.expression.statements import PartitionClause, PartitionStrategy @@ -44,13 +44,11 @@ def __init__( partition_scheme: str, *, range_direction: SQLServerPartitionRangeDirection = SQLServerPartitionRangeDirection.RIGHT, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__( dialect, PartitionStrategy.RANGE, keys, - dialect_options=dialect_options, ) self.partition_scheme = partition_scheme self.range_direction = range_direction @@ -76,14 +74,12 @@ def __init__( boundary_values: Sequence[Any], *, range_direction: SQLServerPartitionRangeDirection = SQLServerPartitionRangeDirection.RIGHT, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.function_name = function_name self.data_type = data_type self.boundary_values = list(boundary_values) self.range_direction = range_direction - self.dialect_options = dict(dialect_options or {}) def to_sql(self) -> SQLQueryAndParams: return self.dialect.format_sqlserver_partition_function(self) @@ -107,7 +103,6 @@ def __init__( filegroups: Sequence[str], *, all_filegroup: Optional[str] = None, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.scheme_name = scheme_name @@ -116,7 +111,6 @@ def __init__( if all_filegroup and filegroups: raise ValueError("all_filegroup and filegroups are mutually exclusive") self.all_filegroup = all_filegroup - self.dialect_options = dict(dialect_options or {}) def to_sql(self) -> SQLQueryAndParams: return self.dialect.format_sqlserver_partition_scheme(self) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/table_options.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/table_options.py new file mode 100644 index 0000000..8e4f962 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/table_options.py @@ -0,0 +1,45 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/expression/table_options.py +"""SQL Server-specific CREATE TABLE options. + +SQL Server adds table-level options that have no generic equivalent: + +* ``MEMORY_OPTIMIZED=ON`` — memory-optimized (in-memory) table. +* ``DURABILITY=SCHEMA_ONLY|SCHEMA_AND_DATA`` — durability of a + memory-optimized table. + +These live on ``SQLServerCreateTableOptions`` (deriving the generic +``CreateTableOptions``) and are rendered by the SQL Server +``format_create_table_statement`` override. +""" + +from typing import Optional, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.statements import CreateTableOptions + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "SQLServerCreateTableOptions", +] + + +class SQLServerCreateTableOptions(CreateTableOptions): + """A SQL Server CREATE TABLE options declaration extending the generic one. + + Adds the SQL Server-only ``memory_optimized`` / ``durability`` options. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + *, + or_replace: bool = False, + comment: Optional[str] = None, + memory_optimized: bool = False, + durability: Optional[str] = None, + ): + super().__init__(dialect, or_replace=or_replace, comment=comment) + self.memory_optimized = memory_optimized + self.durability = durability diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/types.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/types.py index d47d167..9403575 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 Any, Dict, Optional, Set +from typing import Optional, Set from rhosocial.activerecord.backend.expression.types import ( VarCharType, @@ -23,9 +23,8 @@ class SQLServerNVarCharType(VarCharType): name = "sqlserver_nvarchar" - 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) + def __init__(self, dialect=None, length: Optional[int] = None): + super().__init__(dialect, length=length) @classmethod def synonyms(cls) -> Set[str]: @@ -37,9 +36,8 @@ class SQLServerNCharType(CharType): name = "sqlserver_nchar" - 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) + def __init__(self, dialect=None, length: Optional[int] = None): + super().__init__(dialect, length=length) @classmethod def synonyms(cls) -> Set[str]: @@ -51,8 +49,8 @@ class SQLServerNVarCharMaxType(VarCharType): name = "sqlserver_nvarchar_max" - def __init__(self, dialect=None, dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, length=None, dialect_options=dialect_options) + def __init__(self, dialect=None): + super().__init__(dialect, length=None) @classmethod def synonyms(cls) -> Set[str]: @@ -66,9 +64,8 @@ class SQLServerVarBinaryType(BlobType): length: Optional[int] = None - def __init__(self, dialect=None, length: Optional[int] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, dialect_options=dialect_options) + def __init__(self, dialect=None, length: Optional[int] = None): + super().__init__(dialect) self.length = length def _type_params(self) -> tuple: @@ -84,8 +81,8 @@ class SQLServerVarBinaryMaxType(BlobType): name = "sqlserver_varbinary_max" - def __init__(self, dialect=None, dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, dialect_options=dialect_options) + def __init__(self, dialect=None): + super().__init__(dialect) @classmethod def synonyms(cls) -> Set[str]: @@ -97,8 +94,8 @@ class SQLServerXmlType(VarCharType): name = "sqlserver_xml" - def __init__(self, dialect=None, dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, length=None, dialect_options=dialect_options) + def __init__(self, dialect=None): + super().__init__(dialect, length=None) @classmethod def synonyms(cls) -> Set[str]: @@ -110,8 +107,8 @@ class SQLServerTinyIntType(IntegerType): name = "sqlserver_tinyint" - def __init__(self, dialect=None, dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, dialect_options=dialect_options) + def __init__(self, dialect=None): + super().__init__(dialect) @classmethod def synonyms(cls) -> Set[str]: @@ -123,8 +120,8 @@ class SQLServerBitType(BooleanType): name = "sqlserver_bit" - def __init__(self, dialect=None, dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, dialect_options=dialect_options) + def __init__(self, dialect=None): + super().__init__(dialect) @classmethod def synonyms(cls) -> Set[str]: @@ -136,8 +133,8 @@ class SQLServerImageType(BlobType): name = "sqlserver_image" - def __init__(self, dialect=None, dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, dialect_options=dialect_options) + def __init__(self, dialect=None): + super().__init__(dialect) @classmethod def synonyms(cls) -> Set[str]: diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/functions/aggregate.py b/src/rhosocial/activerecord/backend/impl/sqlserver/functions/aggregate.py index e2ebe70..d2ff620 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/functions/aggregate.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/functions/aggregate.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase - from .dialect import SQLServerDialect + from ..dialect import SQLServerDialect def _convert_to_expression( diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/functions/bitwise.py b/src/rhosocial/activerecord/backend/impl/sqlserver/functions/bitwise.py index 1442407..dbb5dc3 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/functions/bitwise.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/functions/bitwise.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase - from .dialect import SQLServerDialect + from ..dialect import SQLServerDialect def _convert_to_expression( diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/functions/datetime.py b/src/rhosocial/activerecord/backend/impl/sqlserver/functions/datetime.py index b0b0749..df0efe6 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/functions/datetime.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/functions/datetime.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase - from .dialect import SQLServerDialect + from ..dialect import SQLServerDialect def _convert_to_expression( diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/functions/json.py b/src/rhosocial/activerecord/backend/impl/sqlserver/functions/json.py index 7209fb8..0c31b42 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/functions/json.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/functions/json.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase - from .dialect import SQLServerDialect + from ..dialect import SQLServerDialect def _convert_to_expression( diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/functions/math.py b/src/rhosocial/activerecord/backend/impl/sqlserver/functions/math.py index 9a3d9cc..d58dc25 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/functions/math.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/functions/math.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase - from .dialect import SQLServerDialect + from ..dialect import SQLServerDialect def _convert_to_expression( diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/functions/spatial.py b/src/rhosocial/activerecord/backend/impl/sqlserver/functions/spatial.py index 2c88dd7..279417d 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/functions/spatial.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/functions/spatial.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase - from .dialect import SQLServerDialect + from ..dialect import SQLServerDialect def _convert_to_expression( diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/functions/string.py b/src/rhosocial/activerecord/backend/impl/sqlserver/functions/string.py index 81ae46b..a9f3922 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/functions/string.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/functions/string.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase - from .dialect import SQLServerDialect + from ..dialect import SQLServerDialect def _convert_to_expression( diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/functions/utility.py b/src/rhosocial/activerecord/backend/impl/sqlserver/functions/utility.py index c950468..9ac126e 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/functions/utility.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/functions/utility.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase - from .dialect import SQLServerDialect + from ..dialect import SQLServerDialect def _convert_to_expression( diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins.py index f1ba6ee..a78a5e7 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins.py @@ -5,10 +5,12 @@ SQLServerBackendMixin, SQLServerConcurrencyMixin, SQLServerTypeSupportMixin, + SQLServerTypeDDLMixin, ) __all__ = [ "SQLServerBackendMixin", "SQLServerConcurrencyMixin", "SQLServerTypeSupportMixin", -] \ No newline at end of file + "SQLServerTypeDDLMixin", +] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py index 2846ba4..55bfbef 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py @@ -28,6 +28,7 @@ from .dql import SQLServerDQLMixin from .dml import SQLServerDMLMixin from .ddl_view import SQLServerViewMixin +from .ddl_database import SQLServerDatabaseMixin from .schema import SQLServerSchemaMixin from .index import SQLServerIndexMixin from .generated_column import SQLServerGeneratedColumnMixin @@ -36,6 +37,7 @@ from .identifier import SQLServerIdentifierMixin from .transaction import SQLServerTransactionMixin from .function import SQLServerFunctionMixin +from .ddl_type import SQLServerTypeDDLMixin __all__ = [ "SQLServerTypeSupportMixin", @@ -68,6 +70,7 @@ "SQLServerDMLMixin", "SQLServerViewMixin", "SQLServerSchemaMixin", + "SQLServerDatabaseMixin", "SQLServerIndexMixin", "SQLServerGeneratedColumnMixin", "SQLServerSetOperationMixin", @@ -75,4 +78,5 @@ "SQLServerIdentifierMixin", "SQLServerTransactionMixin", "SQLServerFunctionMixin", + "SQLServerTypeDDLMixin", ] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/backend_mixin.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/backend_mixin.py index 7a1acd6..3fa5c9b 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/backend_mixin.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/backend_mixin.py @@ -352,7 +352,17 @@ def dialect(self): from ..dialect import SQLServerDialect if self._dialect is None: - self._dialect = SQLServerDialect(self._version) + deployment_target = getattr(self, "deployment_target", None) + if deployment_target is None: + deployment_target = getattr( + getattr(self, "config", None), + "deployment_target", + "sqlserver", + ) + self._dialect = SQLServerDialect( + self._version, + deployment_target=deployment_target, + ) return self._dialect @dialect.setter diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_type.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_type.py new file mode 100644 index 0000000..7ffe62e --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_type.py @@ -0,0 +1,691 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_type.py +"""SQL Server schema-level user-defined TYPE capabilities and formatting.""" + +from __future__ import annotations + +from typing import Any, List, Optional, Tuple, Type, Union, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.base import SQLDialectBase +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.dialect.mixins.user_defined_type import UserDefinedTypeMixin +from rhosocial.activerecord.backend.expression.bases import ToSQLProtocol +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + ColumnConstraintType, + ColumnDefinition, + IndexDefinition, + TableConstraint, + TableConstraintType, +) +from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + AlterTypeExpression, + CreateTypeExpression, + DropTypeExpression, + TypeAlterAction, + TypeDefinition, +) +from .version_constants import ( + SQL_SERVER_2005, + SQL_SERVER_2008, + SQL_SERVER_2016, + SQL_SERVER_2017, + SQL_SERVER_2019, +) +from ..expression.ddl.type import ( + SQLServerAliasTypeDefinition, + SQLServerClrTypeDefinition, + SQLServerDropTypeExpression, + SQLServerRenameTypeExpression, + SQLServerTableTypeDefinition, +) + + +class SQLServerTypeDDLMixin(UserDefinedTypeMixin): + """SQL Server user-defined TYPE DDL support.""" + + if TYPE_CHECKING: + name: str + _version: Optional[Tuple[int, int, int]] + deployment_target: str + + def format_identifier(self, identifier: str, need_quote: bool = True) -> str: + ... + + def format_column_definition( + self, + column: ColumnDefinition, + *, + memory_optimized: bool = False, + ) -> Tuple[str, tuple]: + ... + + def format_table_constraint( + self, + constraint: TableConstraint, + *, + memory_optimized: bool = False, + ) -> Tuple[str, tuple]: + ... + + def supports_memory_optimized_tables(self) -> bool: + ... + + _SQLSERVER_TYPE_DEFINITIONS = ( + SQLServerAliasTypeDefinition, + SQLServerTableTypeDefinition, + SQLServerClrTypeDefinition, + ) + + def _version_at_least(self, minimum: Tuple[int, int, int]) -> bool: + version = getattr(self, "_version", None) + return version is not None and tuple(version) >= minimum + + def _is_sqlserver_deployment_target(self) -> bool: + target = getattr(self, "deployment_target", "sqlserver") + if target is None: + return False + target = getattr(target, "value", target) + normalized = "_".join(str(target).strip().lower().replace("-", " ").split()) + return normalized in { + "sqlserver", + "sql_server", + "microsoft_sql_server", + "sqlserver_linux", + "sql_server_linux", + "microsoft_sql_server_linux", + "azure_sql_managed_instance", + "azure_sql_mi", + "sql_managed_instance", + "on_premises", + "sqlserver_on_premises", + "on_premises_sql_server", + "box", + } + + def supports_type_objects(self) -> bool: + return self._version_at_least(SQL_SERVER_2005) + + def supports_create_type(self) -> bool: + return self.supports_type_objects() + + def supports_alter_type(self) -> bool: + return False + + def supports_drop_type(self) -> bool: + return self.supports_type_objects() + + def _definition_is_supported(self, definition_type: Type[TypeDefinition]) -> bool: + try: + if issubclass(definition_type, SQLServerAliasTypeDefinition): + return self.supports_type_objects() + if issubclass(definition_type, SQLServerTableTypeDefinition): + return self._version_at_least(SQL_SERVER_2008) + if issubclass(definition_type, SQLServerClrTypeDefinition): + return self.supports_type_objects() and self._is_sqlserver_deployment_target() + except TypeError: + return False + return False + + def supported_type_definitions(self) -> Tuple[Type[TypeDefinition], ...]: + return tuple( + definition + for definition in self._SQLSERVER_TYPE_DEFINITIONS + if self._definition_is_supported(definition) + ) + + def supports_type_definition( + self, + definition_type: Type[TypeDefinition], + ) -> bool: + try: + return any( + issubclass(definition_type, supported) + for supported in self.supported_type_definitions() + ) + except TypeError: + return False + + def supports_type_alter_action( + self, + action_type: Type[TypeAlterAction], + ) -> bool: + return False + + def supports_create_type_if_not_exists(self) -> bool: + return False + + def supports_create_type_or_replace(self) -> bool: + return False + + def supports_alter_type_if_exists(self) -> bool: + return False + + def supports_drop_type_if_exists(self) -> bool: + return self._version_at_least(SQL_SERVER_2016) + + def supports_multiple_type_alter_actions(self) -> bool: + return False + + def supports_drop_type_cascade(self) -> bool: + return False + + def supports_drop_type_restrict(self) -> bool: + return False + + def supports_clr_type_definition(self) -> bool: + return self.supports_type_objects() and self._is_sqlserver_deployment_target() + + def supports_clr_user_defined_type(self) -> bool: + return self.supports_clr_type_definition() + + def supports_memory_optimized_table_types(self) -> bool: + return bool(self.supports_memory_optimized_tables()) + + def supports_rename_type(self) -> bool: + return self._version_at_least(SQL_SERVER_2005) + + def _format_type_name( + self, + type_name: str, + schema_name: Optional[str] = None, + ) -> str: + if not isinstance(type_name, str) or not type_name.strip(): + raise ValueError("type_name must be a non-empty string") + parts: List[str] = [] + if schema_name is not None: + if not isinstance(schema_name, str): + raise TypeError("schema_name must be a string or None") + if not schema_name.strip(): + raise ValueError("schema_name must contain a non-empty identifier") + parts.append(self.format_identifier(schema_name)) + if not type_name.strip(): + raise ValueError("type_name must contain a non-empty identifier") + parts.append(self.format_identifier(type_name)) + return ".".join(parts) + + def _raw_type_name( + self, + type_name: str, + schema_name: Optional[str] = None, + ) -> str: + formatted = self._format_type_name(type_name, schema_name) + return formatted + + @staticmethod + def _format_nstring(value: str) -> str: + if not isinstance(value, str): + raise TypeError("sp_rename arguments must be strings") + return f"N'{value.replace(chr(39), chr(39) * 2)}'" + + def _format_table_type_column( + self, + column: ColumnDefinition, + memory_optimized: bool, + ) -> Tuple[str, tuple]: + if not isinstance(column, ColumnDefinition): + raise TypeError("table TYPE columns must be ColumnDefinition instances") + if getattr(column, "comment", None) is not None: + raise UnsupportedFeatureError( + self.name, + "TYPE table column comment", + "SQL Server table TYPE definitions do not support inline column comments.", + ) + if getattr(column, "generated_expression", None) is not None: + raise UnsupportedFeatureError( + self.name, + "TYPE table computed column", + "Use a SQL Server-specific computed-column expression for table TYPEs.", + ) + if getattr(column, "sparse", None) or getattr(column, "rowguidcol", None): + raise UnsupportedFeatureError( + self.name, + "TYPE table column storage attribute", + "SPARSE and ROWGUIDCOL are not supported in table TYPE definitions.", + ) + allowed = { + ColumnConstraintType.PRIMARY_KEY, + ColumnConstraintType.NOT_NULL, + ColumnConstraintType.NULL, + ColumnConstraintType.DEFAULT, + } + if not memory_optimized or self._version_at_least(SQL_SERVER_2016): + allowed.update({ + ColumnConstraintType.UNIQUE, + ColumnConstraintType.CHECK, + }) + for constraint in column.constraints: + if memory_optimized and constraint.constraint_type not in allowed: + if constraint.constraint_type in { + ColumnConstraintType.UNIQUE, + ColumnConstraintType.CHECK, + }: + raise UnsupportedFeatureError( + self.name, + f"memory-optimized TYPE table column constraint {constraint.constraint_type}", + "requires SQL Server 2016+.", + ) + raise UnsupportedFeatureError( + self.name, + f"memory-optimized TYPE table column constraint {constraint.constraint_type}", + "SQL Server memory-optimized table TYPEs allow only key constraints and DEFAULT.", + ) + if constraint.constraint_type not in allowed: + raise UnsupportedFeatureError( + self.name, + f"TYPE table column constraint {constraint.constraint_type}", + ) + if constraint.constraint_type == ColumnConstraintType.CHECK: + if constraint.check_condition is None: + raise ValueError("CHECK constraint must have a check condition") + if constraint.constraint_type == ColumnConstraintType.DEFAULT: + if constraint.default_value is None: + raise ValueError("DEFAULT constraint must have a default value") + return self.format_column_definition( + column, + memory_optimized=memory_optimized, + ) + + def _format_table_type_constraint( + self, + constraint: TableConstraint, + memory_optimized: bool, + ) -> Tuple[str, tuple]: + if not isinstance(constraint, TableConstraint): + raise TypeError("table TYPE constraints must be TableConstraint instances") + if memory_optimized: + if constraint.constraint_type in { + TableConstraintType.UNIQUE, + TableConstraintType.CHECK, + } and not self._version_at_least(SQL_SERVER_2016): + raise UnsupportedFeatureError( + self.name, + f"memory-optimized TYPE table constraint {constraint.constraint_type}", + "requires SQL Server 2016+.", + ) + if constraint.constraint_type not in { + TableConstraintType.PRIMARY_KEY, + TableConstraintType.UNIQUE, + TableConstraintType.CHECK, + }: + raise UnsupportedFeatureError( + self.name, + f"memory-optimized TYPE table constraint {constraint.constraint_type}", + "SQL Server memory-optimized table TYPEs allow only key constraints and CHECK.", + ) + supported_types = { + TableConstraintType.PRIMARY_KEY, + TableConstraintType.UNIQUE, + TableConstraintType.CHECK, + } + if constraint.constraint_type not in supported_types: + raise UnsupportedFeatureError( + self.name, + f"TYPE table constraint {constraint.constraint_type}", + ) + if constraint.constraint_type in { + TableConstraintType.PRIMARY_KEY, + TableConstraintType.UNIQUE, + }: + if constraint.check_condition is not None: + raise ValueError("key constraints cannot carry a check condition") + if constraint.foreign_key_table is not None or constraint.foreign_key_columns: + raise ValueError("key constraints cannot carry foreign key fields") + elif ( + constraint.columns + or constraint.foreign_key_table is not None + or constraint.foreign_key_columns + ): + raise ValueError("CHECK constraints cannot carry key or foreign key fields") + for field_name in ("deferrable", "initially_deferred", "validation", "enforced"): + if getattr(constraint, field_name, None) is not None: + raise UnsupportedFeatureError( + self.name, + f"TYPE table constraint option {field_name}", + ) + if constraint.constraint_type in { + TableConstraintType.PRIMARY_KEY, + TableConstraintType.UNIQUE, + } and not constraint.columns: + raise ValueError("table TYPE key constraint requires columns") + if constraint.constraint_type == TableConstraintType.CHECK: + if constraint.check_condition is None: + raise ValueError("table TYPE CHECK constraint requires a condition") + sql, params = self.format_table_constraint( + constraint, + memory_optimized=memory_optimized, + ) + if not sql: + raise ValueError("table TYPE constraint did not render SQL") + return sql, tuple(params) + + def _format_table_type_index( + self, + index: IndexDefinition, + memory_optimized: bool, + ) -> Tuple[str, tuple]: + if not isinstance(index, IndexDefinition): + raise TypeError("table TYPE indexes must be IndexDefinition instances") + if not isinstance(index.name, str) or not index.name.strip(): + raise ValueError("table TYPE index name must be a non-empty string") + if not index.columns: + raise ValueError("table TYPE index requires at least one column") + unsupported_values = ( + ("partial_condition", getattr(index, "partial_condition", None)), + ("if_not_exists", getattr(index, "if_not_exists", None)), + ("tablespace", getattr(index, "tablespace", None)), + ("if_exists", getattr(index, "if_exists", None)), + ("concurrent", getattr(index, "concurrent", None)), + ) + for field_name, value in unsupported_values: + if value: + raise UnsupportedFeatureError( + self.name, + f"TYPE table index option {field_name}", + ) + hash_index = bool(getattr(index, "hash_index", False)) + bucket_count = getattr(index, "bucket_count", None) + if bucket_count is not None: + if isinstance(bucket_count, bool) or not isinstance(bucket_count, int): + raise TypeError("bucket_count must be an integer") + if bucket_count <= 0 or bucket_count > 1_073_741_824: + raise ValueError("bucket_count must be between 1 and 1073741824") + if bucket_count is not None and not hash_index: + raise UnsupportedFeatureError( + self.name, + "TYPE table index bucket_count", + "bucket_count is valid only for a HASH index.", + ) + if hash_index: + if not memory_optimized: + raise UnsupportedFeatureError( + self.name, + "TYPE table HASH index", + "HASH indexes require a memory-optimized table TYPE.", + ) + if not self.supports_memory_optimized_table_types(): + raise UnsupportedFeatureError( + self.name, + "TYPE table HASH index", + "requires SQL Server 2014+.", + ) + if bucket_count is None: + raise ValueError("bucket_count is required for a HASH index") + index_type = getattr(index, "type", None) + if index_type is not None: + if not isinstance(index_type, str): + raise TypeError("index type must be a string or None") + index_type = index_type.upper() + if index_type not in {"CLUSTERED", "NONCLUSTERED"}: + raise UnsupportedFeatureError( + self.name, + f"TYPE table index type {index_type}", + ) + if memory_optimized and index_type == "CLUSTERED": + raise UnsupportedFeatureError( + self.name, + "TYPE table CLUSTERED index", + "memory-optimized table TYPE indexes cannot be clustered.", + ) + column_parts: List[str] = [] + params: List[Any] = [] + for column in index.columns: + if isinstance(column, ToSQLProtocol): + column_sql, column_params = column.to_sql() + column_parts.append(column_sql) + params.extend(column_params) + elif isinstance(column, str): + column_parts.append(self.format_identifier(column)) + else: + raise TypeError("table TYPE index columns must be strings or expressions") + parts: List[str] = [] + if index.unique: + parts.append("UNIQUE") + parts.extend(("INDEX", self.format_identifier(index.name))) + if hash_index: + parts.append("NONCLUSTERED HASH") + elif index_type: + parts.append(index_type) + elif memory_optimized: + parts.append("NONCLUSTERED") + parts.append(f"({', '.join(column_parts)})") + include_columns = getattr(index, "include_columns", None) + if memory_optimized and include_columns and not self._version_at_least(SQL_SERVER_2019): + raise UnsupportedFeatureError( + self.name, + "TYPE table index INCLUDE columns", + "requires SQL Server 2019+.", + ) + if hash_index and include_columns: + raise UnsupportedFeatureError( + self.name, + "TYPE HASH index INCLUDE columns", + "HASH indexes cannot use INCLUDE.", + ) + if include_columns: + included = [] + for column in include_columns: + if not isinstance(column, str): + raise TypeError("INCLUDE columns must be strings") + included.append(self.format_identifier(column)) + parts.append(f"INCLUDE ({', '.join(included)})") + if hash_index: + parts.append(f"WITH (BUCKET_COUNT = {bucket_count})") + return " ".join(parts), tuple(params) + + def _format_table_type_definition( + self, + expr: SQLServerTableTypeDefinition, + ) -> Tuple[str, tuple]: + if expr.memory_optimized and not self.supports_memory_optimized_table_types(): + raise UnsupportedFeatureError( + self.name, + "memory-optimized TYPE table", + "requires SQL Server 2014+.", + ) + parts: List[str] = [] + params: List[Any] = [] + for column in expr.columns: + column_sql, column_params = self._format_table_type_column( + column, + expr.memory_optimized, + ) + parts.append(column_sql) + params.extend(column_params) + for constraint in expr.constraints: + constraint_sql, constraint_params = self._format_table_type_constraint( + constraint, + expr.memory_optimized, + ) + parts.append(constraint_sql) + params.extend(constraint_params) + for index in expr.indexes: + index_sql, index_params = self._format_table_type_index( + index, + expr.memory_optimized, + ) + parts.append(index_sql) + params.extend(index_params) + if expr.memory_optimized and not self._version_at_least(SQL_SERVER_2017): + key_constraint_count = sum( + constraint.constraint_type + in { + ColumnConstraintType.PRIMARY_KEY, + ColumnConstraintType.UNIQUE, + } + for column in expr.columns + for constraint in column.constraints + ) + sum( + constraint.constraint_type + in { + TableConstraintType.PRIMARY_KEY, + TableConstraintType.UNIQUE, + } + for constraint in expr.constraints + ) + if len(expr.indexes) + key_constraint_count > 8: + raise UnsupportedFeatureError( + self.name, + "memory-optimized TYPE table index count", + "SQL Server 2014 and 2016 allow at most 8 indexes; use SQL Server 2017+.", + ) + if expr.memory_optimized: + has_key_constraint = any( + constraint.constraint_type + in { + ColumnConstraintType.PRIMARY_KEY, + ColumnConstraintType.UNIQUE, + } + for column in expr.columns + for constraint in column.constraints + ) or any( + constraint.constraint_type + in { + TableConstraintType.PRIMARY_KEY, + TableConstraintType.UNIQUE, + } + for constraint in expr.constraints + ) + if not has_key_constraint and not expr.indexes: + raise ValueError( + "memory-optimized table TYPE requires at least one index " + "or PRIMARY KEY/UNIQUE constraint" + ) + definition = f"AS TABLE ({', '.join(parts)})" + if expr.memory_optimized: + definition += " WITH (MEMORY_OPTIMIZED = ON)" + return definition, tuple(params) + + def format_create_type_statement( + self, + expr: CreateTypeExpression, + ) -> Tuple[str, tuple]: + if not isinstance(expr, CreateTypeExpression): + raise TypeError("expr must be a CreateTypeExpression") + if expr.if_not_exists and expr.or_replace: + raise ValueError("CREATE TYPE IF NOT EXISTS and OR REPLACE are mutually exclusive") + if not self.supports_type_objects() or not self.supports_create_type(): + raise UnsupportedFeatureError(self.name, "CREATE TYPE") + if expr.if_not_exists and not self.supports_create_type_if_not_exists(): + raise UnsupportedFeatureError(self.name, "CREATE TYPE IF NOT EXISTS") + if expr.or_replace and not self.supports_create_type_or_replace(): + raise UnsupportedFeatureError(self.name, "CREATE OR REPLACE TYPE") + if not isinstance(expr.definition, TypeDefinition): + raise TypeError("definition must be a TypeDefinition instance") + if not self.supports_type_definition(type(expr.definition)): + raise UnsupportedFeatureError( + self.name, + f"TYPE definition {expr.definition.definition_kind}", + ) + definition_sql, definition_params = expr.definition.to_sql() + if definition_params: + raise ValueError("CREATE TYPE must render without bind parameters") + name_sql = self._format_type_name(expr.type_name, expr.schema_name) + return f"CREATE TYPE {name_sql} {definition_sql}", () + + def format_alter_type_statement( + self, + expr: AlterTypeExpression, + ) -> Tuple[str, tuple]: + if not isinstance(expr, AlterTypeExpression): + raise TypeError("expr must be an AlterTypeExpression") + raise UnsupportedFeatureError( + self.name, + "ALTER TYPE", + "SQL Server has no ALTER TYPE; drop and recreate the TYPE instead.", + ) + + def format_drop_type_statement( + self, + expr: Union[DropTypeExpression, SQLServerDropTypeExpression], + ) -> Tuple[str, tuple]: + if not isinstance(expr, DropTypeExpression): + raise TypeError("expr must be a DropTypeExpression") + if not self.supports_type_objects() or not self.supports_drop_type(): + raise UnsupportedFeatureError(self.name, "DROP TYPE") + if expr.if_exists and not self.supports_drop_type_if_exists(): + raise UnsupportedFeatureError( + self.name, + "DROP TYPE IF EXISTS", + "requires SQL Server 2016+.", + ) + cascade = bool(getattr(expr, "cascade", False)) + restrict = bool(getattr(expr, "restrict", False)) + if cascade and restrict: + raise ValueError("CASCADE and RESTRICT are mutually exclusive") + if cascade: + raise UnsupportedFeatureError(self.name, "DROP TYPE CASCADE") + if restrict: + raise UnsupportedFeatureError(self.name, "DROP TYPE RESTRICT") + parts = ["DROP TYPE"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self._format_type_name(expr.type_name, expr.schema_name)) + return " ".join(parts), () + + def format_type_definition( + self, + expr: TypeDefinition, + ) -> Tuple[str, tuple]: + if not isinstance(expr, TypeDefinition): + raise TypeError("expr must be a TypeDefinition instance") + if not self.supports_type_definition(type(expr)): + raise UnsupportedFeatureError( + self.name, + f"TYPE definition {getattr(expr, 'definition_kind', type(expr).__name__)}", + ) + if isinstance(expr, SQLServerAliasTypeDefinition): + base_sql, base_params = expr.base_type.to_sql() + if base_params: + raise ValueError("TYPE base types must render without bind parameters") + if not SQLDialectBase._validate_data_type(base_sql): + raise ValueError("TYPE base types must render as a safe SQL type") + parts = [f"FROM {base_sql}"] + if expr.nullability is True: + parts.append("NOT NULL") + elif expr.nullability is False: + parts.append("NULL") + return " ".join(parts), () + if isinstance(expr, SQLServerClrTypeDefinition): + assembly_sql = self.format_identifier(expr.assembly_name) + if expr.class_name is None: + return f"EXTERNAL NAME {assembly_sql}", () + class_sql = self.format_identifier(expr.class_name) + return f"EXTERNAL NAME {assembly_sql}.{class_sql}", () + if isinstance(expr, SQLServerTableTypeDefinition): + return self._format_table_type_definition(expr) + raise UnsupportedFeatureError( + self.name, + f"TYPE definition {getattr(expr, 'definition_kind', type(expr).__name__)}", + ) + + def format_type_alter_action( + self, + expr: TypeAlterAction, + ) -> Tuple[str, tuple]: + if not isinstance(expr, TypeAlterAction): + raise TypeError("expr must be a TypeAlterAction instance") + raise UnsupportedFeatureError( + self.name, + f"ALTER TYPE action {getattr(expr, 'action_kind', type(expr).__name__)}", + ) + + def format_rename_type_statement( + self, + expr: SQLServerRenameTypeExpression, + ) -> Tuple[str, tuple]: + if not isinstance(expr, SQLServerRenameTypeExpression): + raise TypeError("expr must be a SQLServerRenameTypeExpression") + if not self.supports_rename_type(): + raise UnsupportedFeatureError( + self.name, + "RENAME TYPE", + "requires SQL Server 2005+.", + ) + old_name = self._raw_type_name(expr.type_name, expr.schema_name) + return ( + f"EXECUTE sp_rename {self._format_nstring(old_name)}, " + f"{self._format_nstring(expr.new_name)}, N'USERDATATYPE'", + (), + ) + + +__all__ = ["SQLServerTypeDDLMixin"] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_view.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_view.py index fe73ebe..c8a9d41 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_view.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_view.py @@ -59,6 +59,12 @@ def format_create_view_statement( parts.append(as_sql) if expr.options and hasattr(expr.options, 'check_option') and expr.options.check_option: + if not self.supports_view_check_option(): + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + raise UnsupportedFeatureError( + self.name, "WITH CHECK OPTION", + f"{self.name} does not support WITH CHECK OPTION.", + ) parts.append(f"WITH {expr.options.check_option.value} CHECK OPTION") return " ".join(parts), query_params diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/memory_optimized.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/memory_optimized.py index 4c5e688..4aaf0cd 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/memory_optimized.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/memory_optimized.py @@ -10,7 +10,7 @@ This mixin provides the capability switch and the ``format_*`` helpers used both standalone and from the dialect's ``format_create_table_statement`` -path (via ``dialect_options``). +path (via the typed ``SQLServerCreateTableOptions``). """ from typing import Optional, Sequence diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/partition.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/partition.py index 02b6ebc..9051b15 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/partition.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/partition.py @@ -95,7 +95,8 @@ def format_partition_clause(self, expr: "PartitionClause") -> Tuple[str, tuple]: """Format PARTITION BY clause for SQL Server. SQL Server uses ON partition_scheme(column) syntax. - The core PartitionClause must reference a scheme via dialect_options. + The partition scheme is a typed field on + ``SQLServerPartitionByRangeClause``. """ self.check_feature_support( # type: ignore[attr-defined] "supports_partitioned_table_creation", @@ -122,7 +123,7 @@ def format_partition_clause(self, expr: "PartitionClause") -> Tuple[str, tuple]: parts.append(key_sql) params.extend(key_params) - scheme = expr.dialect_options.get("partition_scheme") + scheme = getattr(expr, "partition_scheme", None) if scheme: return f" ON {self.format_identifier(scheme)} ({', '.join(parts)})", tuple(params) # type: ignore[attr-defined] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/protocol_support.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/protocol_support.py index ecf29f0..302c0ea 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/protocol_support.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/protocol_support.py @@ -250,15 +250,14 @@ def format_select_into_statement(self, expr: Any) -> Tuple[str, tuple]: SQL Syntax: SELECT ... INTO [new_table] FROM ... WHERE ... - The target table is taken from ``expr.dialect_options["select_into_table"]``. + The target table is taken from ``expr.select_into_table``. The INTO clause is inserted between the select list and the FROM clause of the fully rendered query. """ - dialect_options = getattr(expr, "dialect_options", None) or {} - into_table = dialect_options.get("select_into_table") + into_table = getattr(expr, "select_into_table", None) if not into_table: raise ValueError( - "dialect_options['select_into_table'] is required for SELECT INTO" + "select_into_table is required for SELECT INTO" ) full_sql, params = expr.to_sql() diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols.py index e1476fd..e980912 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols.py @@ -18,6 +18,7 @@ SQLServerPaginationSupport, SQLServerMergeSupport, SQLServerPartitionSupport, + SQLServerUserDefinedTypeSupport, ) __all__ = [ @@ -37,4 +38,5 @@ "SQLServerPaginationSupport", "SQLServerMergeSupport", "SQLServerPartitionSupport", -] \ No newline at end of file + "SQLServerUserDefinedTypeSupport", +] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/__init__.py index 574e578..a27e4a0 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/__init__.py @@ -16,6 +16,7 @@ from .graph import SQLServerGraphSupport from .merge import SQLServerMergeSupport from .partition import SQLServerPartitionSupport +from .type import SQLServerUserDefinedTypeSupport __all__ = [ "SQLServerIdentitySupport", @@ -35,4 +36,5 @@ "SQLServerGraphSupport", "SQLServerMergeSupport", "SQLServerPartitionSupport", -] \ No newline at end of file + "SQLServerUserDefinedTypeSupport", +] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/type.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/type.py new file mode 100644 index 0000000..335e6a5 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/type.py @@ -0,0 +1,35 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/protocols/type.py +"""SQL Server user-defined TYPE protocol.""" + +from typing import Any, Protocol, Tuple, runtime_checkable + +from rhosocial.activerecord.backend.dialect.protocols import UserDefinedTypeSupport + + +@runtime_checkable +class SQLServerUserDefinedTypeSupport(UserDefinedTypeSupport, Protocol): + """SQL Server-specific contract layered on core TYPE support.""" + + def supports_clr_type_definition(self) -> bool: + ... + + def supports_clr_user_defined_type(self) -> bool: + ... + + def supports_drop_type_cascade(self) -> bool: + ... + + def supports_drop_type_restrict(self) -> bool: + ... + + def supports_rename_type(self) -> bool: + ... + + def supports_memory_optimized_table_types(self) -> bool: + ... + + def format_rename_type_statement(self, expr: Any) -> Tuple[str, tuple]: + ... + + +__all__ = ["SQLServerUserDefinedTypeSupport"] 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 186ec5d..27d16f7 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 @@ -19,6 +19,7 @@ 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 ( + SQLServerCreateTableExpression, SQLServerForPathTable, SQLServerGraphDirection, SQLServerGraphEdgeRef, @@ -290,10 +291,10 @@ 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( + expr = SQLServerCreateTableExpression( dialect, table="graph_table", columns=[ColumnDefinition(dialect, "ID", IntegerType(dialect))], - dialect_options={"graph_table_kind": kind}, + graph_table_kind=kind, ) with pytest.raises(UnsupportedFeatureError, match="AS NODE/EDGE") as exc: expr.to_sql() @@ -327,23 +328,21 @@ def test_edge_constraint(self, major, name, expected): ) def test_create_graph_table(self, major, kind, expected): dialect = SQLServerDialect(version=(major, 0, 0)) - expr = CreateTableExpression( + expr = SQLServerCreateTableExpression( dialect, table="graph_table", columns=[ColumnDefinition(dialect, "ID", IntegerType(dialect))], - dialect_options={"graph_table_kind": kind}, + graph_table_kind=kind, ) assert expr.to_sql() == (expected, ()) @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( + expr = SQLServerCreateTableExpression( dialect, table="friend", columns=[ColumnDefinition(dialect, "ID", IntegerType(dialect))], - dialect_options={ - "graph_table_kind": SQLServerGraphTableKind.EDGE, - "edge_constraints": [SQLServerEdgeConstraint(dialect, "Person", "Person", name="ec_friend")], - }, + graph_table_kind=SQLServerGraphTableKind.EDGE, + edge_constraints=[SQLServerEdgeConstraint(dialect, "Person", "Person", name="ec_friend")], ) if major == 14: with pytest.raises(UnsupportedFeatureError, match="CONNECTION") as exc: diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_ddl_source_mixin.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_ddl_source_mixin.py new file mode 100644 index 0000000..c0c0119 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_ddl_source_mixin.py @@ -0,0 +1,537 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_ddl_source_mixin.py + +try: + from typing import Annotated, ClassVar, Optional +except ImportError: + from typing_extensions import Annotated, ClassVar, Optional + +import pytest + +from rhosocial.activerecord.backend.expression import ColumnDefinition +from rhosocial.activerecord.backend.expression.core import Column, Literal +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraintType, + GeneratedColumnExpression, + GeneratedColumnType, + IndexDefinition, + StorageOptionsExpression, + TableConstraint, + TableConstraintType, +) +from rhosocial.activerecord.backend.expression.statements.ddl_partition import ( + PartitionClause, + PartitionStrategy, +) +from rhosocial.activerecord.backend.impl.sqlserver import SQLServerDialect +from rhosocial.activerecord.backend.impl.sqlserver.expression.column import ( + SQLServerColumnOptions, +) +from rhosocial.activerecord.backend.impl.sqlserver.expression.table_options import ( + SQLServerCreateTableOptions, +) +from rhosocial.activerecord.backend.impl.sqlserver.expression.types import ( + SQLServerNCharType, + SQLServerNVarCharType, +) +from rhosocial.activerecord.base import ( + CharacterSetAttribute, + CollationAttribute, + DDLAnnotation, + DDLAnnotationHandler, + DDLSourceMixin, + DDLSource, + IdentityAttribute, + UseColumn, + UseColumnAttributes, + UseComment, + UseConstraint, + UseGeneratedColumn, + UseIndex, + UseSqlType, +) +from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord + + +class UseColumnOptions(DDLAnnotation): + def __init__(self, *options): + self.options = options + + +class ColumnOptionsHandler(DDLAnnotationHandler): + annotation_types = (UseColumnOptions,) + + @classmethod + def apply(cls, new_class, field_name, annotation, metadata): + metadata.add_column_options(*annotation.options) + + +def check_condition(dialect): + return Column(dialect, "value_col") == "active" + + +def index_condition(dialect): + return Column(dialect, "value_col") == "active" + + +PRIMARY_TYPE = SQLServerNVarCharType(length=64) +FALLBACK_TYPE = SQLServerNCharType(length=8) +TYPE_MARKER = UseSqlType(PRIMARY_TYPE, FALLBACK_TYPE) +DEFAULT_VALUE = Literal(None, "model-default", inline_literals=True) +DEFAULT_CONSTRAINT = UseConstraint( + ColumnConstraintType.DEFAULT, + name="df_value", + default_value=DEFAULT_VALUE, +) +CHECK_CONSTRAINT = UseConstraint( + ColumnConstraintType.CHECK, + name="ck_value", + check_condition=check_condition, +) +INCLUDE_COLUMNS = ["nullable_col"] +INDEX_MARKER = UseIndex( + "ix_value", + unique=True, + type="BTREE", + partial_condition=index_condition, + include_columns=INCLUDE_COLUMNS, + if_not_exists=True, + tablespace="index_space", + if_exists=True, + concurrent=True, +) +SECOND_INDEX_MARKER = UseIndex("ix_value_suffix", type="BTREE") +IDENTITY_ATTRIBUTE = IdentityAttribute(generation="ALWAYS", start=5, increment=2) +COLLATION_ATTRIBUTE = CollationAttribute(name="NOCASE") +CHARACTER_SET_ATTRIBUTE = CharacterSetAttribute(name="utf8mb4") +ATTRIBUTES_MARKER = UseColumnAttributes(IDENTITY_ATTRIBUTE, COLLATION_ATTRIBUTE) +SECOND_ATTRIBUTES_MARKER = UseColumnAttributes(CHARACTER_SET_ATTRIBUTE) +COMMENT_MARKER = UseComment("value comment") +GENERATED_EXPRESSION = GeneratedColumnExpression( + None, + Column(None, "value_col"), + GeneratedColumnType.STORED, +) +GENERATED_MARKER = UseGeneratedColumn(GENERATED_EXPRESSION) +VALUE_OPTIONS = ( + SQLServerColumnOptions(sparse=True, rowguidcol=True), + SQLServerColumnOptions(sparse=False), +) +VALUE_OPTIONS_MARKER = UseColumnOptions(*VALUE_OPTIONS) +SINGLE_OPTION = SQLServerColumnOptions(rowguidcol=True) +SINGLE_OPTION_MARKER = UseColumnOptions(SINGLE_OPTION) +NULL_CONSTRAINT = UseConstraint(ColumnConstraintType.NULL) +PK_TYPE = Annotated[int, UseColumn("id")] +NULLABLE_TYPE = Annotated[Optional[str], NULL_CONSTRAINT] +VALUE_TYPE = Annotated[ + str, + UseColumn("value_col"), + TYPE_MARKER, + DEFAULT_CONSTRAINT, + CHECK_CONSTRAINT, + INDEX_MARKER, + SECOND_INDEX_MARKER, + ATTRIBUTES_MARKER, + SECOND_ATTRIBUTES_MARKER, + COMMENT_MARKER, + GENERATED_MARKER, + VALUE_OPTIONS_MARKER, +] + +TABLE_INDEXES = [ + IndexDefinition(None, name="ix_table_first", columns=["value_col"], unique=True), + IndexDefinition(None, name="ix_table_second", columns=["id"]), +] +TABLE_CONSTRAINTS = [ + TableConstraint( + None, + TableConstraintType.UNIQUE, + name="uq_value", + columns=["value_col"], + ), + TableConstraint( + None, + TableConstraintType.CHECK, + name="ck_value_table", + columns=["value_col"], + check_condition=check_condition, + ), +] +TABLE_OPTIONS = SQLServerCreateTableOptions( + None, + memory_optimized=True, + durability="SCHEMA_AND_DATA", +) +STORAGE_OPTIONS = StorageOptionsExpression(None, {"fillfactor": 80}) +PARTITION_KEY = Column(None, "id") +TABLE_PARTITION = PartitionClause( + None, + PartitionStrategy.RANGE, + [PARTITION_KEY], +) +INHERITED_TABLES = ["parent_a", "parent_b"] +TABLE_SPACE = "ts_data" +BATCH_METHODS = ( + "columns_name", + "columns_type", + "columns_constraints", + "columns_attributes", + "columns_indexes", + "columns_comment", + "columns_generated", + "columns_options", +) + + +class DeclarationMixin: + __table_indexes__ = TABLE_INDEXES + __table_constraints__ = TABLE_CONSTRAINTS + _feature_handlers: ClassVar[list] = [DDLAnnotationHandler, ColumnOptionsHandler] + + @classmethod + def table_options(cls): + return TABLE_OPTIONS + + @classmethod + def table_storage_options(cls): + return STORAGE_OPTIONS + + @classmethod + def table_partition(cls): + return TABLE_PARTITION + + @classmethod + def table_inherits(cls): + return INHERITED_TABLES + + @classmethod + def table_tablespace(cls): + return TABLE_SPACE + + +class SyncModel(DeclarationMixin, ActiveRecord): + __table_name__ = "ddl_source_sync" + __primary_key__ = "id" + + pk_field: PK_TYPE + value_field: VALUE_TYPE = DEFAULT_VALUE + nullable_field: NULLABLE_TYPE = None + optional_field: Annotated[Optional[str], SINGLE_OPTION_MARKER] = None + + +class AsyncModel(DeclarationMixin, AsyncActiveRecord): + __table_name__ = "ddl_source_async" + __primary_key__ = "id" + + pk_field: PK_TYPE + value_field: VALUE_TYPE = DEFAULT_VALUE + nullable_field: NULLABLE_TYPE = None + optional_field: Annotated[Optional[str], SINGLE_OPTION_MARKER] = None + + +class CompositeModel(DeclarationMixin, ActiveRecord): + __table_name__ = "ddl_source_composite" + __primary_key__ = ("left_col", "right_col") + + left_field: Annotated[int, UseColumn("left_col")] + right_field: Annotated[int, UseColumn("right_col")] + value_field: str + + +class AsyncCompositeModel(DeclarationMixin, AsyncActiveRecord): + __table_name__ = "ddl_source_async_composite" + __primary_key__ = ("left_col", "right_col") + + left_field: Annotated[int, UseColumn("left_col")] + right_field: Annotated[int, UseColumn("right_col")] + value_field: str + + +class PlainModel(ActiveRecord): + __table_name__ = "ddl_source_plain" + + id: int + optional: Optional[str] = None + + +class AsyncPlainModel(AsyncActiveRecord): + __table_name__ = "ddl_source_async_plain" + + id: int + optional: Optional[str] = None + + +def _constraint_signature(model, field): + return [ + ( + constraint.constraint_type, + constraint.name, + constraint.default_value, + constraint.check_condition, + ) + for constraint in model.column_constraints(field) + ] + + +def _index_signature(index): + return ( + index.name, + index.columns, + index.unique, + index.type, + index.partial_condition, + index.include_columns, + index.if_not_exists, + index.tablespace, + index.if_exists, + index.concurrent, + ) + + +@pytest.mark.parametrize("model", [SyncModel, AsyncModel], ids=("sync", "async")) +def test_field_declarations_are_collected_in_declaration_order(model): + assert issubclass(model, DDLSourceMixin) + assert isinstance(model, DDLSource) + assert ColumnOptionsHandler in model.get_feature_handlers() + assert model.ddl_field_names() == ( + "pk_field", + "value_field", + "nullable_field", + "optional_field", + ) + assert model.columns_name() == { + "pk_field": "id", + "value_field": "value_col", + "nullable_field": "nullable_field", + "optional_field": "optional_field", + } + assert model.column_name("pk_field") == "id" + assert model.column_name("value_field") == "value_col" + assert model.column_type("value_field") is TYPE_MARKER + assert model.column_type("value_field").data_types == (PRIMARY_TYPE, FALLBACK_TYPE) + assert model.column_type("value_field").data_types[0] is PRIMARY_TYPE + assert model.column_type("value_field").data_types[1] is FALLBACK_TYPE + assert isinstance(model.column_type("value_field").data_type, SQLServerNVarCharType) + assert model.column_type("value_field").data_type.length == 64 + + value_constraints = model.column_constraints("value_field") + assert value_constraints[0] is DEFAULT_CONSTRAINT.constraint + assert value_constraints[1] is CHECK_CONSTRAINT.constraint + assert value_constraints[2].constraint_type is ColumnConstraintType.NOT_NULL + assert value_constraints[0].default_value is DEFAULT_VALUE + assert value_constraints[1].check_condition is check_condition + assert model.column_constraints("nullable_field") == [NULL_CONSTRAINT.constraint] + assert model.column_constraints("optional_field") == [] + + attributes = model.column_attributes("value_field") + assert attributes[0] is IDENTITY_ATTRIBUTE + assert attributes[1] is COLLATION_ATTRIBUTE + assert attributes[2] is CHARACTER_SET_ATTRIBUTE + + indexes = model.column_indexes("value_field") + assert [index.name for index in indexes] == ["ix_value", "ix_value_suffix"] + assert all(index.columns == ["value_col"] for index in indexes) + assert indexes[0].unique is True + assert indexes[0].type == "BTREE" + assert indexes[0].partial_condition is index_condition + assert indexes[0].include_columns is INCLUDE_COLUMNS + assert indexes[0].if_not_exists is True + assert indexes[0].tablespace == "index_space" + assert indexes[0].if_exists is True + assert indexes[0].concurrent is True + + assert model.column_comment("value_field") == "value comment" + assert model.generated_column("value_field") is GENERATED_EXPRESSION + options = model.column_options("value_field") + assert options[0] is VALUE_OPTIONS[0] + assert options[1] is VALUE_OPTIONS[1] + assert options[0].sparse is True + assert options[0].rowguidcol is True + assert options[1].sparse is False + assert model.column_options("optional_field") is SINGLE_OPTION + assert model.field_python_type("value_field") is str + assert model.field_is_optional("value_field") is False + assert model.field_is_optional("optional_field") is True + + +def test_collected_backend_data_type_renders_with_dialect(): + dialect = SQLServerDialect((16, 0, 0)) + data_type = SyncModel.column_type("value_field").data_types[0] + assert isinstance(data_type, SQLServerNVarCharType) + data_type.dialect = dialect + try: + rendered = ColumnDefinition(dialect, "value_col", data_type).to_sql() + finally: + data_type.dialect = None + assert rendered == ("[value_col] NVARCHAR(64)", ()) + + +@pytest.mark.parametrize("model", [SyncModel, AsyncModel], ids=("sync", "async")) +def test_table_declarations_are_returned_unchanged(model): + assert model.__table_indexes__ is TABLE_INDEXES + assert model.__table_constraints__ is TABLE_CONSTRAINTS + indexes = model.table_indexes() + assert indexes is not TABLE_INDEXES + assert indexes[0] is TABLE_INDEXES[0] + assert indexes[1] is TABLE_INDEXES[1] + constraints = model.table_constraints() + assert constraints is not TABLE_CONSTRAINTS + assert constraints[0] is TABLE_CONSTRAINTS[0] + assert constraints[1] is TABLE_CONSTRAINTS[1] + assert model.table_options() is TABLE_OPTIONS + assert model.table_options().memory_optimized is True + assert model.table_options().durability == "SCHEMA_AND_DATA" + assert model.table_storage_options() is STORAGE_OPTIONS + assert model.table_partition() is TABLE_PARTITION + assert model.table_partition().method == PartitionStrategy.RANGE.value + assert model.table_partition().keys[0] is PARTITION_KEY + assert model.table_inherits() is INHERITED_TABLES + assert model.table_inherits() == ["parent_a", "parent_b"] + assert model.table_tablespace() == TABLE_SPACE + assert model.create_table_statement_classes() is None + + +@pytest.mark.parametrize("model", [SyncModel, AsyncModel], ids=("sync", "async")) +def test_single_primary_key_is_composed_on_the_physical_column(model): + assert model.primary_key_columns() == ("id",) + assert model.primary_key_field() == "pk_field" + assert model.column_name("pk_field") == "id" + constraints = model.column_constraints("pk_field") + assert [item.constraint_type for item in constraints] == [ + ColumnConstraintType.PRIMARY_KEY, + ColumnConstraintType.NOT_NULL, + ] + assert all( + item.constraint_type is not TableConstraintType.PRIMARY_KEY + for item in model.table_constraints() + ) + + +@pytest.mark.parametrize( + "model", [CompositeModel, AsyncCompositeModel], ids=("sync", "async") +) +def test_composite_primary_key_is_composed_as_a_table_constraint(model): + assert model.primary_key_columns() == ("left_col", "right_col") + assert model.primary_key_field() == ("left_field", "right_field") + assert model.column_name("left_field") == "left_col" + assert model.column_name("right_field") == "right_col" + assert [item.constraint_type for item in model.column_constraints("left_field")] == [ + ColumnConstraintType.NOT_NULL + ] + assert [item.constraint_type for item in model.column_constraints("right_field")] == [ + ColumnConstraintType.NOT_NULL + ] + constraints = model.table_constraints() + assert constraints[0] is TABLE_CONSTRAINTS[0] + assert constraints[1] is TABLE_CONSTRAINTS[1] + assert constraints[2].constraint_type is TableConstraintType.PRIMARY_KEY + assert constraints[2].columns == ["left_col", "right_col"] + + +@pytest.mark.parametrize("model", [PlainModel, AsyncPlainModel], ids=("sync", "async")) +def test_default_declarations_and_batch_interfaces(model): + assert model.ddl_field_names() == ("id", "optional") + assert model.columns_name() == {"id": "id", "optional": "optional"} + assert model.column_type("id") is None + assert model.column_comment("id") is None + assert model.generated_column("id") is None + assert model.column_options("id") is None + assert model.column_attributes("id") == [] + assert model.column_indexes("id") == [] + assert [item.constraint_type for item in model.column_constraints("id")] == [ + ColumnConstraintType.PRIMARY_KEY, + ColumnConstraintType.NOT_NULL, + ] + assert model.column_constraints("optional") == [] + assert model.columns_type() == {"id": None, "optional": None} + assert model.columns_constraints()["optional"] == [] + assert model.columns_attributes() == {"id": [], "optional": []} + assert model.columns_indexes() == {"id": [], "optional": []} + assert model.columns_comment() == {"id": None, "optional": None} + assert model.columns_generated() == {"id": None, "optional": None} + assert model.columns_options() == {"id": None, "optional": None} + assert model.table_options() is None + assert model.table_storage_options() is None + assert model.table_partition() is None + assert model.table_inherits() is None + assert model.table_tablespace() is None + assert model.table_indexes() == [] + assert model.table_constraints() == [] + assert model.create_table_statement_classes() is None + + +@pytest.mark.parametrize("model", [SyncModel, AsyncModel], ids=("sync", "async")) +def test_batch_interfaces_preserve_requested_field_order(model): + for method_name in BATCH_METHODS: + method = getattr(model, method_name) + assert tuple(method()) == model.ddl_field_names() + + fields = ["value_field", "pk_field", "optional_field"] + names = model.columns_name(fields) + types = model.columns_type(fields) + constraints = model.columns_constraints(fields) + attributes = model.columns_attributes(fields) + indexes = model.columns_indexes(fields) + comments = model.columns_comment(fields) + generated = model.columns_generated(fields) + options = model.columns_options(fields) + + assert list(names) == fields + assert list(types) == fields + assert list(constraints) == fields + assert list(attributes) == fields + assert list(indexes) == fields + assert list(comments) == fields + assert list(generated) == fields + assert list(options) == fields + assert names == { + "value_field": "value_col", + "pk_field": "id", + "optional_field": "optional_field", + } + assert types["value_field"] is TYPE_MARKER + assert constraints["value_field"][0] is DEFAULT_CONSTRAINT.constraint + assert attributes["value_field"][0] is IDENTITY_ATTRIBUTE + assert indexes["value_field"][0].columns == ["value_col"] + assert comments["value_field"] == "value comment" + assert generated["value_field"] is GENERATED_EXPRESSION + assert options["value_field"][0] is VALUE_OPTIONS[0] + assert options["optional_field"] is SINGLE_OPTION + + +def test_sync_and_async_ddl_sources_collect_the_same_declarations(): + assert SyncModel.columns_name() == AsyncModel.columns_name() + assert SyncModel.columns_type() == AsyncModel.columns_type() + assert SyncModel.columns_attributes() == AsyncModel.columns_attributes() + assert SyncModel.columns_comment() == AsyncModel.columns_comment() + assert SyncModel.columns_generated() == AsyncModel.columns_generated() + assert SyncModel.columns_options() == AsyncModel.columns_options() + for field in ("pk_field", "value_field", "nullable_field", "optional_field"): + assert _constraint_signature(SyncModel, field) == _constraint_signature( + AsyncModel, field + ) + sync_indexes = SyncModel.column_indexes(field) + async_indexes = AsyncModel.column_indexes(field) + assert len(sync_indexes) == len(async_indexes) + for sync_index, async_index in zip(sync_indexes, async_indexes): + assert _index_signature(sync_index) == _index_signature(async_index) + assert SyncModel.table_indexes()[0] is AsyncModel.table_indexes()[0] + assert SyncModel.table_indexes()[1] is AsyncModel.table_indexes()[1] + assert SyncModel.table_constraints()[0] is AsyncModel.table_constraints()[0] + assert SyncModel.table_constraints()[1] is AsyncModel.table_constraints()[1] + assert SyncModel.table_options() is AsyncModel.table_options() + assert SyncModel.table_storage_options() is AsyncModel.table_storage_options() + assert SyncModel.table_partition() is AsyncModel.table_partition() + assert SyncModel.table_inherits() is AsyncModel.table_inherits() + assert SyncModel.table_tablespace() == AsyncModel.table_tablespace() + assert _constraint_signature(CompositeModel, "left_field") == _constraint_signature( + AsyncCompositeModel, "left_field" + ) + assert _constraint_signature(CompositeModel, "right_field") == _constraint_signature( + AsyncCompositeModel, "right_field" + ) + assert [ + (item.constraint_type, item.columns) for item in CompositeModel.table_constraints() + ] == [ + (item.constraint_type, item.columns) + for item in AsyncCompositeModel.table_constraints() + ] 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 2e84175..87de38f 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect.py @@ -6,7 +6,6 @@ """ import pytest from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect -from rhosocial.activerecord.backend.impl.sqlserver.mixins.ddl_database import SQLServerDatabaseMixin class TestSQLServerDialect: @@ -85,7 +84,7 @@ def dialect(self): @pytest.fixture def database_dialect(self): - class DatabaseDialect(SQLServerDatabaseMixin, SQLServerDialect): + class DatabaseDialect(SQLServerDialect): pass return DatabaseDialect(version=(16, 0, 0)) 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 cead7cb..f455ee8 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 @@ -287,11 +287,13 @@ def test_merge_output_action_snapshot(self): from rhosocial.activerecord.backend.expression.statements import ( MergeAction, MergeActionType, - MergeExpression, + ) + from rhosocial.activerecord.backend.impl.sqlserver.expression import ( + SQLServerMergeExpression, ) d = SQLServerDialect((16, 0, 0)) - merge = MergeExpression( + merge = SQLServerMergeExpression( dialect=d, target_table=TableExpression(d, "target"), source=QueryExpression( @@ -312,10 +314,10 @@ def test_merge_output_action_snapshot(self): when_not_matched=[ MergeAction(dialect=d, action_type=MergeActionType.INSERT, assignments={"id": Column(d, "id", "source")}) ], + output=["inserted.id"], + output_action=True, + holdlock=True, ) - # MergeExpression carries no dialect_options of its own; the dialect - # reads them via getattr, so the test injects the mapping directly. - merge.dialect_options = {"output": ["inserted.id"], "output_action": True, "holdlock": True} sql, params = merge.to_sql() assert sql.endswith("OUTPUT $action, inserted.id;") assert sql.startswith("MERGE INTO [target] WITH (HOLDLOCK)") diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_expression_roundtrip_all.py index 1185e04..87630a3 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_expression_roundtrip_all.py @@ -48,6 +48,68 @@ def partition(d): d, keys=[Column(d, "id")], partition_scheme="ps" ) + def alias_type(d): + from rhosocial.activerecord.backend.expression.types import IntegerType + from rhosocial.activerecord.backend.impl.sqlserver.expression.ddl.type import ( + SQLServerAliasTypeDefinition, + ) + return SQLServerAliasTypeDefinition(d, IntegerType(d), nullability=True) + + def table_type(d): + from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, + ColumnConstraintType, + ColumnDefinition, + TableConstraint, + TableConstraintType, + ) + from rhosocial.activerecord.backend.expression.types import IntegerType + from rhosocial.activerecord.backend.impl.sqlserver.expression.ddl.type import ( + SQLServerTableTypeDefinition, + ) + from rhosocial.activerecord.backend.impl.sqlserver.expression.index import ( + SQLServerIndexDefinition, + ) + return SQLServerTableTypeDefinition( + d, + columns=[ + ColumnDefinition( + d, + "id", + IntegerType(d), + constraints=[ + ColumnConstraint(d, ColumnConstraintType.NOT_NULL), + ], + ) + ], + constraints=[ + TableConstraint( + d, + TableConstraintType.PRIMARY_KEY, + columns=["id"], + ) + ], + indexes=[SQLServerIndexDefinition(d, "ix_id", ["id"])], + ) + + def clr_type(d): + from rhosocial.activerecord.backend.impl.sqlserver.expression.ddl.type import ( + SQLServerClrTypeDefinition, + ) + return SQLServerClrTypeDefinition(d, "assembly", "Namespace.Class") + + def drop_type(d): + from rhosocial.activerecord.backend.impl.sqlserver.expression.ddl.type import ( + SQLServerDropTypeExpression, + ) + return SQLServerDropTypeExpression(d, "type_name") + + def rename_type(d): + from rhosocial.activerecord.backend.impl.sqlserver.expression.ddl.type import ( + SQLServerRenameTypeExpression, + ) + return SQLServerRenameTypeExpression(d, "old_name", "new_name") + register_special_constructor("pivot.PivotExpression", pivot) register_special_constructor( "columnstore.SQLServerColumnstoreIndexExpression", columnstore @@ -55,6 +117,11 @@ def partition(d): register_special_constructor( "partition.SQLServerPartitionByRangeClause", partition ) + register_special_constructor("type.SQLServerAliasTypeDefinition", alias_type) + register_special_constructor("type.SQLServerTableTypeDefinition", table_type) + register_special_constructor("type.SQLServerClrTypeDefinition", clr_type) + register_special_constructor("type.SQLServerDropTypeExpression", drop_type) + register_special_constructor("type.SQLServerRenameTypeExpression", rename_type) _register_sqlserver_specials() diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_generated_column_capabilities.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_generated_column_capabilities.py new file mode 100644 index 0000000..9570c2d --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_generated_column_capabilities.py @@ -0,0 +1,16 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_generated_column_capabilities.py +"""Explicit SQL Server generated/computed-column capability assertions.""" + +from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect + + +def test_generated_columns_supported(): + dialect = SQLServerDialect() + assert dialect.supports_generated_column() is True + assert dialect.supports_generated_columns() is True + + +def test_stored_and_virtual_generated_columns_supported(): + dialect = SQLServerDialect() + assert dialect.supports_stored_generated_columns() is True + assert dialect.supports_virtual_generated_columns() is True 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 96fcf1a..b4fdeeb 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 @@ -47,15 +47,18 @@ SQL_SERVER_2022 = (16, 0, 0) -def _make_query(d, columns=("a", "b"), where=None, **dialect_options): - from rhosocial.activerecord.backend.expression import Column, QueryExpression, TableExpression +def _make_query(d, columns=("a", "b"), where=None, select_into_table=None): + from rhosocial.activerecord.backend.expression import Column, TableExpression + from rhosocial.activerecord.backend.impl.sqlserver.expression import ( + SQLServerSelectIntoExpression, + ) - return QueryExpression( + return SQLServerSelectIntoExpression( dialect=d, select=[Column(d, col) for col in columns], from_=TableExpression(d, "t"), where=where, - dialect_options=dialect_options, + select_into_table=select_into_table, ) @@ -346,12 +349,13 @@ def _alter(self, d, action): return AlterTableExpression(d, "t", actions=[action]).to_sql()[0] - def _alter_column(self, d, column_name="c", operation="SET DATA TYPE", new_value=None, **dialect_options): - from rhosocial.activerecord.backend.expression.statements import AlterColumn + def _alter_column(self, d, column_name="c", operation="SET DATA TYPE", new_value=None, **options): + from rhosocial.activerecord.backend.impl.sqlserver.expression import ( + SQLServerAlterColumn, + ) - return AlterColumn( - d, column_name, operation=operation, new_value=new_value, - dialect_options=dialect_options, + return SQLServerAlterColumn( + d, column_name, operation, new_value=new_value, **options ) def test_alter_column_type(self, 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 4987e34..d51b3f6 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 @@ -74,9 +74,12 @@ def _create_table(self, d, memory_optimized=True, durability="SCHEMA_ONLY", ColumnConstraint, ColumnConstraintType, ColumnDefinition, - IndexDefinition, ) from rhosocial.activerecord.backend.expression.types import IntegerType + from rhosocial.activerecord.backend.impl.sqlserver.expression import ( + SQLServerCreateTableOptions, + SQLServerIndexDefinition, + ) col = ColumnDefinition(d, "id", IntegerType(d)) col.constraints.append(ColumnConstraint(d, ColumnConstraintType.PRIMARY_KEY)) @@ -84,18 +87,21 @@ def _create_table(self, d, memory_optimized=True, durability="SCHEMA_ONLY", if hash_indexes: for name, (columns, bucket_count) in hash_indexes.items(): indexes.append( - IndexDefinition( + SQLServerIndexDefinition( d, name, list(columns), - dialect_options={"hash_index": True, "bucket_count": bucket_count}, + hash_index=True, + bucket_count=bucket_count, ) ) - dialect_options = {} + table_options = None if memory_optimized: - dialect_options = {"memory_optimized": True, "durability": durability} + table_options = SQLServerCreateTableOptions( + d, memory_optimized=True, durability=durability + ) return CreateTableExpression( - d, "t", [col], indexes=indexes, dialect_options=dialect_options + d, "t", [col], indexes=indexes, table_options=table_options ) def test_mixin_registered(self, dialect): @@ -197,9 +203,12 @@ def test_create_table_with_hash_index_missing_bucket_count(self, dialect): ColumnConstraint, ColumnConstraintType, ColumnDefinition, - IndexDefinition, ) from rhosocial.activerecord.backend.expression.types import IntegerType + from rhosocial.activerecord.backend.impl.sqlserver.expression import ( + SQLServerCreateTableOptions, + SQLServerIndexDefinition, + ) col = ColumnDefinition(dialect, "id", IntegerType(dialect)) col.constraints.append(ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)) @@ -207,8 +216,8 @@ def test_create_table_with_hash_index_missing_bucket_count(self, dialect): dialect, "t", [col], - indexes=[IndexDefinition(dialect, "ix", ["id"], dialect_options={"hash_index": True})], - dialect_options={"memory_optimized": True}, + indexes=[SQLServerIndexDefinition(dialect, "ix", ["id"], hash_index=True)], + table_options=SQLServerCreateTableOptions(dialect, memory_optimized=True), ) with pytest.raises(ValueError): ct.to_sql() 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 f6fcd22..380b8f7 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 @@ -232,10 +232,12 @@ def _build_merge(self, d): from rhosocial.activerecord.backend.expression.statements import ( MergeAction, MergeActionType, - MergeExpression, + ) + from rhosocial.activerecord.backend.impl.sqlserver.expression import ( + SQLServerMergeExpression, ) - return MergeExpression( + return SQLServerMergeExpression( dialect=d, target_table="tgt", source=TableExpression(d, "src"), @@ -271,28 +273,24 @@ def test_merge_output_clause_with_action(self, dialect): def test_merge_statement_with_output(self, dialect): merge = self._build_merge(dialect) - merge.dialect_options = { - "output": ["inserted.*", "deleted.*"], - "output_action": True, - } + merge.output = ["inserted.*", "deleted.*"] + merge.output_action = True sql, params = merge.to_sql() assert sql.startswith("MERGE INTO [tgt]") assert "OUTPUT $action, inserted.*, deleted.*" in sql def test_merge_statement_with_holdlock(self, dialect): merge = self._build_merge(dialect) - merge.dialect_options = {"holdlock": True} + merge.holdlock = True sql, params = merge.to_sql() assert sql.startswith("MERGE INTO [tgt] WITH (HOLDLOCK)") assert "USING [src]" in sql def test_merge_statement_with_output_and_holdlock(self, dialect): merge = self._build_merge(dialect) - merge.dialect_options = { - "output": ["inserted.*", "deleted.*"], - "output_action": True, - "holdlock": True, - } + merge.output = ["inserted.*", "deleted.*"] + merge.output_action = True + merge.holdlock = True sql, params = merge.to_sql() assert sql.startswith("MERGE INTO [tgt] WITH (HOLDLOCK)") assert "OUTPUT $action, inserted.*, deleted.*" in sql @@ -427,21 +425,29 @@ def test_supports_tablesample(self, dialect): assert dialect.supports_tablesample() is True def test_format_select_into_statement(self, dialect): - from rhosocial.activerecord.backend.expression import Column, QueryExpression + from rhosocial.activerecord.backend.expression import Column + from rhosocial.activerecord.backend.impl.sqlserver.expression import ( + SQLServerSelectIntoExpression, + ) - query = QueryExpression( + query = SQLServerSelectIntoExpression( dialect=dialect, select=[Column(dialect, "a"), Column(dialect, "b")], - dialect_options={"select_into_table": "new_table"}, + select_into_table="new_table", ) sql, params = dialect.format_select_into_statement(query) assert sql == "SELECT [a], [b] INTO [new_table]" assert params == () def test_format_select_into_requires_target_table(self, dialect): - from rhosocial.activerecord.backend.expression import Column, QueryExpression + from rhosocial.activerecord.backend.expression import Column + from rhosocial.activerecord.backend.impl.sqlserver.expression import ( + SQLServerSelectIntoExpression, + ) - query = QueryExpression(dialect=dialect, select=[Column(dialect, "id")]) + query = SQLServerSelectIntoExpression( + dialect=dialect, select=[Column(dialect, "id")] + ) with pytest.raises(ValueError): dialect.format_select_into_statement(query) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_column_definition.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_column_definition.py new file mode 100644 index 0000000..0dcb7e7 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_column_definition.py @@ -0,0 +1,61 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_column_definition.py +"""Tests for the SQL Server-specific column definition expressions.""" + +import pytest + +from rhosocial.activerecord.backend.expression import ColumnDefinition +from rhosocial.activerecord.backend.expression.types import IntegerType +from rhosocial.activerecord.backend.impl.sqlserver.expression import ( + SQLServerColumnDefinition, + SQLServerColumnOptions, +) + + +@pytest.fixture +def dialect(): + from rhosocial.activerecord.backend.impl.sqlserver import SQLServerDialect + + return SQLServerDialect((2022, 0, 0)) + + +def test_derives_generic_column_definition(): + assert ColumnDefinition in SQLServerColumnDefinition.__mro__ + + +def test_sparse(dialect): + sql, _ = SQLServerColumnDefinition(dialect, "x", IntegerType(dialect), sparse=True).to_sql() + assert sql == "[x] INT SPARSE" + + +def test_rowguidcol(dialect): + sql, _ = SQLServerColumnDefinition( + dialect, "x", IntegerType(dialect), rowguidcol=True + ).to_sql() + assert sql == "[x] INT ROWGUIDCOL" + + +def test_generic_column_still_renders_on_sqlserver(dialect): + generic = ColumnDefinition(dialect, "x", IntegerType(dialect)) + sql, _ = generic.to_sql() + assert sql == "[x] INT" + + +def test_options_select_sqlserver_column_class(): + assert SQLServerColumnOptions(sparse=True).column_definition_class() is ( + SQLServerColumnDefinition + ) + + +def test_options_apply_to(dialect): + options = SQLServerColumnOptions(sparse=True, rowguidcol=True) + col = SQLServerColumnDefinition(dialect, "c", IntegerType(dialect)) + options.apply_to(col) + assert col.sparse is True + assert col.rowguidcol is True + + +def test_options_apply_to_rejects_generic_column(dialect): + options = SQLServerColumnOptions(sparse=True) + generic = ColumnDefinition(dialect, "c", IntegerType(dialect)) + with pytest.raises(TypeError, match="SQLServerColumnDefinition"): + options.apply_to(generic) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_database_ddl.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_database_ddl.py new file mode 100644 index 0000000..6700c0b --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_database_ddl.py @@ -0,0 +1,29 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_database_ddl.py +"""Explicit SQLServerDialect database DDL capability + rendering tests.""" + +from rhosocial.activerecord.backend.expression.statements.ddl_database import ( + CreateDatabaseExpression, + DropDatabaseExpression, +) +from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect + + +def _dialect(): + return SQLServerDialect() + + +def test_database_capabilities(): + dialect = _dialect() + assert dialect.supports_create_database() is True + assert dialect.supports_drop_database() is True + + +def test_create_database_renders(): + sql, params = CreateDatabaseExpression(_dialect(), database_name="app").to_sql() + assert "CREATE DATABASE" in sql + assert params == () + + +def test_drop_database_renders(): + sql, _ = DropDatabaseExpression(_dialect(), database_name="app").to_sql() + assert "DROP DATABASE" in sql diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_ddl_improvements.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_ddl_improvements.py index d08e8c4..faef2d1 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 @@ -1,22 +1,86 @@ # 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 unittest.mock import patch +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression import ( Column, - TableExpression, - QueryExpression, + CreateTableExpression, CreateViewExpression, - DropViewExpression, + QueryExpression, + TableExpression, +) +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ViewCheckOption, + ViewOptions, ) +from rhosocial.activerecord.backend.expression.types import IntegerType from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + +class TestSQLServerTableCapabilityGating: + def test_table_declaration_defaults_are_absent(self): + dialect = SQLServerDialect(version=(16, 0, 0)) + expression = CreateTableExpression( + dialect, + "plain_table_defaults", + [ColumnDefinition(dialect, "id", IntegerType(dialect))], + ) + sql, params = expression.to_sql() + assert expression.inherits == [] + assert expression.tablespace is None + assert "plain_table_defaults" in sql.lower() + assert "id" in sql.lower() + assert params == () + + def test_table_inherits_declaration_is_propagated_and_fails_fast(self): + dialect = SQLServerDialect(version=(16, 0, 0)) + expression = CreateTableExpression( + dialect, + "inheriting_table", + [ColumnDefinition(dialect, "id", IntegerType(dialect))], + inherits=["parent_a", "parent_b"], + ) + assert expression.inherits == ["parent_a", "parent_b"] + assert dialect.supports_table_inheritance() is False + with pytest.raises(UnsupportedFeatureError, match="INHERITS"): + expression.to_sql() + + def test_table_tablespace_declaration_is_propagated_and_fails_fast(self): + dialect = SQLServerDialect(version=(16, 0, 0)) + expression = CreateTableExpression( + dialect, + "tablespaced_table", + [ColumnDefinition(dialect, "id", IntegerType(dialect))], + tablespace="ts_data", + ) + assert expression.tablespace == "ts_data" + assert dialect.supports_table_tablespace() is False + with pytest.raises(UnsupportedFeatureError, match="TABLESPACE"): + expression.to_sql() class TestSQLServerViewCapabilityGating: """Tests for SQL Server VIEW DDL capability gating.""" + def test_create_view_check_option_gated(self): + """WITH CHECK OPTION must fail fast when the capability is off.""" + dialect = SQLServerDialect() + query = QueryExpression( + dialect, select=[Column(dialect, "id")], from_=TableExpression(dialect, "t") + ) + expr = CreateViewExpression( + dialect, + view_name="v", + query=query, + options=ViewOptions(check_option=ViewCheckOption.CASCADED), + ) + with patch.object(type(dialect), "supports_view_check_option", return_value=False): + with pytest.raises(UnsupportedFeatureError, match="CHECK OPTION"): + expr.to_sql() + def test_create_or_replace_view_not_supported(self): """SQL Server does not support CREATE OR REPLACE VIEW.""" dialect = SQLServerDialect(version=(16, 0, 0)) 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 f846c1a..0c6fde3 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 @@ -17,6 +17,9 @@ ColumnConstraintType, ) from rhosocial.activerecord.backend.expression.types import DateType, IntegerType, VarCharType +from rhosocial.activerecord.backend.impl.sqlserver.expression.partition import ( + SQLServerPartitionByRangeClause, +) from rhosocial.activerecord.backend.dialect.mixins import ( DDLColumnMixin, ExpressionMixin, @@ -97,10 +100,7 @@ class TestSQLServerPartitionClause: def test_format_partition_clause_with_scheme(self): d = SQLServerDialect(SQL_SERVER_2022) col = Column(d, "create_date") - partition = PartitionClause( - d, PartitionStrategy.RANGE, [col], - dialect_options={"partition_scheme": "ps_sales"}, - ) + partition = SQLServerPartitionByRangeClause(d, [col], "ps_sales") sql, params = d.format_partition_clause(partition) assert "ON [ps_sales] ([create_date])" in sql assert params == () @@ -109,10 +109,7 @@ def test_format_partition_clause_multiple_keys(self): d = SQLServerDialect(SQL_SERVER_2022) col1 = Column(d, "year") col2 = Column(d, "month") - partition = PartitionClause( - d, PartitionStrategy.RANGE, [col1, col2], - dialect_options={"partition_scheme": "ps_date"}, - ) + partition = SQLServerPartitionByRangeClause(d, [col1, col2], "ps_date") sql, params = d.format_partition_clause(partition) assert "ON [ps_date] ([year], [month])" in sql @@ -266,10 +263,7 @@ def test_create_table_with_partition_on_old_version(self): pk = ColumnConstraint(d, ColumnConstraintType.PRIMARY_KEY) col_def = ColumnDefinition(d, "id", IntegerType(d), constraints=[pk]) col = Column(d, "id") - partition = PartitionClause( - d, PartitionStrategy.RANGE, [col], - dialect_options={"partition_scheme": "ps_test"}, - ) + partition = SQLServerPartitionByRangeClause(d, [col], "ps_test") stmt = CreateTableExpression( d, "test", columns=[col_def], @@ -280,16 +274,11 @@ def test_create_table_with_partition_on_old_version(self): # Helper wrappers for tests -class SqlServerPartitionClause(PartitionClause): +class SqlServerPartitionClause(SQLServerPartitionByRangeClause): """Test helper that constructs the KVS dict for scheme name.""" def __init__(self, dialect, keys, *, partition_scheme): - super().__init__( - dialect, - PartitionStrategy.RANGE, - keys, - dialect_options={"partition_scheme": partition_scheme}, - ) + super().__init__(dialect, keys, partition_scheme) def _get_protocol_methods(protocol: type) -> Set[str]: 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 2827f2b..3149c94 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 @@ -61,9 +61,11 @@ def get_all_generic_protocols() -> dict: dialect_protocols.ArraySupport, dialect_protocols.AutoIncrementSupport, dialect_protocols.CTESupport, + dialect_protocols.ColumnAttributeSupport, dialect_protocols.CollationSupport, dialect_protocols.ConstraintSupport, - dialect_protocols.DDLTypeSupport, + dialect_protocols.DataTypeSupport, + dialect_protocols.UserDefinedTypeSupport, dialect_protocols.ExplainSupport, dialect_protocols.FilterClauseSupport, dialect_protocols.FunctionSupport, @@ -104,8 +106,12 @@ def get_all_generic_protocols() -> dict: # decision (move to SQLSERVER_PROTOCOLS or revert). SQLSERVER_NOT_IMPLEMENTED = [ # --- Intentional non-support --- + # SQL Server has no standalone COMMENT ON statement; comments use + # sp_addextendedproperty (not modelled as a COMMENT ON expression). + dialect_protocols.CommentSupport, # The generic DatabaseSupport protocol is not composed by SQLServerDialect. dialect_protocols.DatabaseSupport, + dialect_protocols.DomainSupport, # 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, @@ -153,7 +159,10 @@ def test_does_not_implement_protocol(self, dialect, protocol): def test_positive_and_negative_lists_partition_all_protocols(self): """Every generic protocol must be classified for SQL Server.""" - all_protos = set(get_all_generic_protocols()) + all_protos = { + protocol.__name__ + for protocol in get_all_generic_protocols().values() + } positive = { p.__name__ for p in SQLSERVER_PROTOCOLS diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_type_ddl.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_type_ddl.py new file mode 100644 index 0000000..ce6ec0e --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_type_ddl.py @@ -0,0 +1,681 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_type_ddl.py +"""SQL Server schema-level TYPE DDL SQL, capability, and protocol tests.""" + +import pytest + +from rhosocial.activerecord.backend.expression import RawSQLPredicate +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.dialect.mixins import UserDefinedTypeMixin +from rhosocial.activerecord.backend.dialect.protocols import UserDefinedTypeSupport +from rhosocial.activerecord.backend.expression.serialization import ( + ExpressionRegistry, + deserialize, + deserialize_json, + deserialize_xml, + serialize, + serialize_json, + serialize_xml, +) +from rhosocial.activerecord.backend.expression.statements import ( + AlterTypeExpression, + ColumnConstraint, + ColumnConstraintType, + ColumnDefinition, + CreateTypeExpression, + DropTypeExpression, + TableConstraint, + TableConstraintType, +) +from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType +from rhosocial.activerecord.backend.impl.dummy.expression import _DummyTypeAlterAction +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig +from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect +from rhosocial.activerecord.backend.impl.sqlserver.expression.ddl.type import ( + SQLServerAliasTypeDefinition, + SQLServerClrTypeDefinition, + SQLServerDropTypeExpression, + SQLServerRenameTypeExpression, + SQLServerTableTypeDefinition, +) +from rhosocial.activerecord.backend.impl.sqlserver.expression.index import ( + SQLServerIndexDefinition, +) +from rhosocial.activerecord.backend.impl.sqlserver.mixins import SQLServerTypeDDLMixin +from rhosocial.activerecord.backend.impl.sqlserver.protocols import ( + SQLServerUserDefinedTypeSupport, +) + +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) + + +def test_type_protocol_and_mro(): + dialect = SQLServerDialect(SQL_SERVER_2022) + assert issubclass(SQLServerUserDefinedTypeSupport, UserDefinedTypeSupport) + assert isinstance(dialect, SQLServerUserDefinedTypeSupport) + assert isinstance(dialect, UserDefinedTypeSupport) + assert isinstance(dialect, SQLServerTypeDDLMixin) + assert isinstance(dialect, UserDefinedTypeMixin) + mro = SQLServerDialect.__mro__ + assert mro.index(SQLServerTypeDDLMixin) < mro.index(UserDefinedTypeMixin) + assert mro.index(SQLServerUserDefinedTypeSupport) < mro.index(UserDefinedTypeSupport) + + +def test_type_capability_version_boundaries(): + sql2005 = SQLServerDialect(SQL_SERVER_2005) + sql2008 = SQLServerDialect(SQL_SERVER_2008) + sql2012 = SQLServerDialect(SQL_SERVER_2012) + sql2014 = SQLServerDialect(SQL_SERVER_2014) + sql2016 = SQLServerDialect(SQL_SERVER_2016) + + assert sql2005.supports_type_objects() is True + assert sql2005.supports_type_definition(SQLServerAliasTypeDefinition) is True + assert sql2005.supports_type_definition(SQLServerTableTypeDefinition) is False + assert sql2008.supports_type_definition(SQLServerTableTypeDefinition) is True + assert sql2012.supports_memory_optimized_table_types() is False + assert sql2014.supports_memory_optimized_table_types() is True + memory_definition = SQLServerTableTypeDefinition( + sql2012, + columns=[ColumnDefinition(sql2012, "id", IntegerType(sql2012))], + memory_optimized=True, + ) + with pytest.raises(UnsupportedFeatureError): + CreateTypeExpression(sql2012, "too_old", memory_definition).to_sql() + assert sql2012.supports_drop_type_if_exists() is False + assert sql2016.supports_drop_type_if_exists() is True + assert sql2016.supports_create_type_if_not_exists() is False + assert sql2016.supports_create_type_or_replace() is False + assert sql2016.supports_alter_type() is False + assert sql2016.supports_type_alter_action(_DummyTypeAlterAction) is False + table_definition = SQLServerTableTypeDefinition( + sql2005, + columns=[ColumnDefinition(sql2005, "id", IntegerType(sql2005))], + ) + with pytest.raises(UnsupportedFeatureError): + CreateTypeExpression(sql2005, "too_old", table_definition).to_sql() + + +def test_clr_definition_is_platform_gated(): + sqlserver = SQLServerDialect(SQL_SERVER_2022) + managed_instance = SQLServerDialect( + SQL_SERVER_2022, + deployment_target="azure_sql_managed_instance", + ) + azure = SQLServerDialect(SQL_SERVER_2022, deployment_target="azure_sql_database") + fabric = SQLServerDialect(SQL_SERVER_2022, deployment_target="fabric") + definition = SQLServerClrTypeDefinition(sqlserver, "sample_assembly", "Sample.Type") + + assert sqlserver.supports_clr_type_definition() is True + assert sqlserver.supports_type_definition(SQLServerClrTypeDefinition) is True + assert managed_instance.supports_clr_type_definition() is True + assert managed_instance.supports_type_definition(SQLServerClrTypeDefinition) is True + assert azure.supports_clr_type_definition() is False + assert azure.supports_type_definition(SQLServerClrTypeDefinition) is False + assert fabric.supports_clr_type_definition() is False + assert fabric.supports_type_definition(SQLServerClrTypeDefinition) is False + with pytest.raises(UnsupportedFeatureError): + CreateTypeExpression(azure, "clr_type", definition).to_sql() + + +def test_backend_passes_deployment_target_from_connection_config(): + config = SQLServerConnectionConfig( + host="localhost", + database="master", + username="sa", + password="", + deployment_target="azure_sql_database", + ) + backend = SQLServerBackend(connection_config=config) + + assert backend.dialect.deployment_target == "azure_sql_database" + assert backend.dialect.supports_clr_type_definition() is False + + +def test_async_backend_passes_deployment_target_from_connection_config(): + pytest.importorskip("aioodbc") + from rhosocial.activerecord.backend.impl.sqlserver.async_backend import ( + AsyncSQLServerBackend, + ) + + config = SQLServerConnectionConfig( + host="localhost", + database="master", + username="sa", + password="", + deployment_target="fabric", + ) + backend = AsyncSQLServerBackend(connection_config=config) + + assert backend.dialect.deployment_target == "fabric" + assert backend.dialect.supports_clr_type_definition() is False + + +def test_alias_type_schema_and_identifier_escaping(): + dialect = SQLServerDialect(SQL_SERVER_2022) + definition = SQLServerAliasTypeDefinition( + dialect, + VarCharType(dialect, length=50), + nullability="NOT NULL", + ) + expression = CreateTypeExpression( + dialect, + "name]x", + definition, + schema_name="app]schema", + ) + + assert expression.to_sql() == ( + "CREATE TYPE [app]]schema].[name]]x] FROM VARCHAR(50) NOT NULL", + (), + ) + + +def test_type_name_dot_is_a_single_identifier(): + dialect = SQLServerDialect(SQL_SERVER_2022) + definition = SQLServerAliasTypeDefinition(dialect, IntegerType(dialect)) + expression = CreateTypeExpression( + dialect, + "type.with.dot", + definition, + schema_name="app", + ) + + assert expression.to_sql() == ( + "CREATE TYPE [app].[type.with.dot] FROM INT", + (), + ) + + +def test_clr_type_sql(): + dialect = SQLServerDialect(SQL_SERVER_2022) + definition = SQLServerClrTypeDefinition( + dialect, + "sample_assembly", + "Sample.Namespace.UserType", + ) + expression = CreateTypeExpression(dialect, "clr_type", definition, schema_name="app") + + assert expression.to_sql() == ( + "CREATE TYPE [app].[clr_type] EXTERNAL NAME " + "[sample_assembly].[Sample.Namespace.UserType]", + (), + ) + + +def test_table_type_columns_constraints_indexes_and_memory(): + dialect = SQLServerDialect(SQL_SERVER_2014) + columns = [ + ColumnDefinition( + dialect, + "id", + IntegerType(dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)], + ), + ColumnDefinition(dialect, "name", VarCharType(dialect, length=100)), + ] + constraints = [ + TableConstraint( + dialect, + TableConstraintType.PRIMARY_KEY, + columns=["id"], + ) + ] + indexes = [SQLServerIndexDefinition(dialect, "ix_name", ["name"], type="NONCLUSTERED")] + definition = SQLServerTableTypeDefinition( + dialect, + columns=columns, + constraints=constraints, + indexes=indexes, + memory_optimized=True, + ) + expression = CreateTypeExpression(dialect, "inventory_type", definition) + sql, params = expression.to_sql() + + assert sql.startswith("CREATE TYPE [inventory_type] AS TABLE (") + assert "[id] INT NOT NULL" in sql + assert "PRIMARY KEY NONCLUSTERED ([id])" in sql + assert "INDEX [ix_name] NONCLUSTERED ([name])" in sql + assert sql.endswith("WITH (MEMORY_OPTIMIZED = ON)") + assert params == () + + +def test_memory_table_type_requires_an_index(): + dialect = SQLServerDialect(SQL_SERVER_2014) + definition = SQLServerTableTypeDefinition( + dialect, + columns=[ColumnDefinition(dialect, "id", IntegerType(dialect))], + memory_optimized=True, + ) + + with pytest.raises(ValueError, match="at least one index"): + CreateTypeExpression(dialect, "missing_index_type", definition).to_sql() + + +def test_non_memory_table_type_keeps_regular_constraints(): + dialect = SQLServerDialect(SQL_SERVER_2014) + definition = SQLServerTableTypeDefinition( + dialect, + columns=[ + ColumnDefinition(dialect, "id", IntegerType(dialect)), + ColumnDefinition(dialect, "name", VarCharType(dialect, length=20)), + ], + constraints=[ + TableConstraint( + dialect, + TableConstraintType.PRIMARY_KEY, + columns=["id"], + ), + TableConstraint( + dialect, + TableConstraintType.UNIQUE, + columns=["name"], + ), + ], + ) + + sql, params = CreateTypeExpression(dialect, "regular_type", definition).to_sql() + assert "PRIMARY KEY ([id])" in sql + assert "UNIQUE ([name])" in sql + assert "MEMORY_OPTIMIZED" not in sql + assert params == () + + +def test_memory_table_type_column_key_constraints_use_nonclustered(): + dialect = SQLServerDialect(SQL_SERVER_2016) + definition = SQLServerTableTypeDefinition( + dialect, + columns=[ + ColumnDefinition( + dialect, + "id", + IntegerType(dialect), + constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), + ], + ), + ColumnDefinition( + dialect, + "code", + VarCharType(dialect, length=20), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.UNIQUE)], + ), + ], + memory_optimized=True, + ) + + sql, params = CreateTypeExpression(dialect, "column_keys_type", definition).to_sql() + assert "PRIMARY KEY NONCLUSTERED" in sql + assert "UNIQUE NONCLUSTERED" in sql + assert params == () + + +def test_memory_table_type_rejects_unsupported_constraints(): + dialect = SQLServerDialect(SQL_SERVER_2014) + check = RawSQLPredicate(dialect, "1 = 1") + definitions = [ + SQLServerTableTypeDefinition( + dialect, + columns=[ + ColumnDefinition( + dialect, + "id", + IntegerType(dialect), + constraints=[ + ColumnConstraint( + dialect, + ColumnConstraintType.CHECK, + check_condition=check, + ) + ], + ) + ], + indexes=[SQLServerIndexDefinition(dialect, "ix_id", ["id"])], + memory_optimized=True, + ), + SQLServerTableTypeDefinition( + dialect, + columns=[ColumnDefinition(dialect, "id", IntegerType(dialect))], + constraints=[ + TableConstraint( + dialect, + TableConstraintType.CHECK, + check_condition=check, + ) + ], + indexes=[SQLServerIndexDefinition(dialect, "ix_id", ["id"])], + memory_optimized=True, + ), + SQLServerTableTypeDefinition( + dialect, + columns=[ColumnDefinition(dialect, "id", IntegerType(dialect))], + constraints=[ + TableConstraint( + dialect, + TableConstraintType.FOREIGN_KEY, + columns=["id"], + foreign_key_table="other", + foreign_key_columns=["id"], + ) + ], + indexes=[SQLServerIndexDefinition(dialect, "ix_id", ["id"])], + memory_optimized=True, + ), + ] + + for definition in definitions: + with pytest.raises(UnsupportedFeatureError): + CreateTypeExpression(dialect, "invalid_memory_type", definition).to_sql() + + +def test_memory_table_type_default_is_supported_from_2014(): + dialect = SQLServerDialect(SQL_SERVER_2014) + definition = SQLServerTableTypeDefinition( + dialect, + columns=[ + ColumnDefinition( + dialect, + "id", + IntegerType(dialect), + constraints=[ + ColumnConstraint( + dialect, + ColumnConstraintType.DEFAULT, + default_value=0, + ) + ], + ) + ], + indexes=[SQLServerIndexDefinition(dialect, "ix_id", ["id"])], + memory_optimized=True, + ) + + sql, params = CreateTypeExpression(dialect, "default_type", definition).to_sql() + assert "DEFAULT 0" in sql + assert params == () + + +def test_memory_table_type_check_and_unique_version_gates(): + sql2014 = SQLServerDialect(SQL_SERVER_2014) + check_2014 = SQLServerTableTypeDefinition( + sql2014, + columns=[ + ColumnDefinition( + sql2014, + "id", + IntegerType(sql2014), + constraints=[ + ColumnConstraint( + sql2014, + ColumnConstraintType.CHECK, + check_condition=RawSQLPredicate(sql2014, "1 = 1"), + ) + ], + ) + ], + indexes=[SQLServerIndexDefinition(sql2014, "ix_id", ["id"])], + memory_optimized=True, + ) + with pytest.raises(UnsupportedFeatureError, match="2016\\+"): + CreateTypeExpression(sql2014, "check_2014", check_2014).to_sql() + + unique_2014 = SQLServerTableTypeDefinition( + sql2014, + columns=[ + ColumnDefinition( + sql2014, + "code", + IntegerType(sql2014), + constraints=[ + ColumnConstraint(sql2014, ColumnConstraintType.UNIQUE), + ], + ) + ], + indexes=[SQLServerIndexDefinition(sql2014, "ix_code", ["code"])], + memory_optimized=True, + ) + with pytest.raises(UnsupportedFeatureError, match="2016\\+"): + CreateTypeExpression(sql2014, "unique_2014", unique_2014).to_sql() + + sql2016 = SQLServerDialect(SQL_SERVER_2016) + allowed = SQLServerTableTypeDefinition( + sql2016, + columns=[ + ColumnDefinition( + sql2016, + "id", + IntegerType(sql2016), + constraints=[ + ColumnConstraint( + sql2016, + ColumnConstraintType.CHECK, + check_condition=RawSQLPredicate(sql2016, "1 = 1"), + ) + ], + ), + ColumnDefinition( + sql2016, + "code", + IntegerType(sql2016), + constraints=[ + ColumnConstraint(sql2016, ColumnConstraintType.UNIQUE), + ], + ), + ], + indexes=[SQLServerIndexDefinition(sql2016, "ix_id", ["id"])], + memory_optimized=True, + ) + sql, params = CreateTypeExpression(sql2016, "keys_2016", allowed).to_sql() + assert "CHECK (1 = 1)" in sql + assert "UNIQUE NONCLUSTERED" in sql + assert params == () + + +def _memory_type_with_indexes(dialect, count): + columns = [ + ColumnDefinition(dialect, f"column_{index}", IntegerType(dialect)) + for index in range(count) + ] + indexes = [ + SQLServerIndexDefinition( + dialect, + f"ix_{index}", + [f"column_{index}"], + type="NONCLUSTERED", + ) + for index in range(count) + ] + return SQLServerTableTypeDefinition( + dialect, + columns=columns, + indexes=indexes, + memory_optimized=True, + ) + + +@pytest.mark.parametrize("version", [SQL_SERVER_2014, SQL_SERVER_2016]) +def test_memory_table_type_index_limit_before_2017(version): + dialect = SQLServerDialect(version) + definition = _memory_type_with_indexes(dialect, 9) + + with pytest.raises(UnsupportedFeatureError, match="8 indexes"): + CreateTypeExpression(dialect, "too_many_indexes", definition).to_sql() + + +def test_memory_table_type_index_limit_removed_in_2017(): + dialect = SQLServerDialect(SQL_SERVER_2017) + definition = _memory_type_with_indexes(dialect, 9) + + sql, params = CreateTypeExpression(dialect, "many_indexes", definition).to_sql() + assert sql.count("INDEX ") == 9 + assert params == () + + +@pytest.mark.parametrize("version", [SQL_SERVER_2014, SQL_SERVER_2016, SQL_SERVER_2017]) +def test_memory_table_type_include_requires_2019(version): + dialect = SQLServerDialect(version) + definition = SQLServerTableTypeDefinition( + dialect, + columns=[ + ColumnDefinition(dialect, "id", IntegerType(dialect)), + ColumnDefinition(dialect, "payload", IntegerType(dialect)), + ], + indexes=[ + SQLServerIndexDefinition( + dialect, + "ix_id", + ["id"], + include_columns=["payload"], + ) + ], + memory_optimized=True, + ) + + with pytest.raises(UnsupportedFeatureError, match="2019\\+"): + CreateTypeExpression(dialect, "include_type", definition).to_sql() + + +def test_memory_table_type_include_is_supported_from_2019(): + dialect = SQLServerDialect(SQL_SERVER_2019) + definition = SQLServerTableTypeDefinition( + dialect, + columns=[ + ColumnDefinition(dialect, "id", IntegerType(dialect)), + ColumnDefinition(dialect, "payload", IntegerType(dialect)), + ], + indexes=[ + SQLServerIndexDefinition( + dialect, + "ix_id", + ["id"], + include_columns=["payload"], + ) + ], + memory_optimized=True, + ) + + sql, params = CreateTypeExpression(dialect, "include_type", definition).to_sql() + assert "INCLUDE ([payload])" in sql + assert params == () + + +def test_table_type_hash_index_sql(): + dialect = SQLServerDialect(SQL_SERVER_2014) + definition = SQLServerTableTypeDefinition( + dialect, + columns=[ColumnDefinition(dialect, "id", IntegerType(dialect))], + indexes=[ + SQLServerIndexDefinition( + dialect, + "ix_id", + ["id"], + hash_index=True, + bucket_count=1024, + ) + ], + memory_optimized=True, + ) + sql, params = CreateTypeExpression(dialect, "hash_type", definition).to_sql() + assert "NONCLUSTERED HASH ([id]) WITH (BUCKET_COUNT = 1024)" in sql + assert params == () + + +def test_create_and_drop_type_fail_fast_options(): + dialect = SQLServerDialect(SQL_SERVER_2022) + definition = SQLServerAliasTypeDefinition(dialect, IntegerType(dialect)) + + with pytest.raises(UnsupportedFeatureError): + CreateTypeExpression(dialect, "status", definition, if_not_exists=True).to_sql() + with pytest.raises(UnsupportedFeatureError): + CreateTypeExpression(dialect, "status", definition, or_replace=True).to_sql() + with pytest.raises(ValueError): + SQLServerDropTypeExpression(dialect, "status", cascade=True, restrict=True) + with pytest.raises(UnsupportedFeatureError): + SQLServerDropTypeExpression(dialect, "status", cascade=True).to_sql() + with pytest.raises(UnsupportedFeatureError): + SQLServerDropTypeExpression(dialect, "status", restrict=True).to_sql() + + pre_2016 = SQLServerDialect(SQL_SERVER_2012) + with pytest.raises(UnsupportedFeatureError): + DropTypeExpression(pre_2016, "status", schema_name="app", if_exists=True).to_sql() + assert DropTypeExpression( + dialect, + "status", + schema_name="app", + if_exists=True, + ).to_sql() == ("DROP TYPE IF EXISTS [app].[status]", ()) + + +def test_alter_type_is_explicitly_unsupported(): + dialect = SQLServerDialect(SQL_SERVER_2022) + action = _DummyTypeAlterAction(dialect, "renamed") + expression = AlterTypeExpression(dialect, "status", [action]) + + with pytest.raises(UnsupportedFeatureError, match="ALTER TYPE"): + expression.to_sql() + + +def test_rename_type_uses_sp_rename(): + dialect = SQLServerDialect(SQL_SERVER_2022) + expression = SQLServerRenameTypeExpression( + dialect, + "old]name", + "new]name", + schema_name="app]schema", + ) + + assert expression.to_sql() == ( + "EXECUTE sp_rename N'[app]]schema].[old]]name]', " + "N'new]name', N'USERDATATYPE'", + (), + ) + + +def test_type_expressions_are_explicitly_registered(): + dialect = SQLServerDialect(SQL_SERVER_2022) + expressions = ( + SQLServerAliasTypeDefinition(dialect, IntegerType(dialect)), + SQLServerClrTypeDefinition(dialect, "assembly", "Class"), + SQLServerTableTypeDefinition( + dialect, + columns=[ColumnDefinition(dialect, "id", IntegerType(dialect))], + ), + SQLServerDropTypeExpression(dialect, "type_name"), + SQLServerRenameTypeExpression(dialect, "old_name", "new_name"), + ) + for expression in expressions: + fqn = f"{type(expression).__module__}.{type(expression).__name__}" + assert ExpressionRegistry.lookup(fqn) is type(expression) + for encoder, decoder in ( + (serialize, deserialize), + (serialize_json, deserialize_json), + (serialize_xml, deserialize_xml), + ): + restored = decoder(encoder(expression), dialect) + assert type(restored) is type(expression) + assert encoder(restored) == encoder(expression) + + +def test_table_type_rejects_foreign_key_constraint(): + dialect = SQLServerDialect(SQL_SERVER_2022) + definition = SQLServerTableTypeDefinition( + dialect, + columns=[ColumnDefinition(dialect, "id", IntegerType(dialect))], + constraints=[ + TableConstraint( + dialect, + TableConstraintType.FOREIGN_KEY, + columns=["id"], + foreign_key_table="other", + foreign_key_columns=["id"], + ) + ], + ) + with pytest.raises(UnsupportedFeatureError): + CreateTypeExpression(dialect, "invalid_type", definition).to_sql() diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_type_protocol.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_type_protocol.py index 6d29ad6..8e3fa8a 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_type_protocol.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_type_protocol.py @@ -13,34 +13,14 @@ 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) @@ -164,24 +144,15 @@ def test_suggests_unsupported_core_types(self, dialect): ) -class TestDialectOptionsForwarding: - """dialect_options are forwarded through __init__ and participate in equality.""" +class TestDialectOptionsRemoved: + """The data-type value objects no longer carry a dialect_options bag.""" + + def test_constructor_rejects_dialect_options(self): + with pytest.raises(TypeError): + SQLServerNVarCharType(length=100, dialect_options={"foo": "bar"}) - 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)