diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a850841..6e7ff07 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@release/v1.0.0.dev17" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates" - 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@release/v1.0.0.dev17" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates" - 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@release/v1.0.0.dev17" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates" - 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@release/v1.0.0.dev17" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates" - 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@release/v1.0.0.dev17" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates" - 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@release/v1.0.0.dev17" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates" - 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@release/v1.0.0.dev17" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates" - 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@release/v1.0.0.dev17" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates" - 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@release/v1.0.0.dev17" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates" - name: Create test database run: | diff --git a/.gitignore b/.gitignore index 9d0040e..451f87d 100644 --- a/.gitignore +++ b/.gitignore @@ -212,3 +212,8 @@ __marimo__/ .claude/tmp/ tests/config/ test_connection.py +# Worktrees for feature/* and fix/* branches. Never tracked: a `git add -A` +# from the repo root would commit each branch's whole toolchain -- the venv, the +# build directory and the test artifacts -- and the directory disappears when the +# branch merges, so the history would keep a second copy of the source tree. +.worktree/ diff --git a/README.md b/README.md index a7e78d9..fdf6702 100644 --- a/README.md +++ b/README.md @@ -46,11 +46,11 @@ from typing import ClassVar, Optional from rhosocial.activerecord.base import FieldProxy from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig -from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression import CreateTableStatement from rhosocial.activerecord.backend.expression.statements import ( ColumnConstraint, - ColumnConstraintType, ColumnDefinition, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType from rhosocial.activerecord.model import ActiveRecord @@ -79,14 +79,16 @@ User.configure( ) dialect = User.__backend__.dialect -User.__backend__.execute(*CreateTableExpression( +User.__backend__.execute(*CreateTableStatement( dialect, "users", columns=[ ColumnDefinition( dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, - is_auto_increment=True)], + constraints=[ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + )], ), ColumnDefinition(dialect, "name", VarCharType(dialect, 100)), ColumnDefinition(dialect, "email", VarCharType(dialect, 255)), @@ -145,7 +147,7 @@ from rhosocial.activerecord.backend.impl.sqlserver.expression import ( SQLServerCreateTableOptions, ) -CreateTableExpression( +CreateTableStatement( dialect, "sessions", columns=[...], diff --git a/changelog.d/9.added.md b/changelog.d/9.added.md new file mode 100644 index 0000000..f574cf1 --- /dev/null +++ b/changelog.d/9.added.md @@ -0,0 +1,3 @@ +The extended-property family, asked by table name, plus table and column comments read from `MS_Description`; and `views NAME` on the introspect CLI, whose twelve types derive from `_TYPE_CAPABILITIES`. + +Add the SQL Server catalogue questions core has no shape for, on both introspector managers with identical signatures: ``list_schemas`` (``sys.schemas`` with the owning database principal resolved by ``principal_id``, not name), ``list_extended_properties`` (``sys.extended_properties`` -- ``MS_Description`` and friends -- for one catalogue object, optionally one column, with the ``sql_variant`` value cast off the type pyodbc cannot fetch), ``list_procedures`` and ``list_functions`` (``sys.objects`` types ``P``/``PC`` and ``FN``/``IF``/``TF``/``FS``/``FT``, one builder parameterized by the code set), and ``list_sequences`` (``sys.sequences`` with every ``sql_variant`` bound cast to text and parsed back into exact ``Decimal`` values rather than floats that would round a wide sequence's bounds). Each question is built from expression nodes and rendered by the dialect, carries its family name into its cache key so two families never answer each other, and reports a schema-free family as unknown rather than returning nothing. diff --git a/changelog.d/9.changed.md b/changelog.d/9.changed.md new file mode 100644 index 0000000..984fa9a --- /dev/null +++ b/changelog.d/9.changed.md @@ -0,0 +1 @@ +A system table is not a base table; the backend-only catalogue questions are statement classes; and a `NAME` a type cannot use is an error (exit 2) instead of an ignored argument. diff --git a/changelog.d/9.fixed.md b/changelog.d/9.fixed.md new file mode 100644 index 0000000..24929a9 --- /dev/null +++ b/changelog.d/9.fixed.md @@ -0,0 +1,3 @@ +A view's definition is read from `INFORMATION_SCHEMA.VIEWS` rather than left null, and a trigger reports the events that fire it and whether it is disabled. Two returns displaced by inserted switches were restored, and the expression matrix registers the new statement instead of skipping it. + +Fix the ``introspect`` subcommand calling introspector methods that exist nowhere in the backend. It asked ``get_tables``/``get_views``/``get_columns``/``get_indexes``/``get_foreign_keys``/``get_triggers``/``get_constraints``/``get_procedures``/``get_functions``/``get_sequences`` -- core spells those questions ``list_tables``/``list_views``/``list_columns`` and friends -- so every type failed against a live server while the old offline fakes answered the misspelled names happily. The type-to-method mapping now lives in one place (``_steps``) and is exercised twice: the offline dispatch test checks every advertised type against the real introspector classes with no fake in the way, and a live test runs the command itself against a SQL Server. ``table`` and ``database `` now render through ``get_table_info``, whose single query already fills columns, indexes and foreign keys, replacing the three separate calls plus the iteration over every table that triggers used to need. diff --git a/changelog.d/9.internal.md b/changelog.d/9.internal.md new file mode 100644 index 0000000..7352224 --- /dev/null +++ b/changelog.d/9.internal.md @@ -0,0 +1 @@ +The catalogue questions moved to statement classes, the CLI tests follow the removed aggregate, and CI installs core from `release/v1.0.0.dev30` now that core #127 has merged. diff --git a/pyproject.toml b/pyproject.toml index f23194c..daaf225 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,7 @@ dev = [ "ruff>=0.1.0", "pre-commit>=3.0.0", "towncrier>=23.0.0", + "pytest-xdist>=3.0.0", "rhosocial-activerecord-testsuite", ] docs = [ diff --git a/pytest.ini b/pytest.ini index abebe18..1a4a922 100644 --- a/pytest.ini +++ b/pytest.ini @@ -5,9 +5,18 @@ testpaths = tests python_files = test_*.py python_classes = Test* python_functions = test_* -addopts = -v --tb=short -p rhosocial.activerecord.testsuite.conftest +; pythonpath is what makes ``-p plugins.expression_xdist`` loadable: pytest +; applies this ini value *before* it imports the -p plugins, and a plugin +; imported by name has to be findable by name. It also means a plain ``pytest`` +; invocation works without PYTHONPATH=src:tests, which CI sets anyway. +pythonpath = tests +; plugins.expression_xdist is this repository's own: it makes the expression +; area (feature/backend/expression) parallel by default, which the org test +; rules ask for and a conftest cannot do because xdist reads the worker count +; before any conftest loads. See tests/plugins/expression_xdist.py. +addopts = -v --tb=short -p rhosocial.activerecord.testsuite.conftest -p plugins.expression_xdist filterwarnings = ignore::DeprecationWarning markers = requires_sqlserver: mark test as requiring SQL Server connection - slow: mark test as slow running + slow: mark test as slow running \ No newline at end of file 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 4831be5..5a75452 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/alter_table_modifier.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/alter_table_modifier.py @@ -30,11 +30,33 @@ def supports_add_column_if_not_exists(self) -> bool: return False def supports_drop_column_if_exists(self) -> bool: - """``DROP COLUMN IF EXISTS`` is supported since SQL Server 2016.""" + """``DROP COLUMN IF EXISTS`` is supported since SQL Server 2016. + + The "since 2016" floor comes from the official ``ALTER TABLE`` + grammar page; the instance measured here is 2019 only + (``tests/config/sqlserver_scenarios.yaml``: ``sqlserver_2019``, + RTM-CU32-GDR 15.0.4465.1, Developer Edition). + """ return True def supports_drop_constraint_if_exists(self) -> bool: - """``DROP CONSTRAINT IF EXISTS`` is supported since SQL Server 2016.""" + """``DROP CONSTRAINT IF EXISTS`` is supported since SQL Server 2016. + + Measured on 2019 (RTM-CU32-GDR, 15.0.4465.1):: + + ALTER TABLE dbo.probe_t DROP CONSTRAINT IF EXISTS does_not_exist + -> accepted, silent no-op (no result set, no error) + + so ``IF EXISTS`` is what makes dropping a constraint idempotent on + this backend — the plain form is *not* a no-op, it raises + ``'does_not_exist' is not a constraint. (3728)`` followed by + ``Could not drop constraint. See previous errors. (3727)``. + Catching that error pair instead of spelling ``IF EXISTS`` would also + break on MySQL, which has no ``DROP CONSTRAINT IF EXISTS`` at all. + + The "since 2016" floor is from the official ``ALTER TABLE`` grammar + page; no pre-2019 instance was measured here. + """ return True def format_add_column_action(self, action: Any) -> Tuple[str, tuple]: @@ -112,10 +134,13 @@ def format_alter_column_type_action(self, action: Any) -> Tuple[str, tuple]: if data_type is None: raise ValueError("data type is required for ALTER COLUMN") + from rhosocial.activerecord.backend.expression.types import DataType + + type_sql, _ = DataType.as_type(self, data_type).to_sql() parts = [ "ALTER COLUMN", self.format_identifier(action.column_name), - str(data_type), + type_sql, ] collate = getattr(action, "collate", None) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py index 6f30ee0..9cd4d0e 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py @@ -30,6 +30,12 @@ from ..async_transaction import AsyncSQLServerTransactionManager from .backend import SQLServerUnicodeDialect from ..mixins import SQLServerBackendMixin +from ..query_nodes import ( + health_check_sql, + identity_columns_sql, + server_property_value_sql, + session_identity_sql, +) try: import aioodbc @@ -253,8 +259,9 @@ async def _get_cursor(self): async def _get_scope_identity_async(self) -> Optional[int]: """Get the identity value of the last INSERT via @@IDENTITY.""" + sql, params = session_identity_sql(self.dialect) async with await self._get_cursor() as cursor: - await cursor.execute("SELECT @@IDENTITY") + await cursor.execute(sql, params) row = await cursor.fetchone() if row and row[0] is not None: return int(row[0]) @@ -273,13 +280,9 @@ async def _get_identity_columns_async(self, table_name: str) -> Set[str]: return self._identity_columns_cache[cache_key] columns: Set[str] = set() try: + sql, params = identity_columns_sql(self.dialect, table_name) async with await self._get_cursor() as cursor: - await cursor.execute( - "SELECT c.name FROM sys.identity_columns c " - "JOIN sys.tables t ON c.object_id = t.object_id " - f"WHERE t.name = {self.dialect.p()} AND SCHEMA_NAME(t.schema_id) = 'dbo'", - (table_name,), - ) + await cursor.execute(sql, params) for row in await cursor.fetchall(): columns.add(str(row[0]).lower()) except Exception: @@ -413,8 +416,8 @@ async def _execute_statement( data = [] for row in rows: row_dict = dict(zip(column_names, row)) - adapted_row = self._adapt_row_types(row_dict, adapters) - final_row = self._remap_row_columns(adapted_row, mapping) + adapted_row = self.adapt_row_types(row_dict, adapters) + final_row = self.remap_row_columns(adapted_row, mapping) data.append(final_row) elif cursor.description: # Also process result when cursor has data (e.g. OUTPUT clause) @@ -426,8 +429,8 @@ async def _execute_statement( data = [] for row in rows: row_dict = dict(zip(column_names, row)) - adapted_row = self._adapt_row_types(row_dict, adapters) - final_row = self._remap_row_columns(adapted_row, mapping) + adapted_row = self.adapt_row_types(row_dict, adapters) + final_row = self.remap_row_columns(adapted_row, mapping) data.append(final_row) except Exception: data = None @@ -500,8 +503,11 @@ async def get_server_version(self) -> Tuple[int, int, int]: await self.connect() try: + probe, probe_params = server_property_value_sql( + self.dialect, "ProductVersion" + ) async with await self._get_cursor() as cursor: - await cursor.execute("SELECT SERVERPROPERTY('ProductVersion')") + await cursor.execute(probe, probe_params) row = await cursor.fetchone() if row: @@ -536,8 +542,9 @@ async def ping(self, reconnect: bool = True) -> bool: return True return False + probe, probe_params = health_check_sql(self.dialect) async with await self._get_cursor() as cursor: - await cursor.execute("SELECT 1") + await cursor.execute(probe, probe_params) await cursor.fetchone() return True diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py index 0c1e3bc..5a66644 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py @@ -32,6 +32,7 @@ from ..dialect import SQLServerDialect from ..transaction import SQLServerTransactionManager from ..mixins import SQLServerBackendMixin, SQLServerConcurrencyMixin +from ..query_nodes import health_check_sql, server_property_value_sql class SQLServerUnicodeDialect(SQLServerDialect): @@ -289,8 +290,9 @@ def _get_cursor(self): self.connect() try: + probe, probe_params = health_check_sql(self.dialect) cursor = self._connection.cursor() - cursor.execute("SELECT 1") + cursor.execute(probe, probe_params) cursor.fetchone() cursor.close() except (PyODBCError, InterfaceError) as e: @@ -460,8 +462,11 @@ def get_server_version(self) -> Tuple[int, int, int]: cursor = None try: + probe, probe_params = server_property_value_sql( + self.dialect, "ProductVersion" + ) cursor = self._get_cursor() - cursor.execute("SELECT SERVERPROPERTY('ProductVersion')") + cursor.execute(probe, probe_params) version_str = cursor.fetchone()[0] parts = version_str.split('.') @@ -497,8 +502,9 @@ def ping(self, reconnect: bool = True) -> bool: return True return False + probe, probe_params = health_check_sql(self.dialect) cursor = self._get_cursor() - cursor.execute("SELECT 1") + cursor.execute(probe, probe_params) cursor.fetchone() cursor.close() return True diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/README.md b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/README.md new file mode 100644 index 0000000..b0b649c --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/README.md @@ -0,0 +1,97 @@ +# `introspect` — SQL Server metadata, from the command line + +The `introspect` subcommand asks a live SQL Server instance what it holds, and +prints the answer. Every question runs through the backend's introspector, so +what the CLI reports is what the library sees — the same objects, the same +types, the same parsing — rather than a second implementation that can drift +from it. + +```bash +python -m rhosocial.activerecord.backend.impl.sqlserver introspect [NAME] [OPTIONS] +``` + +## What it can ask about + +| Type | `NAME` | Reads | Notes | +| :--- | :--- | :--- | :--- | +| `tables` | — | `sys.objects`, `sys.schemas` | Base tables and views in the schema. `--include-system` adds the engine's own. | +| `views` | view name, optional | `INFORMATION_SCHEMA.VIEWS` | Views with their `definition` and `is_updatable`; a name answers for one view. | +| `columns` | table name, **required** | `INFORMATION_SCHEMA.COLUMNS` | One table's columns: name, type, nullability, default, and the identity flag. | +| `indexes` | table name, **required** | `sys.indexes`, `sys.index_columns` | One table's indexes, including the primary key. | +| `foreign-keys` | table name, **required** | `sys.foreign_keys`, `sys.foreign_key_columns` | One table's constraints and the columns each side names. | +| `extended-properties` | table name, **required** | `sys.extended_properties`, `sys.tables` | One table's properties — `MS_Description` above all, which is where SQL Server keeps a table's comment. | +| `triggers` | table name, optional | `sys.triggers`, `sys.trigger_events` | Triggers, with their firing `events` and their `status` (`ENABLED` / `DISABLED`); a name narrows to one table. | +| `database` | — | `sys.databases`, `@@VERSION` | Instance version and the databases visible to this login. | +| `procedures` | — | `sys.procedures` | Stored procedures. | +| `functions` | — | `sys.objects` | Functions. | +| `sequences` | — | `sys.sequences` | Sequences with their current value and bounds. | +| `schemas` | — | `sys.schemas` | The schemas in the database, with their owners. | + +`NAME` means different things for different types, so the column above is part +of the interface: + +* **required** — `columns`, `indexes`, `foreign-keys` and + `extended-properties` cannot be answered without it; leaving it out exits 1. +* **optional** — `views` takes a *view* name and `triggers` takes a *table* + name. Given one, the type answers for that object; left out, it lists. +* **refused** — every other type answers for the database or the schema, so a + name is not a filter it could apply. `tables users` exits 2 rather than + listing every table, because a silently ignored name reads as a filter that + matched everything. + +A name that names no object exits 1 (`views nope`, `extended-properties nope`). +Exit 2 is reserved for a request this server cannot serve: an unknown type, a +family the connected server does not support, or a name the type cannot use. + +## Options + +| Option | Default | Description | +| :--- | :--- | :--- | +| `-o, --output` | `table` | `table` (rich), `json`, `csv`, `tsv`. | +| `--include-system` | off | Include system objects. | +| `--host`, `--port`, `--database` | env or defaults | Connection endpoint. `SQLSERVER_*` env vars are honoured, and arguments override them. | +| `--user` / `--username`, `--password` | env | Credentials. | +| `--trusted-connection` | off | Use Windows integrated authentication. | +| `--driver` | _None_ | ODBC driver name. | +| `--encrypt` / `--no-encrypt` | _per driver_ | TLS for the connection. | +| `--trust-server-certificate` / `--no-trust-server-certificate` | _per driver_ | Whether to verify the server certificate. | +| `--async` | off | Drive the asynchronous backend. | +| `--rich-ascii` | off | ASCII characters for table borders. | + +## Notes + +* **Read-only.** `introspect` never writes. It does not create, drop, or + alter anything, so it is safe to run against an instance whose schema + matters. +* **The connection fixes the scope.** A run names one database, so the + catalogue views are asked about that database; there is no second + question about which schema to read. SQL Server's default schema is + `dbo`, and a login that changes it changes it for the connection. +* **`--driver` matters when the default is wrong.** The CLI's default driver + string is a recent one; if your machine has only an older ODBC driver + installed, pass it explicitly. +* **One question per invocation.** A CLI process is one connection that + opens, answers, and closes — there is no cache carried between runs. + +## Examples + +```bash +# The SQL Server-specific families +python -m rhosocial.activerecord.backend.impl.sqlserver introspect schemas --database sales +python -m rhosocial.activerecord.backend.impl.sqlserver introspect procedures --database sales +python -m rhosocial.activerecord.backend.impl.sqlserver introspect sequences -o json + +# One table's catalogue: its columns, its indexes, its foreign keys +python -m rhosocial.activerecord.backend.impl.sqlserver introspect columns orders --database sales +python -m rhosocial.activerecord.backend.impl.sqlserver introspect foreign-keys orders --database sales + +# The comment SQL Server keeps in sys.extended_properties +python -m rhosocial.activerecord.backend.impl.sqlserver introspect extended-properties orders --database sales + +# One view, with its definition; one table's triggers, with their events +python -m rhosocial.activerecord.backend.impl.sqlserver introspect views active_orders --database sales +python -m rhosocial.activerecord.backend.impl.sqlserver introspect triggers orders --database sales + +# Named connection saved by the named-connection subcommand +python -m rhosocial.activerecord.backend.impl.sqlserver introspect tables --named-connection prod --database sales +``` diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/introspect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/introspect.py index 06f2cd9..d811bf4 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/introspect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/introspect.py @@ -1,8 +1,17 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/cli/introspect.py """introspect subcommand - Database introspection. -SQL Server introspect includes the 'procedures' and 'functions' types -in addition to the standard types. +Every type this command offers is answered by ``backend.introspector``. The +introspector speaks the core API -- ``list_tables``/``list_views``/ +``list_columns``/... -- plus this backend's own ``list_schemas``, +``list_procedures``, ``list_functions`` and ``list_sequences``; there are no +``get_tables``/``get_views``/``get_columns`` methods anywhere, and asking for +them was why this command used to fail for every type. + +The mapping from a type name to an introspector method lives in ``_steps`` +and nowhere else, so a new type is one entry there plus one title. The sync +and async paths differ in exactly one keyword: whether the method is +awaited. """ import argparse @@ -10,28 +19,77 @@ import sys from dataclasses import asdict, is_dataclass from enum import Enum -from typing import Any +from typing import Any, Dict, List, Optional, Tuple -from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend from rhosocial.activerecord.backend.errors import ConnectionError, QueryError +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend from .connection import add_connection_args, resolve_connection_config_from_args from .output import create_provider OUTPUT_CHOICES = ['table', 'json', 'csv', 'tsv'] -INTROSPECT_TYPES = [ - "tables", - "views", - "table", - "columns", - "indexes", - "foreign-keys", - "triggers", - "database", - "procedures", - "functions", - "sequences", -] +#: CLI type -> the dialect capability switch that answers for it. +#: +#: The advertised list below is derived from this mapping, so a family cannot +#: be added to one place and forgotten in the other: a type is in both or in +#: neither. A type whose switch is False on the *connected* server is refused +#: when handled rather than run and answered with an empty list -- "this +#: server cannot answer that" and "this database holds none" are different +#: answers. +_TYPE_CAPABILITIES = { + "tables": "supports_table_introspection", + "views": "supports_view_introspection", + "columns": "supports_column_introspection", + "indexes": "supports_index_introspection", + "foreign-keys": "supports_foreign_key_introspection", + "extended-properties": "supports_extended_property_introspection", + "triggers": "supports_trigger_introspection", + "database": "supports_database_info", + "procedures": "supports_procedure_introspection", + "functions": "supports_function_introspection", + "sequences": "supports_sequence_introspection", + "schemas": "supports_schema_introspection", +} + +INTROSPECT_TYPES = list(_TYPE_CAPABILITIES) + +#: What a NAME means per type, and whether the type takes one at all. +#: +#: ``required`` -- the name *is* the question (one table's columns); +#: ``optional`` -- the name narrows a list to one table or one view; +#: ``forbidden`` -- the type answers for the database, and a name that looks +#: like a filter would be silently ignored, which is the one thing a NAME +#: argument must never be. +#: +#: ``views NAME`` is a view name and ``triggers NAME`` is a *table* name: the +#: two are not the same kind of argument, which is why this table exists. +_NAME_RULES = { + "tables": "forbidden", + "views": "optional", + "columns": "required", + "indexes": "required", + "foreign-keys": "required", + "extended-properties": "required", + "triggers": "optional", + "database": "forbidden", + "procedures": "forbidden", + "functions": "forbidden", + "sequences": "forbidden", + "schemas": "forbidden", +} + +#: Titles for the one-result types. The table-scoped types build their title +#: from the table name instead (see ``_title_for``). +_RESULT_TITLES = { + "list_tables": "Tables", + "list_views": "Views", + "list_triggers": "Triggers", + "list_procedures": "Stored Procedures", + "list_functions": "User-Defined Functions", + "list_sequences": "Sequences", + "list_schemas": "Schemas", + "get_database_info": "Database Info", +} def create_parser(subparsers): @@ -40,14 +98,17 @@ def create_parser(subparsers): 'introspect', help='Database introspection', epilog="""Examples: + # List all schemas and their owners + %(prog)s schemas --database mydb + # List all tables in database %(prog)s tables --database mydb # List all views %(prog)s views --database mydb - # Get detailed table info (columns, indexes, foreign keys) - %(prog)s table users --database mydb + # One view, with its definition and IS_UPDATABLE + %(prog)s views active_users --database mydb # Get column details for a table %(prog)s columns users --database mydb @@ -58,8 +119,12 @@ def create_parser(subparsers): # Get foreign key relationships %(prog)s foreign-keys users --database mydb - # List triggers + # Get a table's extended properties (MS_Description and friends) + %(prog)s extended-properties users --database mydb + + # List triggers (all of them, or one table's) %(prog)s triggers --database mydb + %(prog)s triggers users --database mydb # Get database information %(prog)s database --database mydb @@ -76,12 +141,16 @@ def create_parser(subparsers): # Output as JSON %(prog)s tables --database mydb -o json - # Specify schema - %(prog)s tables --database mydb --schema dbo - # Using environment variables for connection export SQLSERVER_HOST=localhost SQLSERVER_DATABASE=mydb SQLSERVER_USERNAME=sa %(prog)s tables + +NAME per type: + columns, indexes, foreign-keys, extended-properties + a table name, required + views a view name, optional + triggers a table name, optional + everything else no name: refused with exit 2, never ignored """, formatter_class=argparse.RawDescriptionHelpFormatter, ) @@ -110,12 +179,10 @@ def create_parser(subparsers): "name", nargs="?", default=None, - help="Table/view name (required for some types)", - ) - parser.add_argument( - "--schema", - default="dbo", - help='Schema name (default: dbo)', + help=( + "Object name: a table name for the table-scoped types, a view name " + "for views. Types that answer for the database take none." + ), ) parser.add_argument( "--include-system", @@ -126,6 +193,50 @@ def create_parser(subparsers): return parser +def _refuse_unused_name(args) -> None: + """Refuse a NAME the requested type cannot use, rather than ignore it. + + ``introspect tables users`` used to list every table and exit 0, which + reads as "the filter matched everything" or "the filter was applied" -- + the most misleading shape a wrong answer can take. A name for a type that + answers for the database is a misuse of the argument, so it leaves with 2. + """ + if not args.name: + return + if _NAME_RULES.get(args.type) != "forbidden": + return + print( + f"Error: {args.type} introspection takes no name", + file=sys.stderr, + ) + sys.exit(2) + + +def _require_capability(backend, kind: str) -> None: + """Refuse a type the connected server cannot answer. + + The dialect is adapted before this runs, so the switch answers for the + server in front of us rather than for the newest release this backend + knows. + """ + switch = _TYPE_CAPABILITIES.get(kind) + if switch is None: + return + dialect = getattr(backend, "dialect", None) + if dialect is None: + # A stub standing in for the backend (the offline CLI tests) has no + # dialect to ask; a real one always does. + return + supported = getattr(dialect, switch, None) + if supported is None or supported(): + return + print( + f"Error: {kind} introspection is not supported by this server", + file=sys.stderr, + ) + sys.exit(2) + + def handle(args): """Handle the introspect subcommand.""" provider = create_provider(args.output, ascii_borders=args.rich_ascii) @@ -135,6 +246,10 @@ def handle(args): print("Error: --database is required for introspection", file=sys.stderr) sys.exit(1) + # Before a connection is opened: whether a NAME belongs to the type is a + # question about the request, not about the server. + _refuse_unused_name(args) + config = resolve_connection_config_from_args(args) if args.is_async: @@ -146,6 +261,175 @@ def handle(args): _handle_introspect_sync(args, backend, provider) +# --------------------------------------------------------------------------- +# Introspection plan +# --------------------------------------------------------------------------- + + +def _require_name(args, kind: str) -> str: + """Return the object name a table-scoped type needs, or exit.""" + if not args.name: + print( + f"Error: Table name is required for '{kind}' introspection", + file=sys.stderr, + ) + sys.exit(1) + return args.name + + +def _steps(args) -> List[Tuple[str, Dict[str, Any]]]: + """Map the requested type to ``(introspector method, kwargs)`` calls. + + This is the single place that decides which introspector method answers + which type: one type, one question. The table detail that used to be + assembled here went with the aggregate type it belonged to. + + The NAME each type accepts is decided here too, because it *is* part of + the question: ``views NAME`` is the single-view question, not a filter over + the list. + """ + kind = args.type + + if kind == "database": + return [("get_database_info", {})] + if kind == "tables": + return [( + "list_tables", + {"schema": None, "include_system": args.include_system}, + )] + if kind == "views": + # One named view is a different question from the list: it answers + # with the definition and ``is_updatable``. The list now carries the + # same two facts, so filtering it here would be a second way to ask + # the first question. + if args.name: + return [("get_view_info", {"view_name": args.name, "schema": None})] + return [( + "list_views", + {"schema": None, "include_system": args.include_system}, + )] + if kind == "triggers": + # ``list_triggers`` already takes an optional table; iterating tables + # here was never needed, and it swallowed each table's error. + return [( + "list_triggers", + {"table_name": args.name, "schema": None}, + )] + if kind == "procedures": + return [("list_procedures", {"schema": None})] + if kind == "functions": + return [("list_functions", {"schema": None})] + if kind == "sequences": + return [("list_sequences", {"schema": None})] + if kind == "schemas": + return [("list_schemas", {})] + + # columns / indexes / foreign-keys / extended-properties + method = f"list_{kind.replace('-', '_')}" + return [( + method, + {"table_name": _require_name(args, kind), "schema": None}, + )] + + +def _title_for(method: str, kwargs: Dict[str, Any]) -> str: + """The heading for one section of output.""" + if method in _RESULT_TITLES: + return _RESULT_TITLES[method] + if method == "get_view_info": + return f"View {kwargs.get('view_name', '')}" + name = kwargs.get("table_name", "") + if method == "list_columns": + return f"Columns of {name}" + if method == "list_indexes": + return f"Indexes of {name}" + if method == "list_foreign_keys": + return f"Foreign Keys of {name}" + if method == "list_extended_properties": + return f"Extended Properties of {name}" + return method + + +#: The per-object questions, and the name each one answers with nothing. A type +#: with no single object to be missing is absent: an empty *list* is a true +#: answer for those ("this schema has no triggers"), not a failed lookup. +_NAMED_TYPES: Dict[str, Tuple[str, str]] = { + "get_view_info": ("View", "view_name"), + "list_extended_properties": ("Table", "table_name"), +} + + +def _missing_from( + method: str, kwargs: Dict[str, Any], value: Any +) -> Optional[Tuple[str, str]]: + """The ``(label, name)`` of a NAME that answered nothing, or ``None``. + + An empty result is only ambiguous for a question that named one object. + Extended properties are the one that can come back empty for two genuinely + different reasons -- a table with no ``MS_Description`` and a table that is + not there both answer with no rows -- so for that type the name is checked + against the catalogue before it is called missing. + """ + if value or method not in _NAMED_TYPES: + return None + label, argument = _NAMED_TYPES[method] + name = kwargs.get(argument) + return None if name is None else (label, name) + + +def _report_missing(missing: Tuple[str, str]) -> None: + """Say that a NAME named nothing, and leave with the "no such object" code.""" + label, name = missing + print(f"Error: {label} '{name}' not found", file=sys.stderr) + sys.exit(1) + + +def _require_found(introspector: Any, method: str, kwargs: Dict[str, Any], value: Any) -> None: + """Refuse a NAME that named nothing, rather than print an empty answer.""" + missing = _missing_from(method, kwargs, value) + if missing is None: + return + if method == "list_extended_properties" and introspector.table_exists( + kwargs["table_name"], kwargs.get("schema") + ): + return # the table is there; it simply carries no properties + _report_missing(missing) + + +async def _require_found_async( + introspector: Any, method: str, kwargs: Dict[str, Any], value: Any +) -> None: + """``_require_found`` against an asynchronous introspector. + + Same question, same answer, one ``await``: the existence read is the + introspector's, and this backend's asynchronous manager answers with a + coroutine. + """ + missing = _missing_from(method, kwargs, value) + if missing is None: + return + if method == "list_extended_properties" and await introspector.table_exists( + kwargs["table_name"], kwargs.get("schema") + ): + return + _report_missing(missing) + + +def _value_sections( + method: str, kwargs: Dict[str, Any], value: Any +) -> List[Tuple[str, Any]]: + """Turn one introspection result into the sections to render. + + One result, one section. Nothing is expanded into parts here any more, + because no type asks more than one question. + """ + + if method == "get_database_info": + return [(_title_for(method, kwargs), [value])] + + return [(_title_for(method, kwargs), value)] + + # --------------------------------------------------------------------------- # Internal helper functions # --------------------------------------------------------------------------- @@ -175,107 +459,31 @@ def _serialize_for_output(obj: Any) -> Any: return str(obj) +def _render(provider, sections: List[Tuple[str, Any]]) -> None: + """Render each section through the configured output provider.""" + for title, value in sections: + data = _serialize_for_output(value) + if not isinstance(data, list): + data = [data] + provider.display_results(data, title=title) + + def _handle_introspect_sync(args, backend: SQLServerBackend, provider): """Handle introspect subcommand synchronously.""" try: backend.connect() + # Adapt before the first question: the dialect's capability gates and + # its version-syntax rendering read a version the handshake supplies, + # and a CLI that skipped this asked them while they still refused. + backend.introspect_and_adapt() + _require_capability(backend, args.type) introspector = backend.introspector - - if args.type == "tables": - tables = introspector.get_tables( - schema=args.schema, - ) - data = _serialize_for_output(tables) - provider.display_results(data, title="Tables") - - elif args.type == "views": - views = introspector.get_views(schema=args.schema) - data = _serialize_for_output(views) - provider.display_results(data, title="Views") - - elif args.type == "table": - if not args.name: - print("Error: Table name is required for 'table' introspection", file=sys.stderr) - sys.exit(1) - columns = introspector.get_columns(args.name, schema=args.schema) - data = _serialize_for_output(columns) - provider.display_results(data, title=f"Columns of {args.name}") - indexes = introspector.get_indexes(args.name, schema=args.schema) - if indexes: - provider.display_results(_serialize_for_output(indexes), title=f"Indexes of {args.name}") - fks = introspector.get_foreign_keys(args.name, schema=args.schema) - if fks: - provider.display_results(_serialize_for_output(fks), title=f"Foreign Keys of {args.name}") - - elif args.type == "columns": - if not args.name: - print("Error: Table name is required for 'columns' introspection", file=sys.stderr) - sys.exit(1) - columns = introspector.get_columns(args.name, schema=args.schema) - data = _serialize_for_output(columns) - provider.display_results(data, title=f"Columns of {args.name}") - - elif args.type == "indexes": - if not args.name: - print("Error: Table name is required for 'indexes' introspection", file=sys.stderr) - sys.exit(1) - indexes = introspector.get_indexes(args.name, schema=args.schema) - data = _serialize_for_output(indexes) - provider.display_results(data, title=f"Indexes of {args.name}") - - elif args.type == "foreign-keys": - if not args.name: - print("Error: Table name is required for 'foreign-keys' introspection", file=sys.stderr) - sys.exit(1) - fks = introspector.get_foreign_keys(args.name, schema=args.schema) - data = _serialize_for_output(fks) - provider.display_results(data, title=f"Foreign Keys of {args.name}") - - elif args.type == "triggers": - if args.name: - triggers = introspector.get_triggers(table=args.name, schema=args.schema) - else: - tables = introspector.get_tables(schema=args.schema) - all_triggers = [] - for t in tables: - tname = t.name if hasattr(t, 'name') else t.get('TABLE_NAME', t.get('name', '')) - if tname: - try: - t_triggers = introspector.get_triggers(table=tname, schema=args.schema) - all_triggers.extend(t_triggers) - except Exception: - pass - triggers = all_triggers - data = _serialize_for_output(triggers) - provider.display_results(data, title="Triggers") - - elif args.type == "database": - if args.name: - constraints = introspector.get_constraints(table=args.name, schema=args.schema) - data = _serialize_for_output(constraints) - provider.display_results([data] if isinstance(data, dict) else data, title=f"Constraints of {args.name}") - else: - tables = introspector.get_tables(schema=args.schema) - db_info = { - "tables": _serialize_for_output(tables), - "schema": args.schema, - } - provider.display_results([db_info], title="Database Info") - - elif args.type == "procedures": - procedures = introspector.get_procedures(schema=args.schema) - data = _serialize_for_output(procedures) - provider.display_results(data, title="Stored Procedures") - - elif args.type == "functions": - functions = introspector.get_functions(schema=args.schema) - data = _serialize_for_output(functions) - provider.display_results(data, title="User-Defined Functions") - - elif args.type == "sequences": - sequences = introspector.get_sequences(schema=args.schema) - data = _serialize_for_output(sequences) - provider.display_results(data, title="Sequences") + sections: List[Tuple[str, Any]] = [] + for method, kwargs in _steps(args): + value = getattr(introspector, method)(**kwargs) + _require_found(introspector, method, kwargs, value) + sections.extend(_value_sections(method, kwargs, value)) + _render(provider, sections) except ConnectionError as e: provider.display_connection_error(e) @@ -287,7 +495,7 @@ def _handle_introspect_sync(args, backend: SQLServerBackend, provider): print(f"Error during introspection: {e}", file=sys.stderr) sys.exit(1) finally: - if backend._connection: # type: ignore + if backend._connection: # type: ignore backend.disconnect() @@ -295,101 +503,15 @@ async def _handle_introspect_async(args, backend, provider): """Handle introspect subcommand asynchronously.""" try: await backend.connect() + await backend.introspect_and_adapt() + _require_capability(backend, args.type) introspector = backend.introspector - - if args.type == "tables": - tables = introspector.get_tables(schema=args.schema) - data = _serialize_for_output(tables) - provider.display_results(data, title="Tables") - - elif args.type == "views": - views = introspector.get_views(schema=args.schema) - data = _serialize_for_output(views) - provider.display_results(data, title="Views") - - elif args.type == "table": - if not args.name: - print("Error: Table name is required for 'table' introspection", file=sys.stderr) - sys.exit(1) - columns = introspector.get_columns(args.name, schema=args.schema) - data = _serialize_for_output(columns) - provider.display_results(data, title=f"Columns of {args.name}") - indexes = introspector.get_indexes(args.name, schema=args.schema) - if indexes: - provider.display_results(_serialize_for_output(indexes), title=f"Indexes of {args.name}") - fks = introspector.get_foreign_keys(args.name, schema=args.schema) - if fks: - provider.display_results(_serialize_for_output(fks), title=f"Foreign Keys of {args.name}") - - elif args.type == "columns": - if not args.name: - print("Error: Table name is required for 'columns' introspection", file=sys.stderr) - sys.exit(1) - columns = introspector.get_columns(args.name, schema=args.schema) - data = _serialize_for_output(columns) - provider.display_results(data, title=f"Columns of {args.name}") - - elif args.type == "indexes": - if not args.name: - print("Error: Table name is required for 'indexes' introspection", file=sys.stderr) - sys.exit(1) - indexes = introspector.get_indexes(args.name, schema=args.schema) - data = _serialize_for_output(indexes) - provider.display_results(data, title=f"Indexes of {args.name}") - - elif args.type == "foreign-keys": - if not args.name: - print("Error: Table name is required for 'foreign-keys' introspection", file=sys.stderr) - sys.exit(1) - fks = introspector.get_foreign_keys(args.name, schema=args.schema) - data = _serialize_for_output(fks) - provider.display_results(data, title=f"Foreign Keys of {args.name}") - - elif args.type == "triggers": - if args.name: - triggers = introspector.get_triggers(table=args.name, schema=args.schema) - else: - tables = introspector.get_tables(schema=args.schema) - all_triggers = [] - for t in tables: - tname = t.name if hasattr(t, 'name') else t.get('TABLE_NAME', t.get('name', '')) - if tname: - try: - t_triggers = introspector.get_triggers(table=tname, schema=args.schema) - all_triggers.extend(t_triggers) - except Exception: - pass - triggers = all_triggers - data = _serialize_for_output(triggers) - provider.display_results(data, title="Triggers") - - elif args.type == "database": - if args.name: - constraints = introspector.get_constraints(table=args.name, schema=args.schema) - data = _serialize_for_output(constraints) - provider.display_results([data] if isinstance(data, dict) else data, title=f"Constraints of {args.name}") - else: - tables = introspector.get_tables(schema=args.schema) - db_info = { - "tables": _serialize_for_output(tables), - "schema": args.schema, - } - provider.display_results([db_info], title="Database Info") - - elif args.type == "procedures": - procedures = introspector.get_procedures(schema=args.schema) - data = _serialize_for_output(procedures) - provider.display_results(data, title="Stored Procedures") - - elif args.type == "functions": - functions = introspector.get_functions(schema=args.schema) - data = _serialize_for_output(functions) - provider.display_results(data, title="User-Defined Functions") - - elif args.type == "sequences": - sequences = introspector.get_sequences(schema=args.schema) - data = _serialize_for_output(sequences) - provider.display_results(data, title="Sequences") + sections: List[Tuple[str, Any]] = [] + for method, kwargs in _steps(args): + value = await getattr(introspector, method)(**kwargs) + await _require_found_async(introspector, method, kwargs, value) + sections.extend(_value_sections(method, kwargs, value)) + _render(provider, sections) except ConnectionError as e: provider.display_connection_error(e) @@ -401,5 +523,5 @@ async def _handle_introspect_async(args, backend, provider): print(f"Error during introspection: {e}", file=sys.stderr) sys.exit(1) finally: - if backend._connection: # type: ignore + if backend._connection: # type: ignore await backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/collation.py b/src/rhosocial/activerecord/backend/impl/sqlserver/collation.py index 401e516..4d103d9 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/collation.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/collation.py @@ -7,8 +7,17 @@ from typing import Dict, Optional, Tuple -class SQLServerCollation(Enum): - """Common SQL Server collations for expression-level COLLATE.""" +class SQLServerCollation(str, Enum): + """Common SQL Server collations for expression-level COLLATE. + + A member *is* the word it carries -- ``str.__str__``, not ``Enum.__str__``, + which would win the base order otherwise -- because the member rides inside + ``CollateFunction``'s params and a param that travels in an artifact must be + a JSON scalar. Plain ``Enum`` raises "not one of the value kinds" the moment + anything serialises the node, which is how the spelling was settled. + """ + + __str__ = str.__str__ LATIN1_GENERAL_CI_AS = "Latin1_General_CI_AS" LATIN1_GENERAL_CS_AS = "Latin1_General_CS_AS" diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index e6fa3ca..6950531 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -7,7 +7,6 @@ """ from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING -import copy from rhosocial.activerecord.backend.dialect.base import SQLDialectBase from rhosocial.activerecord.backend.expression import bases @@ -152,6 +151,7 @@ from .collation import validate_sqlserver_collation_name from .alter_table_modifier import SQLServerAlterColumnModifierMixin from .mixins.identity_column import SQLServerIdentityColumnMixin +from .mixins.introspection import SQLServerIntrospectionMixin from .mixins.sequence import SQLServerSequenceMixin from .mixins.pivot import SQLServerPivotMixin from .mixins.graph import SQLServerGraphMixin @@ -159,6 +159,7 @@ from .mixins.memory_optimized import SQLServerMemoryOptimizedMixin from .mixins.ddl_type import SQLServerTypeDDLMixin from .mixins.trim import SQLServerTrimMixin +from .mixins.literal import SQLServerLiteralValueMixin from .mixins.routine import SQLServerRoutineMixin from .mixins.trigger import SQLServerTriggerDdlMixin from .mixins.protocol_support import SQLServerProtocolSupportMixin @@ -207,8 +208,8 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression import bases - from rhosocial.activerecord.backend.expression.collation import CollateExpression - from rhosocial.activerecord.backend.expression.advanced_functions import ArrayExpression, OrderedSetAggregation + from rhosocial.activerecord.backend.expression.collation import CollateFunction + from rhosocial.activerecord.backend.expression.advanced_functions import ArrayOperation, OrderedSetAggregation from rhosocial.activerecord.backend.expression.graph import MatchClause from rhosocial.activerecord.backend.expression.query_parts import ( LimitOffsetClause, @@ -216,30 +217,30 @@ QualifyClause, ) from rhosocial.activerecord.backend.expression.statements import ( - ExplainExpression, - CreateViewExpression, - DropViewExpression, - CreateMaterializedViewExpression, + ExplainStatement, + CreateViewStatement, + DropViewStatement, + CreateMaterializedViewStatement, ReturningClause, - InsertExpression, + InsertStatement, ColumnDefinition, TableConstraint, IndexDefinition, - CreateTableExpression, - CreateIndexExpression, - DropTableExpression, - CreateSchemaExpression, - DropSchemaExpression, - AlterSequenceExpression, - MergeExpression, - TruncateExpression, + CreateTableStatement, + CreateIndexStatement, + DropTableStatement, + CreateSchemaStatement, + DropSchemaStatement, + AlterSequenceStatement, + MergeStatement, + TruncateStatement, ) from rhosocial.activerecord.backend.expression.transaction import ( - BeginTransactionExpression, - SetTransactionExpression, + BeginTransactionStatement, + SetTransactionStatement, ) from rhosocial.activerecord.backend.expression.statements.fulltext_match import ( - FulltextMatchExpression, + FulltextMatch, ) @@ -254,6 +255,22 @@ class SQLServerDialect( + # The bases below come in bundles: a backend mixin, then the core bundle + # it stands in for. The backend's answer has to come first -- a mixin + # placed after the class it overrides never wins -- so a new mixin goes + # immediately before the core mixin it overrides, and the pairing is read + # off the list. Two positions sit outside that pairing and are called out + # where they occur: a mixin that overrides a method ``SQLDialectBase`` + # defines has to precede the base, and a mixin that subclasses a core + # mixin has to precede it too (C3: a subclass precedes its base). + # Value literals: SQL Server's tier-2 spellings -- 1/0 for the booleans + # T-SQL has no keywords for, the unquoted 0x hex constant, and quoted + # text a UNIQUEIDENTIFIER converts -- ahead of every other base and + # therefore of SQLDialectBase, whose format_literal ladder they refine. + # A mixin placed after the class it overrides never wins, and this one + # overrides the base; the position is first, like the SQLite literal + # mixin's, so the override is visible before the list grows long. + SQLServerLiteralValueMixin, # ``SQLDialectBase`` quotes identifiers with double quotes. Python resolves # base classes left to right, so the bracket-quoting mixin has to precede it # to win. This is also why the dialect body declares no ``format_identifier`` @@ -263,6 +280,13 @@ class SQLServerDialect( # one. The per-kind rendering lives in the core *NameMixin classes listed # below, which is where every object kind is spelled. SQLServerNamespaceMixin, + # Which catalogue questions SQL Server can answer. These override core's + # conservative default of ``False`` -- a default that is right for a + # backend which cannot introspect, and wrong for one which can, because a + # caller gating on the switch would conclude the capability is absent. + # Placed with the other backend answers, before the core bundle each + # replaces. + SQLServerIntrospectionMixin, # A JOIN side is a row source, not an object: it carries the alias the # object has no room for, and renders the relation's name through that # relation's own format__object. @@ -480,17 +504,6 @@ class SQLServerDialect( name = "SQL Server" - _NILADIC_FUNCTION_EQUIVALENTS = { - "NOW": "SYSDATETIME()", - "CURRENT_DATE": "CAST(GETDATE() AS DATE)", - "CURRENT_TIME": "CAST(GETDATE() AS TIME)", - } - - _FUNCTION_NAME_EQUIVALENTS = { - "LENGTH": "LEN", - "CHAR_LENGTH": "LEN", - } - _SQLSERVER_FUNCTION_VERSIONS = { "json_value": (SQL_SERVER_2016, None), "json_query": (SQL_SERVER_2016, None), @@ -583,7 +596,7 @@ def format_auto_increment(self) -> Tuple[str, tuple]: """Return IDENTITY(1,1) for auto-increment columns.""" return "IDENTITY(1,1)", () - def format_array_expression(self, _expr: "ArrayExpression") -> Tuple[str, tuple]: + def format_array_expression(self, _expr: "ArrayOperation") -> Tuple[str, tuple]: """Format array expression - not supported.""" raise UnsupportedFeatureError(self.name, "Array operations", _SUGGESTION_ARRAY_TYPES) @@ -621,7 +634,7 @@ def format_grouping_clause( raise UnsupportedFeatureError(self.name, f"{operation} grouping operation") def format_create_table_statement( - self, expr: "CreateTableExpression" + self, expr: "CreateTableStatement" ) -> Tuple[str, tuple]: """Format CREATE TABLE statement for SQL Server. @@ -651,22 +664,22 @@ def format_create_table_statement( if not isinstance(expr.table, Table): raise TypeError( - f"CreateTableExpression.table must be a Table, " + f"CreateTableStatement.table must be a Table, " f"got {type(expr.table).__name__}" ) - if getattr(expr, "tablespace", None): + if expr.tablespace: raise UnsupportedFeatureError( self.name, "TABLESPACE", "SQL Server does not support table tablespaces.", ) - if getattr(expr, "inherits", None): + if expr.inherits: raise UnsupportedFeatureError( self.name, "table INHERITS", "SQL Server does not support table inheritance.", ) - if getattr(getattr(expr, "table_options", None), "comment", None): + if expr.table_options is not None and expr.table_options.comment: # 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. @@ -726,8 +739,14 @@ def format_create_table_statement( column_parts.append(idx_sql) all_params.extend(idx_params) - # SQL Graph edge constraints (CONNECTION) are table-level constraints. - edge_constraints = getattr(expr, "edge_constraints", None) + # SQL Graph edge constraints (CONNECTION) are table-level constraints + # declared by the SQL Server CREATE TABLE node; the generic statement + # has no such field, so the dispatch is on the declared class. + from .expression.create_table import SQLServerCreateTableStatement + + edge_constraints = ( + expr.edge_constraints if isinstance(expr, SQLServerCreateTableStatement) else None + ) if edge_constraints: for edge_constraint in edge_constraints: ec_sql, ec_params = edge_constraint.to_sql() @@ -742,13 +761,16 @@ def format_create_table_statement( all_params.extend(partition_params) from .expression.table_options import SQLServerCreateTableOptions - table_options = getattr(expr, "table_options", None) + table_options = expr.table_options 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 = getattr(expr, "graph_table_kind", None) + # SQL Graph table kind (AS NODE / AS EDGE), if requested. Like the + # edge constraints above, the field belongs to the SQL Server node. + graph_kind = ( + expr.graph_table_kind if isinstance(expr, SQLServerCreateTableStatement) else None + ) if graph_kind is not None: from .expression.ddl.graph import ( SQLServerAsGraphTableExpression, @@ -774,14 +796,43 @@ def format_column_definition( Accepts both the generic ``ColumnDefinition`` and the SQL Server ``SQLServerColumnDefinition``; the latter's SQL Server-only attributes (``sparse`` / ``rowguidcol``) are rendered here. + + Which kind each constraint is comes from its **branch class**, not from + a tag: ``ColumnConstraint`` holds the ``CONSTRAINT `` prefix and + the trailing traits, and the branch holds what belongs to one + alternative. Everything except the two kinds whose spelling the + memory-optimized flag changes is delegated to + :meth:`format_column_constraint`, which renders the prefix, dispatches + to the branch's own formatter, and appends the traits. + + PRIMARY KEY and UNIQUE are spelled here rather than delegated because + ``NONCLUSTERED`` rides the *clause* and the branch formatter is + dispatched with a single argument (``format_constraint_branch`` reads + ``branch.format_method``), so there is no way to hand it the flag + without smuggling the flag in as dialect state. ``IDENTITY(1,1)`` + comes off the PRIMARY KEY branch's own ``is_auto_increment`` flag. + + Because those two kinds do not reach + :meth:`format_column_constraint`, their traits are refused here by the + same helper that seam uses -- otherwise an explicitly requested + ``ENFORCED`` / ``DEFERRABLE`` on a PRIMARY KEY would be dropped without + a word. + + Raises: + UnsupportedFeatureError: An ``ENFORCED`` / ``NOT ENFORCED`` / + ``DEFERRABLE`` / ``NOT DEFERRABLE`` / ``INITIALLY DEFERRED`` / + ``INITIALLY IMMEDIATE`` trait was requested on any constraint; + every one of the six spellings is Msg 102 or 156 on 2019. Also + the column-comment refusal below. """ from rhosocial.activerecord.backend.expression.statements import ( - ColumnConstraintType, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, ) from rhosocial.activerecord.backend.impl.sqlserver.expression.column import ( SQLServerColumnDefinition, ) - if getattr(col_def, "comment", None): + if col_def.comment: # 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. @@ -797,94 +848,47 @@ def format_column_definition( constraint_parts = [] for constraint in col_def.constraints: - if constraint.constraint_type == ColumnConstraintType.PRIMARY_KEY: + # The trait refusals run first and unconditionally, before either + # branch spelled below or the delegated core rendering, so no path + # can silently drop a clause T-SQL does not parse. PRIMARY KEY and + # UNIQUE are spelled here rather than delegated, so this is the only + # place that sees their traits -- without the call they would be + # dropped without a word, which is the very failure the shared + # helper exists to prevent. + self._refuse_constraint_traits( + constraint, + deferrable=constraint.deferrable, + not_deferrable=constraint.not_deferrable, + initially_deferred=constraint.initially_deferred, + initially_immediate=constraint.initially_immediate, + enforced=constraint.enforced, + not_enforced=constraint.not_enforced, + ) + branch = constraint.branch + if isinstance(branch, ColumnPrimaryKeyConstraint): 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 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 - if isinstance(constraint.default_value, bases.BaseExpression): - default_sql, default_params = constraint.default_value.to_sql() - constraint_parts.append(f"DEFAULT {default_sql}") - params.extend(default_params) - elif isinstance(constraint.default_value, str): - escaped = self._escape_sql_string(constraint.default_value) - constraint_parts.append(f"DEFAULT '{escaped}'") - else: - constraint_parts.append(f"DEFAULT {constraint.default_value}") - elif constraint.constraint_type == ColumnConstraintType.NULL: - constraint_parts.append("NULL") - 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) - - # The node's two-spelling constraint attributes are consumed here - # even though SQL Server cannot spell any of them: this override - # replaces core's column formatter, so an explicitly requested - # attribute is refused by name rather than silently dropped. - # Measured refused on 2019 / 2022 / 2025. - from rhosocial.activerecord.backend.dialect.mixins.ddl_table import ( - normalize_column_constraint_type, - ) - - if constraint.enforced or constraint.not_enforced: - normalized = normalize_column_constraint_type( - constraint.constraint_type - ) - if normalized not in { - ColumnConstraintType.CHECK, - ColumnConstraintType.FOREIGN_KEY, - }: - raise ValueError( - "ENFORCED/NOT ENFORCED is only valid for CHECK and " - "FOREIGN KEY constraints" - ) - spelling = "ENFORCED" if constraint.enforced else "NOT ENFORCED" - if not self.supports_constraint_enforced(): - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - raise UnsupportedFeatureError( - self.name, - f"constraint {spelling}", - f"{self.name} does not support the {spelling} constraint.", - ) - constraint_parts.append(spelling) - if constraint.deferrable or constraint.not_deferrable: - spelling = ( - "DEFERRABLE" if constraint.deferrable else "NOT DEFERRABLE" + if branch.is_auto_increment: + constraint_parts.append("IDENTITY(1,1)") + elif isinstance(branch, ColumnUniqueConstraint): + constraint_parts.append( + "UNIQUE NONCLUSTERED" if memory_optimized else "UNIQUE" ) - if not self.supports_deferrable_constraint(): - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - raise UnsupportedFeatureError( - self.name, - f"constraint {spelling}", - f"{self.name} does not support the {spelling} constraint.", - ) - constraint_parts.append(spelling) - if constraint.initially_deferred or constraint.initially_immediate: - spelling = ( - "INITIALLY DEFERRED" - if constraint.initially_deferred - else "INITIALLY IMMEDIATE" + else: + # The rest -- NOT NULL, NULL, DEFAULT, CHECK, GENERATED, and the + # column-level REFERENCES that T-SQL refuses by name -- go + # through core's production formatter, which renders the + # CONSTRAINT prefix, dispatches to the branch, and appends the + # traits. The trait refusals (ENFORCED / DEFERRABLE / + # INITIALLY, all Msg 102 or 156 on 2019) happen in + # ``SQLServerConstraintMixin.format_column_constraint``. + constraint_sql, constraint_params = self.format_column_constraint( + constraint ) - if not self.supports_deferrable_constraint(): - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - raise UnsupportedFeatureError( - self.name, - f"constraint {spelling}", - f"{self.name} does not support the {spelling} constraint.", - ) - constraint_parts.append(spelling) - - if constraint.is_auto_increment: - constraint_parts.append("IDENTITY(1,1)") + if constraint_sql: + constraint_parts.append(constraint_sql.strip()) + params.extend(constraint_params) if constraint_parts: parts.append(' '.join(constraint_parts)) @@ -915,133 +919,85 @@ def format_table_constraint( ) -> Tuple[str, tuple]: """Format a table constraint for SQL Server. - The memory-optimized variants are why this overrides core's formatter at - all: ``PRIMARY KEY NONCLUSTERED`` and ``UNIQUE NONCLUSTERED`` are the - spellings a memory-optimized table needs. + Two reasons this overrides core's production renderer: the + memory-optimized spellings (``PRIMARY KEY NONCLUSTERED`` / + ``UNIQUE NONCLUSTERED``), and the refusal of the SQL-standard traits. + Everything else -- the ``CONSTRAINT `` prefix, the branch bodies + for CHECK and FOREIGN KEY, and the ``REFERENCES`` clause with its + referential actions -- is core's, reached through ``super()``. + + Which kind this is comes from the **branch class**. ``EXCLUDE`` has + no T-SQL spelling, so it is refused by name by core's + ``format_table_exclude_constraint`` rather than silently dropped. Raises: - TypeError: ``t_const.foreign_key_table`` is not a Table. Another - object kind would have had its own name rendered as the - referenced relation, because the constraint renders that slot - through ``to_sql()``. + TypeError: The foreign-key branch's ``references`` does not pair a + Table with its columns. Another object kind would have had its + own name rendered as the referenced relation. + UnsupportedFeatureError: ``ENFORCED`` / ``NOT ENFORCED`` / + ``DEFERRABLE`` / ``NOT DEFERRABLE`` / ``INITIALLY ...`` -- + all Msg 102 or 156 on 2019 -- or ``CONSTRAINT `` where + this dialect will not spell it. """ - if t_const.foreign_key_table is not None and not isinstance( - t_const.foreign_key_table, Table - ): - raise TypeError( - f"TableConstraint.foreign_key_table must be a Table, " - f"got {type(t_const.foreign_key_table).__name__}" - ) from rhosocial.activerecord.backend.expression.statements import ( - TableConstraintType, - ForeignKeyConstraint, - ReferentialAction, + TableForeignKeyConstraint, + TablePrimaryKeyConstraint, + TableUniqueConstraint, ) - parts = [] - params: List[Any] = [] - - if t_const.name: - parts.append(f"CONSTRAINT {self.format_identifier(t_const.name)}") - - 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) - 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) - 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) - ref_cols_str = ', '.join( - self.format_identifier(c) for c in t_const.foreign_key_columns - ) - ref_table = t_const.foreign_key_table.to_sql()[0] - parts.append( - f"FOREIGN KEY ({cols_str}) REFERENCES {ref_table} ({ref_cols_str})" - ) - if isinstance(t_const, ForeignKeyConstraint): - if t_const.on_delete != ReferentialAction.NO_ACTION: - parts.append(f"ON DELETE {t_const.on_delete.value}") - if t_const.on_update != ReferentialAction.NO_ACTION: - parts.append(f"ON UPDATE {t_const.on_update.value}") - elif t_const.constraint_type == TableConstraintType.CHECK and t_const.check_condition: - check_sql, check_params = t_const.check_condition.to_sql() - parts.append(f"CHECK ({check_sql})") - params.extend(check_params) - - # The node's two-spelling constraint attributes are consumed here even - # though SQL Server cannot spell any of them: this override replaces - # core's formatter, so an explicitly requested attribute is refused by - # name rather than silently dropped. Measured refused on 2019 / 2022 / - # 2025 (ENFORCED, NOT ENFORCED, DEFERRABLE, NOT DEFERRABLE, INITIALLY - # DEFERRED, INITIALLY IMMEDIATE). The probes gate the refusals, and the - # spelling is rendered when a probe answers True, so neither is - # decorative. - from rhosocial.activerecord.backend.dialect.mixins.ddl_table import ( - normalize_table_constraint_type, + branch = t_const.branch + + # The trait refusals run first and unconditionally, before either the + # memory-optimized spelling below or the delegated core rendering, so + # neither path can render a clause T-SQL does not parse. The helper is + # the mixin's so the column seam (``format_column_constraint``) and + # this one cannot drift apart; core's own trait renderer would refuse + # the same traits too, but without naming the spelling that was asked + # for. + self._refuse_constraint_traits( + t_const, + deferrable=t_const.deferrable, + not_deferrable=t_const.not_deferrable, + initially_deferred=t_const.initially_deferred, + initially_immediate=t_const.initially_immediate, + enforced=t_const.enforced, + not_enforced=t_const.not_enforced, ) - normalized_type = normalize_table_constraint_type(t_const.constraint_type) - if t_const.enforced or t_const.not_enforced: - if normalized_type not in { - TableConstraintType.CHECK, - TableConstraintType.FOREIGN_KEY, - }: - raise ValueError( - "ENFORCED/NOT ENFORCED is only valid for CHECK and " - "FOREIGN KEY constraints" + if isinstance(branch, TableForeignKeyConstraint): + referenced_table, _referenced_columns = branch.references + if referenced_table is not None and not isinstance(referenced_table, Table): + raise TypeError( + f"TableForeignKeyConstraint.references must pair a Table " + f"with its columns, got {type(referenced_table).__name__}" ) - spelling = "ENFORCED" if t_const.enforced else "NOT ENFORCED" - if not self.supports_constraint_enforced(): - raise UnsupportedFeatureError( - self.name, - f"constraint {spelling}", - f"{self.name} does not support the {spelling} constraint.", - ) - parts.append(spelling) - if t_const.deferrable or t_const.not_deferrable: - spelling = "DEFERRABLE" if t_const.deferrable else "NOT DEFERRABLE" - if not self.supports_deferrable_constraint(): - raise UnsupportedFeatureError( - self.name, - f"constraint {spelling}", - f"{self.name} does not support the {spelling} constraint.", - ) - parts.append(spelling) - if t_const.initially_deferred or t_const.initially_immediate: - spelling = ( - "INITIALLY DEFERRED" - if t_const.initially_deferred - else "INITIALLY IMMEDIATE" + + if memory_optimized and isinstance( + branch, (TablePrimaryKeyConstraint, TableUniqueConstraint) + ): + keyword = ( + "PRIMARY KEY" + if isinstance(branch, TablePrimaryKeyConstraint) + else "UNIQUE" ) - if not self.supports_deferrable_constraint(): - raise UnsupportedFeatureError( - self.name, - f"constraint {spelling}", - f"{self.name} does not support the {spelling} constraint.", - ) - parts.append(spelling) + parts = [] + if t_const.name: + parts.append(f"CONSTRAINT {self.format_identifier(t_const.name)}") + cols_str = ', '.join(self.format_identifier(c) for c in branch.columns) + parts.append(f"{keyword} NONCLUSTERED ({cols_str})") + return ' '.join(parts), () - return ' '.join(parts), tuple(params) + return super().format_table_constraint(t_const) 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_not_exists", idx_def.if_not_exists), + ("tablespace", idx_def.tablespace), + ("include_columns", idx_def.include_columns), + ("partial_condition", idx_def.partial_condition), ): if value: raise UnsupportedFeatureError( @@ -1081,7 +1037,7 @@ def format_inline_index(self, idx_def: "IndexDefinition") -> Tuple[str, tuple]: return ' '.join(parts), () - def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[str, tuple]: + def format_create_index_statement(self, expr: "CreateIndexStatement") -> Tuple[str, tuple]: """Format CREATE INDEX statement for SQL Server. SQL Server doesn't support IF NOT EXISTS for CREATE INDEX, and has no @@ -1097,20 +1053,20 @@ def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[ if not isinstance(expr.index, Index): raise TypeError( - f"CreateIndexExpression.index must be an Index, " + f"CreateIndexStatement.index must be an Index, " f"got {type(expr.index).__name__}" ) if not isinstance(expr.table, Table): raise TypeError( - f"CreateIndexExpression.table must be a Table, " + f"CreateIndexStatement.table must be a Table, " f"got {type(expr.table).__name__}" ) - if getattr(expr, "if_not_exists", False): + if expr.if_not_exists: raise UnsupportedFeatureError( self.name, "CREATE INDEX IF NOT EXISTS", "SQL Server has no CREATE INDEX IF NOT EXISTS syntax.", ) - if getattr(expr, "tablespace", None): + if expr.tablespace: raise UnsupportedFeatureError( self.name, "index TABLESPACE", "SQL Server has no index tablespace; use a filegroup instead.", @@ -1121,7 +1077,7 @@ def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[ if expr.unique: parts.append("UNIQUE") - if hasattr(expr, 'index_type') and expr.index_type: + if expr.index_type: index_type = expr.index_type.upper() if index_type in ('CLUSTERED', 'NONCLUSTERED'): parts.append(index_type) @@ -1160,7 +1116,7 @@ def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[ # Answering the probes in SQLServerSequenceMixin is therefore the whole # implementation, and one renderer serves every dialect. - def format_alter_sequence_statement(self, expr: "AlterSequenceExpression") -> Tuple[str, tuple]: + def format_alter_sequence_statement(self, expr: "AlterSequenceStatement") -> Tuple[str, tuple]: """Format ALTER SEQUENCE for SQL Server. SQL Server's ALTER SEQUENCE grammar differs from the standard form core @@ -1194,7 +1150,7 @@ def format_alter_sequence_statement(self, expr: "AlterSequenceExpression") -> Tu """ if not isinstance(expr.sequence, Sequence): raise TypeError( - f"AlterSequenceExpression.sequence must be a Sequence, " + f"AlterSequenceStatement.sequence must be a Sequence, " f"got {type(expr.sequence).__name__}" ) if not self.supports_sequence(): @@ -1284,7 +1240,7 @@ def format_alter_sequence_statement(self, expr: "AlterSequenceExpression") -> Tu parts.append("OWNED BY NONE") return " ".join(parts), () - def format_truncate_statement(self, expr: "TruncateExpression") -> Tuple[str, tuple]: + def format_truncate_statement(self, expr: "TruncateStatement") -> Tuple[str, tuple]: """Format TRUNCATE TABLE for SQL Server. SQL Server's TRUNCATE TABLE is a DDL operation (minimal logging) with @@ -1304,7 +1260,7 @@ def format_truncate_statement(self, expr: "TruncateExpression") -> Tuple[str, tu """ if not isinstance(expr.table, Table): raise TypeError( - f"TruncateExpression.table must be a Table, " + f"TruncateStatement.table must be a Table, " f"got {type(expr.table).__name__}" ) if not self.supports_truncate(): @@ -1335,7 +1291,7 @@ def format_truncate_statement(self, expr: "TruncateExpression") -> Tuple[str, tu ) return f"TRUNCATE TABLE {expr.table.to_sql()[0]}", () - def format_merge_statement(self, expr: "MergeExpression") -> Tuple[str, tuple]: + def format_merge_statement(self, expr: "MergeStatement") -> Tuple[str, tuple]: """Format MERGE statement for SQL Server (2008+). SQL Server MERGE syntax: @@ -1352,7 +1308,7 @@ def format_merge_statement(self, expr: "MergeExpression") -> Tuple[str, tuple]: if not isinstance(expr.target_table, Table): raise TypeError( - f"MergeExpression.target_table must be a Table, " + f"MergeStatement.target_table must be a Table, " f"got {type(expr.target_table).__name__}" ) @@ -1367,9 +1323,18 @@ def format_merge_statement(self, expr: "MergeExpression") -> Tuple[str, tuple]: on_sql, on_params = expr.on_condition.to_sql() all_params.extend(on_params) - output_columns = getattr(expr, "output", None) - output_action = getattr(expr, "output_action", False) - holdlock = getattr(expr, "holdlock", False) + # OUTPUT / HOLDLOCK are declared by the SQL Server MERGE node; the + # generic statement has no such fields, so the dispatch is on the + # declared class and the generic node renders the plain MERGE form. + from .expression.dml import SQLServerMergeExpression + + output_columns = None + output_action = False + holdlock = False + if isinstance(expr, SQLServerMergeExpression): + output_columns = expr.output + output_action = expr.output_action + holdlock = expr.holdlock parts = [ f"MERGE INTO {target_sql}", @@ -1435,38 +1400,28 @@ def format_merge_statement(self, expr: "MergeExpression") -> Tuple[str, tuple]: # T-SQL requires a MERGE statement to be terminated by a semi-colon. return " ".join(parts) + ";", tuple(all_params) - def format_function_call(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: - """Format a function call, mapping MySQL/generic function names to - their SQL Server equivalents. - - SQL Server has no NOW(), CURRENT_DATE or CURRENT_TIME: - - NOW() is replaced by SYSDATETIME() - - CURRENT_DATE is replaced by CAST(GETDATE() AS DATE) - - CURRENT_TIME is replaced by CAST(GETDATE() AS TIME) - - SQL Server also has no generic ``LENGTH`` function; it uses ``LEN``. - Generic JSON operators/functions (``json_extract_text`` etc.) are - mapped to ``JSON_VALUE`` (see ``format_json_function_expression``). - """ - name = getattr(expr, "func_name", None) - if name and name.upper() in self._NILADIC_FUNCTION_EQUIVALENTS: - sql = self._NILADIC_FUNCTION_EQUIVALENTS[name.upper()] - return self.apply_alias(sql, (), expr) - if name and name.upper() in self._FUNCTION_NAME_EQUIVALENTS: - renamed_expr = self._clone_with_func_name(expr, self._FUNCTION_NAME_EQUIVALENTS[name.upper()]) - return super().format_function_call(renamed_expr) - return super().format_function_call(expr) - - def _clone_with_func_name(self, expr: "bases.BaseExpression", new_name: str) -> "bases.BaseExpression": - """Return a shallow copy of ``expr`` with ``func_name`` replaced. - - The base ``format_function_call`` renders ``expr.func_name.upper()``, - so remapping generic names (e.g. ``LENGTH`` -> ``LEN``) only requires - a shallow copy with the target name substituted. + def format_char_length_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """CHAR_LENGTH(operand) in T-SQL is ``LEN(operand)``. + + The unit differs with the spelling: SQL Server's ``LEN`` counts + UTF-16 code units, not bytes (``DATALENGTH`` is the byte count), + and it excludes trailing blanks -- the trailing spaces the standard + ``CHAR_LENGTH`` keeps counting. The core's + ``format_char_length_function`` docstring names SQL Server as the + backend whose ``LENGTH`` answers a different question, which is + why this override exists instead of the portable spelling. + + This lives on the dialect body rather than a late mixin because + ``format_char_length_function`` rides into the MRO through + ``ExpressionMixin`` -> ``FunctionCallMixin`` at an early position + (right behind ``SQLDialectBase``), ahead of every SQL Server + mixin block; the class body is the one place guaranteed to win. """ - clone = copy.copy(expr) - clone.func_name = new_name - return clone + operand_sql, operand_params = expr.operand.to_sql() + sql = f"LEN({operand_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(operand_params) def format_expression(self, expr: Any) -> Tuple[str, tuple]: """Format an arbitrary expression to SQL.""" @@ -1475,7 +1430,7 @@ def format_expression(self, expr: Any) -> Tuple[str, tuple]: return str(expr), () def format_fulltext_match( - self, expr: "FulltextMatchExpression" + self, expr: "FulltextMatch" ) -> Tuple[str, tuple]: """Format SQL Server CONTAINS full-text search. @@ -1618,7 +1573,7 @@ def _register_type_formatters(): ``parse_type`` at runtime, and imports this backend's own ``DataType`` classes so ``supports_data_types()`` can resolve the ``sqlserver_``-prefixed names to a class. That last part is load order, not bookkeeping: - ``_type_class_for(name)`` resolves a name by walking the live subclass tree + ``type_class_for(name)`` resolves a name by walking the live subclass tree of ``DataType``, so a name whose class nothing has imported yet resolves to ``None`` and drops out of the mapping silently. Nothing noticed, because every test that looked at the mapping imported a ``SQLServer*`` class in diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/connection/quickstart.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/connection/quickstart.py index 8b3790d..03b1f40 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/connection/quickstart.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/connection/quickstart.py @@ -14,10 +14,10 @@ # SECTION: Setup (necessary for execution, reference only) # ============================================================ from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, - InsertExpression, - QueryExpression, + CreateTableStatement, + DropTableStatement, + InsertStatement, + QueryStatement, ValuesSource, WhereClause, ) @@ -26,8 +26,9 @@ from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.expression.statements import ( ColumnConstraint, - ColumnConstraintType, ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig @@ -63,7 +64,7 @@ def execute_expression(expression, options=None): # Create demo tables -users_table = CreateTableExpression( +users_table = CreateTableStatement( dialect=dialect, table=Table(dialect, 'quickstart_users'), columns=[ @@ -71,14 +72,16 @@ def execute_expression(expression, options=None): 'id', 'INT', constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ), ], ), ColumnDefinition( 'name', 'NVARCHAR(100)', - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))], ), ColumnDefinition('status', 'NVARCHAR(20)'), ], @@ -86,7 +89,7 @@ def execute_expression(expression, options=None): ) execute_expression(users_table, ddl_options) -logs_table = CreateTableExpression( +logs_table = CreateTableStatement( dialect=dialect, table=Table(dialect, 'quickstart_logs'), columns=[ @@ -94,14 +97,16 @@ def execute_expression(expression, options=None): 'id', 'INT', constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ), ], ), ColumnDefinition( 'message', 'NVARCHAR(255)', - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))], ), ], if_not_exists=True, @@ -109,7 +114,7 @@ def execute_expression(expression, options=None): execute_expression(logs_table, ddl_options) # Seed demo data -insert_users = InsertExpression( +insert_users = InsertStatement( dialect=dialect, into=Table(dialect, 'quickstart_users'), columns=['name', 'status'], @@ -127,7 +132,7 @@ def execute_expression(expression, options=None): # SECTION: Business Logic (the pattern to learn) # ============================================================ # Create a simple SELECT query with WHERE clause -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, 'id'), @@ -165,7 +170,7 @@ def execute_expression(expression, options=None): # Use complex predicates with AND/OR from rhosocial.activerecord.backend.expression.predicates import LogicalPredicate -filtered_query = QueryExpression( +filtered_query = QueryStatement( dialect=dialect, select=[ Column(dialect, 'id'), @@ -195,7 +200,7 @@ def execute_expression(expression, options=None): # ============================================================ # Use transaction context manager for automatic commit/rollback with backend.transaction(): - insert_log = InsertExpression( + insert_log = InsertStatement( dialect=dialect, into=Table(dialect, 'quickstart_logs'), columns=['message'], @@ -203,7 +208,7 @@ def execute_expression(expression, options=None): ) execute_expression(insert_log) - logs_query = QueryExpression( + logs_query = QueryStatement( dialect=dialect, select=[Column(dialect, 'id'), Column(dialect, 'message')], from_=Table(dialect, 'quickstart_logs'), @@ -215,7 +220,7 @@ def execute_expression(expression, options=None): # SECTION: Business Logic - Error Handling # ============================================================ try: - invalid_query = QueryExpression( + invalid_query = QueryStatement( dialect=dialect, select=[Column(dialect, 'id')], from_=Table(dialect, 'nonexistent_table'), @@ -227,12 +232,12 @@ def execute_expression(expression, options=None): # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_logs = DropTableExpression( +drop_logs = DropTableStatement( dialect=dialect, table=Table(dialect, 'quickstart_logs'), if_exists=True ) execute_expression(drop_logs, ddl_options) -drop_users = DropTableExpression( +drop_users = DropTableStatement( dialect=dialect, table=Table(dialect, 'quickstart_users'), if_exists=True ) execute_expression(drop_users, ddl_options) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/ddl/create_index.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/ddl/create_index.py index 6e1834f..93ad973 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/ddl/create_index.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/ddl/create_index.py @@ -26,28 +26,31 @@ backend.connect() dialect = backend.dialect -from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression import CreateTableStatement from rhosocial.activerecord.backend.expression.objects import Index, Table from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType ddl_options = ExecutionOptions(stmt_type=StatementType.DDL) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, 'users'), columns=[ ColumnDefinition('id', 'INT', constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ), ]), ColumnDefinition('email', 'NVARCHAR(200)', constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ]), ColumnDefinition('name', 'NVARCHAR(100)'), ], @@ -59,9 +62,9 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.expression import CreateIndexExpression +from rhosocial.activerecord.backend.expression import CreateIndexStatement -create_idx = CreateIndexExpression( +create_idx = CreateIndexStatement( dialect=dialect, index=Index(dialect, 'idx_users_email'), table=Table(dialect, 'users'), @@ -83,9 +86,9 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.expression import DropTableExpression +from rhosocial.activerecord.backend.expression import DropTableStatement -drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, 'users'), if_exists=True) +drop_expr = DropTableStatement(dialect=dialect, table=Table(dialect, 'users'), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params, options=ddl_options) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/ddl/create_table.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/ddl/create_table.py index dcbc466..0b7d1d4 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 @@ -30,37 +30,38 @@ # SECTION: Business Logic (the pattern to learn) # ============================================================ from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, + CreateTableStatement, ColumnDefinition, ColumnConstraint, - ColumnConstraintType, ) from rhosocial.activerecord.backend.expression.objects import Table -from rhosocial.activerecord.backend.expression.statements.ddl_table import IndexDefinition +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, + IndexDefinition, +) columns = [ ColumnDefinition( name='id', data_type='INT', constraints=[ - ColumnConstraint( - constraint_type=ColumnConstraintType.PRIMARY_KEY, - is_auto_increment=True, - ), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True)), ], ), ColumnDefinition( name='name', data_type='NVARCHAR(100)', constraints=[ - ColumnConstraint(constraint_type=ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition( name='email', data_type='NVARCHAR(200)', constraints=[ - ColumnConstraint(constraint_type=ColumnConstraintType.UNIQUE), + ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect)), ], ), ColumnDefinition( @@ -76,7 +77,7 @@ ), ] -create_expr = CreateTableExpression( +create_expr = CreateTableStatement( dialect=dialect, table=Table(dialect, 'users'), columns=columns, @@ -101,9 +102,9 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.expression import DropTableExpression +from rhosocial.activerecord.backend.expression import DropTableStatement -drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, 'users'), if_exists=True) +drop_expr = DropTableStatement(dialect=dialect, table=Table(dialect, 'users'), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params, options=ddl_options) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/functions/json_example.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/functions/json_example.py index 9432866..e4aff2b 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/functions/json_example.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/functions/json_example.py @@ -12,7 +12,7 @@ json_object, json_array, ) -from rhosocial.activerecord.backend.expression import QueryExpression, Column +from rhosocial.activerecord.backend.expression import QueryStatement, Column from rhosocial.activerecord.backend.expression.objects import Table dialect = SQLServerDialect(version=(16, 0, 0)) @@ -48,7 +48,7 @@ print(f" SQL: {sql}") print("\n6. Using functions in a query:") -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/functions/string_example.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/functions/string_example.py index cee09e0..36a5355 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/functions/string_example.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/functions/string_example.py @@ -12,7 +12,7 @@ string_split, format, ) -from rhosocial.activerecord.backend.expression import QueryExpression, Column +from rhosocial.activerecord.backend.expression import QueryStatement, Column from rhosocial.activerecord.backend.expression.objects import Table dialect = SQLServerDialect(version=(16, 0, 0)) @@ -47,7 +47,7 @@ print(f" SQL: {sql}") print("\n6. Using functions in a query:") -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -59,7 +59,7 @@ print(f" SQL: {sql}") print("\n7. Using STRING_SPLIT in WHERE clause:") -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[Column(dialect, "id")], from_=Table(dialect, "users"), diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/insert/batch.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/insert/batch.py index 8051a27..1e8b3b3 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/insert/batch.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/insert/batch.py @@ -26,28 +26,31 @@ backend.connect() dialect = backend.dialect -from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression import CreateTableStatement from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType ddl_options = ExecutionOptions(stmt_type=StatementType.DDL) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, 'batch_users'), columns=[ ColumnDefinition('id', 'INT', constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ), ]), ColumnDefinition('name', 'NVARCHAR(100)', constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ]), ColumnDefinition('email', 'NVARCHAR(200)'), ], @@ -59,7 +62,7 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.expression import InsertExpression, ValuesSource +from rhosocial.activerecord.backend.expression import InsertStatement, ValuesSource from rhosocial.activerecord.backend.expression.core import Literal # Prepare batch data @@ -70,7 +73,7 @@ ] # Create batch insert with multiple value rows -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, 'batch_users'), columns=['name', 'email'], @@ -91,9 +94,9 @@ print(f"Affected rows: {result.affected_rows}") # Verify insertion -from rhosocial.activerecord.backend.expression import QueryExpression, Column +from rhosocial.activerecord.backend.expression import QueryStatement, Column -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[Column(dialect, 'id'), Column(dialect, 'name'), Column(dialect, 'email')], from_=Table(dialect, 'batch_users'), @@ -108,9 +111,9 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.expression import DropTableExpression +from rhosocial.activerecord.backend.expression import DropTableStatement -drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, 'batch_users'), if_exists=True) +drop_expr = DropTableStatement(dialect=dialect, table=Table(dialect, 'batch_users'), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params, options=ddl_options) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/insert/with_output.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/insert/with_output.py index a4723e7..dfec222 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 @@ -28,28 +28,31 @@ backend.connect() dialect = backend.dialect -from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression import CreateTableStatement from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType ddl_options = ExecutionOptions(stmt_type=StatementType.DDL) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, 'output_users'), columns=[ ColumnDefinition('id', 'INT', constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ), ]), ColumnDefinition('name', 'NVARCHAR(100)', constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ]), ], if_not_exists=True, @@ -61,7 +64,7 @@ # SECTION: Business Logic (the pattern to learn) # ============================================================ from rhosocial.activerecord.backend.expression import ( - InsertExpression, + InsertStatement, ValuesSource, ReturningClause, Column, @@ -69,7 +72,7 @@ from rhosocial.activerecord.backend.expression.core import Literal # SQL Server uses 'returning' attribute which dialect converts to OUTPUT clause -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, 'output_users'), source=ValuesSource(dialect, [[Literal(dialect, 'Alice')]]), @@ -92,9 +95,9 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.expression import DropTableExpression +from rhosocial.activerecord.backend.expression import DropTableStatement -drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, 'output_users'), if_exists=True) +drop_expr = DropTableStatement(dialect=dialect, table=Table(dialect, 'output_users'), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params, options=ddl_options) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_expressions/order_expressions.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_expressions/order_expressions.py index a2e22e3..7d1bb53 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_expressions/order_expressions.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_expressions/order_expressions.py @@ -22,14 +22,14 @@ Column, ComparisonPredicate, Literal, - QueryExpression, + QueryStatement, ) from rhosocial.activerecord.backend.expression.objects import Table # noqa: E402 def get_order(dialect, order_id: int): """Get order details by ID.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "status"), Column(dialect, "user_id")], from_=Table(dialect, "orders"), @@ -39,7 +39,7 @@ def get_order(dialect, order_id: int): def check_inventory(dialect, order_id: int): """Check available inventory for an order.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "available")], from_=Table(dialect, "inventory"), @@ -49,7 +49,7 @@ def check_inventory(dialect, order_id: int): def reserve_inventory(dialect, order_id: int): """Reserve inventory for an order.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "available")], from_=Table(dialect, "inventory"), @@ -59,7 +59,7 @@ def reserve_inventory(dialect, order_id: int): def send_notification(dialect, user_id: int, type: str): """Send notification to a user.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id")], from_=Table(dialect, "notifications"), @@ -69,7 +69,7 @@ def send_notification(dialect, user_id: int, type: str): def process_payment(dialect, order_id: int, amount: float): """Process payment for an order.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "status"), Column(dialect, "transaction_id")], from_=Table(dialect, "payments"), @@ -79,7 +79,7 @@ def process_payment(dialect, order_id: int, amount: float): def release_inventory(dialect, order_id: int): """Release reserved inventory.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id")], from_=Table(dialect, "inventory"), @@ -89,7 +89,7 @@ def release_inventory(dialect, order_id: int): def create_order_record(dialect, order_id: int, user_id: int, amount: float): """Create an order record.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "created_at")], from_=Table(dialect, "order_records"), @@ -99,7 +99,7 @@ def create_order_record(dialect, order_id: int, user_id: int, amount: float): def confirm_inventory(dialect, order_id: int): """Confirm inventory (final confirmation).""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id")], from_=Table(dialect, "inventory"), diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/query/basic.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/query/basic.py index abe1c88..50c7a23 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/query/basic.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/query/basic.py @@ -29,8 +29,8 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, ) from rhosocial.activerecord.backend.expression.core import Literal @@ -38,22 +38,25 @@ from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) ddl_options = ExecutionOptions(stmt_type=StatementType.DDL) dql_options = ExecutionOptions(stmt_type=StatementType.DQL) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, 'query_users'), columns=[ ColumnDefinition('id', 'INT', constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ), ]), ColumnDefinition('name', 'NVARCHAR(100)', constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ]), ColumnDefinition('age', 'INT'), ColumnDefinition('status', 'NVARCHAR(20)'), @@ -69,7 +72,7 @@ ('Charlie', 35, 'inactive'), ] for row in users: - insert_expr = InsertExpression( + insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, 'query_users'), columns=['name', 'age', 'status'], @@ -82,7 +85,7 @@ # SECTION: Business Logic (the pattern to learn) # ============================================================ from rhosocial.activerecord.backend.expression import ( - QueryExpression, + QueryStatement, Column, WhereClause, OrderByClause, @@ -90,7 +93,7 @@ ) from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, 'id'), @@ -129,9 +132,9 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.expression import DropTableExpression +from rhosocial.activerecord.backend.expression import DropTableStatement -drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, 'query_users'), if_exists=True) +drop_expr = DropTableStatement(dialect=dialect, table=Table(dialect, 'query_users'), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params, options=ddl_options) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/query/join.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/query/join.py index 77e0a61..cd58539 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/query/join.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/query/join.py @@ -29,8 +29,8 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, ) from rhosocial.activerecord.backend.expression.core import Literal @@ -38,23 +38,26 @@ from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, TableConstraint, - TableConstraintType, + TableForeignKeyConstraint, ) ddl_options = ExecutionOptions(stmt_type=StatementType.DDL) -users_table = CreateTableExpression( +users_table = CreateTableStatement( dialect=dialect, table=Table(dialect, 'join_users'), columns=[ ColumnDefinition('id', 'INT', constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ), ]), ColumnDefinition('name', 'NVARCHAR(100)', constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ]), ], if_not_exists=True, @@ -62,23 +65,25 @@ sql, params = users_table.to_sql() backend.execute(sql, params, options=ddl_options) -orders_table = CreateTableExpression( +orders_table = CreateTableStatement( dialect=dialect, table=Table(dialect, 'join_orders'), columns=[ ColumnDefinition('id', 'INT', constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ), ]), ColumnDefinition('user_id', 'INT'), ColumnDefinition('amount', 'DECIMAL(10,2)'), ], table_constraints=[ TableConstraint( - constraint_type=TableConstraintType.FOREIGN_KEY, - columns=['user_id'], - foreign_key_table=Table(dialect, 'join_users'), - foreign_key_columns=['id'], + dialect, + branch=TableForeignKeyConstraint( + dialect, ['user_id'], (Table(dialect, 'join_users'), ['id']) + ), ), ], if_not_exists=True, @@ -88,7 +93,7 @@ users = [('Alice',), ('Bob',)] for user in users: - insert_expr = InsertExpression( + insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, 'join_users'), columns=['name'], @@ -103,7 +108,7 @@ (2, 150.0), ] for row in orders: - insert_expr = InsertExpression( + insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, 'join_orders'), columns=['user_id', 'amount'], @@ -116,7 +121,7 @@ # SECTION: Business Logic (the pattern to learn) # ============================================================ from rhosocial.activerecord.backend.expression import ( - QueryExpression, + QueryStatement, Column, JoinClause, ) @@ -136,7 +141,7 @@ ), ) -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, 'name', 'u'), @@ -161,13 +166,13 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.expression import DropTableExpression +from rhosocial.activerecord.backend.expression import DropTableStatement -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, 'join_orders'), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, 'join_orders'), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params, options=ddl_options) -drop_users = DropTableExpression(dialect=dialect, table=Table(dialect, 'join_users'), if_exists=True) +drop_users = DropTableStatement(dialect=dialect, table=Table(dialect, 'join_users'), if_exists=True) sql, params = drop_users.to_sql() backend.execute(sql, params, options=ddl_options) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/schema_diff/add_column.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/schema_diff/add_column.py index 0b3b284..4eb2af7 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/schema_diff/add_column.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/schema_diff/add_column.py @@ -24,9 +24,9 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( - DropTableExpression, CreateTableExpression, ColumnDefinition, - ColumnConstraint, ColumnConstraintType, - AlterTableExpression, AddColumn, + DropTableStatement, CreateTableStatement, ColumnDefinition, + ColumnConstraint, ColumnPrimaryKeyConstraint, + AlterTableStatement, AddColumnAction, ) from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.types import ( @@ -35,16 +35,16 @@ users = Table(dialect, "users") -expr = DropTableExpression(dialect, users, if_exists=True) +expr = DropTableStatement(dialect, users, if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) # Baseline table: ID, NAME -expr = CreateTableExpression( +expr = CreateTableStatement( dialect=dialect, table=users, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ - ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True)), ]), ColumnDefinition(dialect, "name", VarCharType(dialect, length=100)), ] @@ -63,10 +63,10 @@ snap_before = builder.build() # Add a column -alter = AlterTableExpression( +alter = AlterTableStatement( dialect=dialect, table=users, actions=[ - AddColumn(dialect, column=ColumnDefinition(dialect, "email", VarCharType(dialect, length=255))), + AddColumnAction(dialect, column=ColumnDefinition(dialect, "email", VarCharType(dialect, length=255))), ], ) sql, params = alter.to_sql() diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/transaction/basic.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/transaction/basic.py index 52257fb..a9eef69 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/transaction/basic.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/transaction/basic.py @@ -27,28 +27,31 @@ backend.connect() dialect = backend.dialect -from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression import CreateTableStatement from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType ddl_options = ExecutionOptions(stmt_type=StatementType.DDL) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, 'txn_users'), columns=[ ColumnDefinition('id', 'INT', constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ), ]), ColumnDefinition('name', 'NVARCHAR(100)', constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ]), ], if_not_exists=True, @@ -59,13 +62,13 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.expression import InsertExpression, ValuesSource +from rhosocial.activerecord.backend.expression import InsertStatement, ValuesSource from rhosocial.activerecord.backend.expression.core import Literal # Use transaction context manager for automatic commit/rollback # If an exception occurs, the transaction is automatically rolled back with backend.transaction(): - insert_expr = InsertExpression( + insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, 'txn_users'), columns=['name'], @@ -74,7 +77,7 @@ sql, params = insert_expr.to_sql() backend.execute(sql, params) - insert_expr2 = InsertExpression( + insert_expr2 = InsertStatement( dialect=dialect, into=Table(dialect, 'txn_users'), columns=['name'], @@ -91,9 +94,9 @@ # ============================================================ # SECTION: Execution (verify results) # ============================================================ -from rhosocial.activerecord.backend.expression import QueryExpression, Column +from rhosocial.activerecord.backend.expression import QueryStatement, Column -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[Column(dialect, 'id'), Column(dialect, 'name')], from_=Table(dialect, 'txn_users'), @@ -108,9 +111,9 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.expression import DropTableExpression +from rhosocial.activerecord.backend.expression import DropTableStatement -drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, 'txn_users'), if_exists=True) +drop_expr = DropTableStatement(dialect=dialect, table=Table(dialect, 'txn_users'), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params, options=ddl_options) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/types/json_basic.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/types/json_basic.py index 929ee75..a36c07a 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/types/json_basic.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/types/json_basic.py @@ -29,12 +29,12 @@ backend.connect() dialect = backend.dialect -from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression import CreateTableStatement from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType @@ -42,13 +42,15 @@ ddl_options = ExecutionOptions(stmt_type=StatementType.DDL) dql_options = ExecutionOptions(stmt_type=StatementType.DQL) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, 'json_data'), columns=[ ColumnDefinition('id', 'INT', constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ), ]), ColumnDefinition('data', 'NVARCHAR(MAX)'), ], @@ -60,14 +62,14 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.expression import InsertExpression, ValuesSource +from rhosocial.activerecord.backend.expression import InsertStatement, ValuesSource from rhosocial.activerecord.backend.expression.core import Literal # Insert JSON data as string literal # Note: SQL Server 2022+ supports JSON_OBJECT function json_literal = '{"name": "John", "age": 30, "hobbies": ["reading", "coding"]}' -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, 'json_data'), columns=['data'], @@ -82,10 +84,10 @@ # ============================================================ # For JSON queries, we use raw SQL since JSON_VALUE/JSON_QUERY # are SQL Server specific functions not yet available as expressions -from rhosocial.activerecord.backend.expression import QueryExpression, Column +from rhosocial.activerecord.backend.expression import QueryStatement, Column # Basic query to get the JSON data -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[Column(dialect, 'id'), Column(dialect, 'data')], from_=Table(dialect, 'json_data'), @@ -99,9 +101,9 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.expression import DropTableExpression +from rhosocial.activerecord.backend.expression import DropTableStatement -drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, 'json_data'), if_exists=True) +drop_expr = DropTableStatement(dialect=dialect, table=Table(dialect, 'json_data'), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params, options=ddl_options) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/__init__.py index b16d7aa..4ca41fe 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/__init__.py @@ -1,5 +1,17 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/expression/__init__.py -"""SQL Server-specific expression classes.""" +"""SQL Server-specific expression classes. + +One naming rule holds for every class in this package: its **kind** spells the +backend's name as a single word. ``SQLServer`` is a compound name -- the +generic consecutive-capital rule reads it as ``SQL`` + ``Server`` -- so each +node here carries ``KIND_RESERVED = ("SQLServer",)``, which is *data* rather +than behaviour: no class overrides ``kind()``, and the whole backend spells the +same way, exactly as SQLite keeps ``SQLite`` whole (``sqlite_integer_type``) and +MySQL keeps ``MySQL`` (``mysql_big_int_type``). The parallel form here is +therefore ``SQLServerBitType`` -> ``sqlserver_bit_type``, with the ``_type`` +suffix kept on both sides of the core/backend seam. The audit that settles the +set is ``scripts/expression_lineage.py``, read off the running code. +""" from .objects import SQLServerTable, PartitionFunction, PartitionScheme from .output import SQLServerOutputInsertedExpression, SQLServerOutputDeletedExpression @@ -32,27 +44,27 @@ from .alter_column import SQLServerAlterColumn from .table_options import SQLServerCreateTableOptions from .index import SQLServerIndexDefinition -from .create_table import SQLServerCreateTableExpression +from .create_table import SQLServerCreateTableStatement from .dml import SQLServerMergeExpression, SQLServerSelectIntoExpression -from .columnstore import SQLServerColumnstoreIndexExpression +from .columnstore import SQLServerColumnstoreIndexStatement from .fulltext import ( - SQLServerCreateFullTextCatalogExpression, - SQLServerDropFullTextCatalogExpression, - SQLServerCreateFullTextIndexExpression, - SQLServerDropFullTextIndexExpression, + SQLServerCreateFullTextCatalogStatement, + SQLServerDropFullTextCatalogStatement, + SQLServerCreateFullTextIndexStatement, + SQLServerDropFullTextIndexStatement, ) from .ddl import ( - SQLServerCreateProcedureExpression, - SQLServerCreateFunctionExpression, - SQLServerDropRoutineExpression, - SQLServerCreateTriggerExpression, - SQLServerDropTriggerExpression, + SQLServerCreateProcedureStatement, + SQLServerCreateFunctionStatement, + SQLServerDropRoutineStatement, + SQLServerCreateTriggerStatement, + SQLServerDropTriggerStatement, SQLServerTypeNullability, SQLServerAliasTypeDefinition, SQLServerTableTypeDefinition, SQLServerClrTypeDefinition, SQLServerDropTypeExpression, - SQLServerRenameTypeExpression, + SQLServerRenameTypeStatement, ) from .functions import ( SQLServerTryCastExpression, @@ -100,23 +112,23 @@ "SQLServerColumnOptions", "SQLServerAlterColumn", "SQLServerIndexDefinition", - "SQLServerCreateTableExpression", - "SQLServerColumnstoreIndexExpression", - "SQLServerCreateFullTextCatalogExpression", - "SQLServerDropFullTextCatalogExpression", - "SQLServerCreateFullTextIndexExpression", - "SQLServerDropFullTextIndexExpression", - "SQLServerCreateProcedureExpression", - "SQLServerCreateFunctionExpression", - "SQLServerDropRoutineExpression", - "SQLServerCreateTriggerExpression", - "SQLServerDropTriggerExpression", + "SQLServerCreateTableStatement", + "SQLServerColumnstoreIndexStatement", + "SQLServerCreateFullTextCatalogStatement", + "SQLServerDropFullTextCatalogStatement", + "SQLServerCreateFullTextIndexStatement", + "SQLServerDropFullTextIndexStatement", + "SQLServerCreateProcedureStatement", + "SQLServerCreateFunctionStatement", + "SQLServerDropRoutineStatement", + "SQLServerCreateTriggerStatement", + "SQLServerDropTriggerStatement", "SQLServerTypeNullability", "SQLServerAliasTypeDefinition", "SQLServerTableTypeDefinition", "SQLServerClrTypeDefinition", "SQLServerDropTypeExpression", - "SQLServerRenameTypeExpression", + "SQLServerRenameTypeStatement", "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 index 811bd7d..4812d2f 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/alter_column.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/alter_column.py @@ -12,20 +12,22 @@ * ``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. +They live on ``SQLServerAlterColumn`` (deriving the generic +``AlterColumnAction``) 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, + AlterColumnAction, ColumnAlterOperation, ) if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.dialect import SQLDialectBase + from rhosocial.activerecord.backend.expression.types import DataType __all__ = [ @@ -33,12 +35,13 @@ ] -class SQLServerAlterColumn(AlterColumn): +class SQLServerAlterColumn(AlterColumnAction): """A SQL Server ``ALTER COLUMN`` action extending the generic one. Adds the SQL Server-only ``data_type``, ``collate``, ``not_null`` / ``nullable``, ``sparse``, ``masked_function`` and ``drop_masked`` fields. """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -48,7 +51,7 @@ def __init__( *, new_value: Any = None, cascade: bool = False, - data_type: Optional[str] = None, + data_type: Optional[Union[str, "DataType"]] = None, collate: Optional[str] = None, not_null: bool = False, nullable: bool = False, diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/column.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/column.py index d1f7944..7729a44 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/column.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/column.py @@ -33,6 +33,7 @@ class SQLServerColumnDefinition(ColumnDefinition): Adds the SQL Server-only ``sparse`` and ``rowguidcol`` attributes. """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -62,6 +63,7 @@ def __init__( class SQLServerColumnOptions(ColumnOptions): """SQL Server per-column options declaration.""" + KIND_RESERVED = ("SQLServer",) def __init__( self, diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/columnstore.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/columnstore.py index 49b07f5..54ccc15 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/columnstore.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/columnstore.py @@ -8,7 +8,7 @@ CREATE COLUMNSTORE INDEX cci ON t ORDER (c1, c2); -- 2022+, ordered CCI SQL generation and version gating are delegated to the dialect's -``format_create_columnstore_index_statement`` formatter. +``format_columnstore_index_statement`` formatter. """ from typing import Sequence, TYPE_CHECKING @@ -19,7 +19,7 @@ from ..dialect import SQLServerDialect -class SQLServerColumnstoreIndexExpression(BaseExpression): +class SQLServerColumnstoreIndexStatement(BaseExpression): """SQL Server CREATE [CLUSTERED|NONCLUSTERED] COLUMNSTORE INDEX statement. Attributes: @@ -38,12 +38,13 @@ class SQLServerColumnstoreIndexExpression(BaseExpression): columnstore (SQL Server 2022+). Example: - >>> expr = SQLServerColumnstoreIndexExpression( + >>> expr = SQLServerColumnstoreIndexStatement( ... dialect, "cci", "t", clustered=True, order_columns=["c1", "c2"] ... ) >>> sql, params = expr.to_sql() >>> assert sql == "CREATE CLUSTERED COLUMNSTORE INDEX [cci] ON [t] ORDER ([c1], [c2])" """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -78,4 +79,4 @@ def validate(self, strict: bool = True) -> None: raise ValueError("ORDER (...) is only valid for clustered columnstore") def to_sql(self) -> SQLQueryAndParams: - return self.dialect.format_create_columnstore_index_statement(self) + return self.dialect.format_columnstore_index_statement(self) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/create_table.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/create_table.py index d685255..58d210b 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/create_table.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/create_table.py @@ -7,8 +7,8 @@ * ``edge_constraints`` — ``CONSTRAINT CONNECTION (...)`` (SQL Server 2019+). -They live on ``SQLServerCreateTableExpression`` (deriving the generic -``CreateTableExpression``) and are rendered by the SQL Server +They live on ``SQLServerCreateTableStatement`` (deriving the generic +``CreateTableStatement``) and are rendered by the SQL Server ``format_create_table_statement`` override. """ @@ -16,7 +16,7 @@ from rhosocial.activerecord.backend.expression.statements.ddl_table import ( ColumnDefinition, - CreateTableExpression, + CreateTableStatement, CreateTableOptions, IndexDefinition, StorageOptionsExpression, @@ -30,12 +30,13 @@ ) -class SQLServerCreateTableExpression(CreateTableExpression): +class SQLServerCreateTableStatement(CreateTableStatement): """A SQL Server CREATE TABLE statement extending the generic one. Adds the SQL Server-only ``graph_table_kind`` / ``edge_constraints`` table features. """ + KIND_RESERVED = ("SQLServer",) def __init__( self, 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 28e80ad..d028d50 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/__init__.py @@ -2,13 +2,13 @@ """SQL Server-specific DDL expression classes.""" from .routine import ( - SQLServerCreateProcedureExpression, - SQLServerCreateFunctionExpression, - SQLServerDropRoutineExpression, + SQLServerCreateProcedureStatement, + SQLServerCreateFunctionStatement, + SQLServerDropRoutineStatement, ) from .trigger import ( - SQLServerCreateTriggerExpression, - SQLServerDropTriggerExpression, + SQLServerCreateTriggerStatement, + SQLServerDropTriggerStatement, ) from .graph import ( SQLServerGraphTableKind, @@ -21,15 +21,15 @@ SQLServerTableTypeDefinition, SQLServerClrTypeDefinition, SQLServerDropTypeExpression, - SQLServerRenameTypeExpression, + SQLServerRenameTypeStatement, ) __all__ = [ - "SQLServerCreateProcedureExpression", - "SQLServerCreateFunctionExpression", - "SQLServerDropRoutineExpression", - "SQLServerCreateTriggerExpression", - "SQLServerDropTriggerExpression", + "SQLServerCreateProcedureStatement", + "SQLServerCreateFunctionStatement", + "SQLServerDropRoutineStatement", + "SQLServerCreateTriggerStatement", + "SQLServerDropTriggerStatement", "SQLServerGraphTableKind", "SQLServerAsGraphTableExpression", "SQLServerEdgeConstraint", @@ -38,5 +38,5 @@ "SQLServerTableTypeDefinition", "SQLServerClrTypeDefinition", "SQLServerDropTypeExpression", - "SQLServerRenameTypeExpression", + "SQLServerRenameTypeStatement", ] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/graph.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/graph.py index e46e420..162d0c6 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/graph.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/graph.py @@ -9,12 +9,12 @@ ALTER TABLE dbo.friend ADD CONSTRAINT ec_friend CONNECTION (Person TO Person); SQL generation is delegated to the dialect's -``format_sqlserver_graph_table_kind`` / ``format_sqlserver_edge_constraint`` +``format_as_graph_table_expression`` / ``format_sqlserver_edge_constraint`` formatters. """ from enum import Enum -from typing import Optional, TYPE_CHECKING +from typing import Any, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -22,23 +22,53 @@ from ...dialect import SQLServerDialect -class SQLServerGraphTableKind(Enum): - """Graph table kind for ``CREATE TABLE ... AS NODE | AS EDGE``.""" +class SQLServerGraphTableKind(str, Enum): + """Graph table kind for ``CREATE TABLE ... AS NODE | AS EDGE``. + + A member *is* the word it carries -- ``str.__str__``, not ``Enum.__str__``, + which would win the base order otherwise -- because the member rides inside + ``SQLServerAsGraphTableExpression``'s params and a param that travels in an + artifact must be a JSON scalar. Plain ``Enum`` raises "not one of the value + kinds" the moment anything serialises the node, which is how the spelling + was settled. + """ + + __str__ = str.__str__ NODE = "NODE" EDGE = "EDGE" +def _as_graph_table_kind(value: Any) -> SQLServerGraphTableKind: + """Read a graph table kind as the member it names. + + A member and the word it carries are the same thing here -- the enum is a + ``str`` subclass, so a word *is* the member's value -- but only the member + answers ``.value``, and an artifact hands back the word. Normalising at + construction keeps one spelling inside the node and lets the formatter read + a field rather than guess what it was given. + """ + if isinstance(value, SQLServerGraphTableKind): + return value + if isinstance(value, str): + try: + return SQLServerGraphTableKind(value.strip().upper()) + except ValueError: + pass + raise ValueError(f"unknown graph table kind {value!r}; use NODE or EDGE") + + class SQLServerAsGraphTableExpression(BaseExpression): """The ``AS NODE`` / ``AS EDGE`` table-kind fragment on ``CREATE TABLE``.""" + KIND_RESERVED = ("SQLServer",) def __init__(self, dialect: "SQLServerDialect", kind: SQLServerGraphTableKind): super().__init__(dialect) - self.kind = kind + self.kind = _as_graph_table_kind(kind) @property def format_method(self) -> str: - return "format_sqlserver_graph_table_kind" + return "format_as_graph_table_expression" class SQLServerEdgeConstraint(BaseExpression): @@ -49,6 +79,7 @@ class SQLServerEdgeConstraint(BaseExpression): to_table: Node table the edge connects to. name: Optional constraint name. """ + KIND_RESERVED = ("SQLServer",) def __init__(self, dialect: "SQLServerDialect", from_table: str, to_table: str, name: Optional[str] = None): diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/routine.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/routine.py index ab80c20..89ecc52 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/routine.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/routine.py @@ -21,7 +21,7 @@ from ...dialect import SQLServerDialect -class SQLServerCreateProcedureExpression(BaseExpression): +class SQLServerCreateProcedureStatement(BaseExpression): """SQL Server CREATE PROCEDURE statement (2005+). Attributes: @@ -33,13 +33,14 @@ class SQLServerCreateProcedureExpression(BaseExpression): or_alter: Whether to render ``CREATE OR ALTER PROCEDURE``. Example: - >>> expr = SQLServerCreateProcedureExpression( + >>> expr = SQLServerCreateProcedureStatement( ... dialect, "p", ["@x INT"], body="SET NOCOUNT ON; SELECT @x + 1;", ... schema="dbo", ... ) >>> sql, params = expr.to_sql() >>> assert sql == "CREATE PROCEDURE [dbo].[p] @x INT AS BEGIN SET NOCOUNT ON; SELECT @x + 1; END;" """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -70,26 +71,27 @@ def to_sql(self) -> SQLQueryAndParams: return self.dialect.format_create_procedure_statement(self) -class SQLServerCreateFunctionExpression(BaseExpression): +class SQLServerCreateFunctionStatement(BaseExpression): """SQL Server CREATE FUNCTION (scalar) statement (2005+). Attributes: name: Function name (may be schema-qualified like ``dbo.f``). parameters: List of parameters (see - ``SQLServerCreateProcedureExpression``). + ``SQLServerCreateProcedureStatement``). returns: Scalar return data type, e.g. ``INT``. body: Raw T-SQL body passed through verbatim. schema: Optional schema qualifier. or_alter: Whether to render ``CREATE OR ALTER FUNCTION``. Example: - >>> expr = SQLServerCreateFunctionExpression( + >>> expr = SQLServerCreateFunctionStatement( ... dialect, "f", ["@x INT"], returns="INT", ... body="RETURN @x * 2;", schema="dbo", ... ) >>> sql, params = expr.to_sql() >>> assert sql == "CREATE FUNCTION [dbo].[f] (@x INT) RETURNS INT AS BEGIN RETURN @x * 2; END;" """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -124,7 +126,7 @@ def to_sql(self) -> SQLQueryAndParams: return self.dialect.format_create_function_statement(self) -class SQLServerDropRoutineExpression(BaseExpression): +class SQLServerDropRoutineStatement(BaseExpression): """SQL Server DROP PROCEDURE / DROP FUNCTION statement (2005+). Attributes: @@ -134,10 +136,11 @@ class SQLServerDropRoutineExpression(BaseExpression): if_exists: Render ``IF EXISTS`` (SQL Server 2016+ only). Example: - >>> expr = SQLServerDropRoutineExpression(dialect, "p", "PROCEDURE") + >>> expr = SQLServerDropRoutineStatement(dialect, "p", "PROCEDURE") >>> sql, params = expr.to_sql() >>> assert sql == "DROP PROCEDURE [p]" """ + KIND_RESERVED = ("SQLServer",) def __init__( self, diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/trigger.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/trigger.py index bdc2b51..b50d0c1 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/trigger.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/trigger.py @@ -19,7 +19,7 @@ from ...dialect import SQLServerDialect -class SQLServerCreateTriggerExpression(BaseExpression): +class SQLServerCreateTriggerStatement(BaseExpression): """SQL Server CREATE TRIGGER statement (2005+). Attributes: @@ -33,13 +33,14 @@ class SQLServerCreateTriggerExpression(BaseExpression): or_alter: Whether to render ``CREATE OR ALTER TRIGGER``. Example: - >>> expr = SQLServerCreateTriggerExpression( + >>> expr = SQLServerCreateTriggerStatement( ... dialect, "trg", "t", timing="AFTER", events=["INSERT"], ... body="SET NOCOUNT ON;", ... ) >>> sql, params = expr.to_sql() >>> assert sql == "CREATE TRIGGER [trg] ON [t] AFTER INSERT AS BEGIN SET NOCOUNT ON; END;" """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -80,7 +81,7 @@ def to_sql(self) -> SQLQueryAndParams: return self.dialect.format_create_trigger_statement(self) -class SQLServerDropTriggerExpression(BaseExpression): +class SQLServerDropTriggerStatement(BaseExpression): """SQL Server DROP TRIGGER statement (2005+). Attributes: @@ -89,10 +90,11 @@ class SQLServerDropTriggerExpression(BaseExpression): if_exists: Render ``IF EXISTS`` (SQL Server 2016+ only). Example: - >>> expr = SQLServerDropTriggerExpression(dialect, "trg") + >>> expr = SQLServerDropTriggerStatement(dialect, "trg") >>> sql, params = expr.to_sql() >>> assert sql == "DROP TRIGGER [trg]" """ + KIND_RESERVED = ("SQLServer",) def __init__( self, diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/type.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/type.py index 40013ab..c75c628 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/type.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/ddl/type.py @@ -8,14 +8,14 @@ from rhosocial.activerecord.backend.expression.bases import BaseExpression from rhosocial.activerecord.backend.expression.objects import Type -from rhosocial.activerecord.backend.expression.serialization import ExpressionRegistry +from rhosocial.activerecord.backend.expression.serialization import default_registry from rhosocial.activerecord.backend.expression.statements.ddl_table import ( ColumnDefinition, IndexDefinition, TableConstraint, ) from rhosocial.activerecord.backend.expression.statements.ddl_type import ( - DropTypeExpression, + DropTypeStatement, TypeDefinition, ) from rhosocial.activerecord.backend.expression.types import DataType @@ -68,6 +68,7 @@ def _require_data_type(value: DataType, field_name: str) -> None: class SQLServerAliasTypeDefinition(TypeDefinition): """SQL Server alias TYPE definition.""" + KIND_RESERVED = ("SQLServer",) definition_kind = "alias" @@ -96,6 +97,7 @@ class SQLServerTableTypeDefinition(TypeDefinition): the reverse) and the reconstruction takes the same branch. Renderers read :attr:`constraint_definitions`, never one particular spelling's slot. """ + KIND_RESERVED = ("SQLServer",) definition_kind = "table" @@ -157,6 +159,7 @@ def constraint_definitions(self) -> List[TableConstraint]: class SQLServerClrTypeDefinition(TypeDefinition): """SQL Server CLR user-defined TYPE definition.""" + KIND_RESERVED = ("SQLServer",) definition_kind = "clr" @@ -174,7 +177,7 @@ def __init__( self.class_name = class_name -class SQLServerDropTypeExpression(DropTypeExpression): +class SQLServerDropTypeExpression(DropTypeStatement): """SQL Server DROP TYPE expression with explicit unsupported behavior flags. ``type`` is the :class:`~rhosocial.activerecord.backend.expression.objects.Type` @@ -182,6 +185,7 @@ class SQLServerDropTypeExpression(DropTypeExpression): ``RESTRICT`` for ``DROP TYPE``, and asking for one is an error here rather than a silently dropped token. """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -201,8 +205,9 @@ def __init__( self.restrict = restrict -class SQLServerRenameTypeExpression(BaseExpression): +class SQLServerRenameTypeStatement(BaseExpression): """SQL Server TYPE rename statement using ``sp_rename``.""" + KIND_RESERVED = ("SQLServer",) @property def format_method(self) -> str: @@ -233,13 +238,22 @@ def __init__( SQLServerTableTypeDefinition, SQLServerClrTypeDefinition, SQLServerDropTypeExpression, - SQLServerRenameTypeExpression, + SQLServerRenameTypeStatement, SQLServerColumnDefinition, SQLServerIndexDefinition, ) +# The kind whitelist the artifact reader resolves through. Registering into +# the framework's default registry (the same singleton deserialization +# consults) is what keeps these backend-owned kinds readable once a process +# imports this package; re-registering a class the framework already knows +# is a no-op. The nullability enum needs no registration of its own: an +# artifact carries only JSON scalars, and this package normalises the enum +# to a bool at construction (see _normalise_nullability), so no member ever +# reaches the wire. +_registry = default_registry() for _expression_class in _SQLSERVER_TYPE_EXPRESSIONS: - ExpressionRegistry.register(_expression_class) + _registry.register_node(_expression_class) __all__ = [ @@ -248,5 +262,5 @@ def __init__( "SQLServerTableTypeDefinition", "SQLServerClrTypeDefinition", "SQLServerDropTypeExpression", - "SQLServerRenameTypeExpression", + "SQLServerRenameTypeStatement", ] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/dml.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/dml.py index 43a627b..0062cb3 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/dml.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/dml.py @@ -4,8 +4,8 @@ from typing import TYPE_CHECKING, List, Optional from rhosocial.activerecord.backend.expression.statements import ( - MergeExpression, - QueryExpression, + MergeStatement, + QueryStatement, ) if TYPE_CHECKING: # pragma: no cover @@ -13,7 +13,7 @@ from rhosocial.activerecord.backend.expression.bases import BaseExpression -class SQLServerMergeExpression(MergeExpression): +class SQLServerMergeExpression(MergeStatement): """A SQL Server MERGE statement extending the generic one. SQL Server adds the ``WITH (HOLDLOCK)`` table hint and the ``OUTPUT`` @@ -21,6 +21,7 @@ class SQLServerMergeExpression(MergeExpression): typed fields and are rendered by the SQL Server ``format_merge_statement`` override. """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -50,13 +51,14 @@ def __init__( self.holdlock = holdlock -class SQLServerSelectIntoExpression(QueryExpression): +class SQLServerSelectIntoExpression(QueryStatement): """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. """ + KIND_RESERVED = ("SQLServer",) def __init__( self, diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/fulltext.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/fulltext.py index bb0e3bf..57c0a89 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/fulltext.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/fulltext.py @@ -21,7 +21,7 @@ from ..dialect import SQLServerDialect -class SQLServerCreateFullTextCatalogExpression(BaseExpression): +class SQLServerCreateFullTextCatalogStatement(BaseExpression): """SQL Server CREATE FULLTEXT CATALOG statement (2005+). Attributes: @@ -30,10 +30,11 @@ class SQLServerCreateFullTextCatalogExpression(BaseExpression): for the database). Example: - >>> expr = SQLServerCreateFullTextCatalogExpression(dialect, "ftc", is_default=True) + >>> expr = SQLServerCreateFullTextCatalogStatement(dialect, "ftc", is_default=True) >>> sql, params = expr.to_sql() >>> assert sql == "CREATE FULLTEXT CATALOG [ftc] AS DEFAULT" """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -52,23 +53,24 @@ def validate(self, strict: bool = True) -> None: raise ValueError("catalog_name is required") def to_sql(self) -> SQLQueryAndParams: - sql, params = self.dialect.format_create_fulltext_catalog_statement( + sql, params = self.dialect.format_create_full_text_catalog_statement( self.catalog_name, as_default=self.is_default ) return sql, params -class SQLServerDropFullTextCatalogExpression(BaseExpression): +class SQLServerDropFullTextCatalogStatement(BaseExpression): """SQL Server DROP FULLTEXT CATALOG statement (2005+). Attributes: catalog_name: Name of the full-text catalog to drop. Example: - >>> expr = SQLServerDropFullTextCatalogExpression(dialect, "ftc") + >>> expr = SQLServerDropFullTextCatalogStatement(dialect, "ftc") >>> sql, params = expr.to_sql() >>> assert sql == "DROP FULLTEXT CATALOG [ftc]" """ + KIND_RESERVED = ("SQLServer",) def __init__(self, dialect: "SQLServerDialect", catalog_name: str): super().__init__(dialect) @@ -81,11 +83,11 @@ def validate(self, strict: bool = True) -> None: raise ValueError("catalog_name is required") def to_sql(self) -> SQLQueryAndParams: - sql, params = self.dialect.format_drop_fulltext_catalog_statement(self.catalog_name) + sql, params = self.dialect.format_drop_full_text_catalog_statement(self.catalog_name) return sql, params -class SQLServerCreateFullTextIndexExpression(BaseExpression): +class SQLServerCreateFullTextIndexStatement(BaseExpression): """SQL Server CREATE FULLTEXT INDEX statement (2005+). Attributes: @@ -95,12 +97,13 @@ class SQLServerCreateFullTextIndexExpression(BaseExpression): catalog_name: Catalog that owns the full-text index. Example: - >>> expr = SQLServerCreateFullTextIndexExpression( + >>> expr = SQLServerCreateFullTextIndexStatement( ... dialect, "t", ["body"], key_index="pk", catalog_name="ftc" ... ) >>> sql, params = expr.to_sql() >>> assert sql == "CREATE FULLTEXT INDEX ON [t] ([body]) KEY INDEX [pk] ON [ftc]" """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -129,7 +132,7 @@ def validate(self, strict: bool = True) -> None: raise ValueError("catalog_name is required") def to_sql(self) -> SQLQueryAndParams: - sql, params = self.dialect.format_create_fulltext_index_statement( + sql, params = self.dialect.format_create_full_text_index_statement( self.table, self.columns, self.key_index, @@ -138,17 +141,18 @@ def to_sql(self) -> SQLQueryAndParams: return sql, params -class SQLServerDropFullTextIndexExpression(BaseExpression): +class SQLServerDropFullTextIndexStatement(BaseExpression): """SQL Server DROP FULLTEXT INDEX statement (2005+). Attributes: table: Table whose full-text index should be dropped. Example: - >>> expr = SQLServerDropFullTextIndexExpression(dialect, "t") + >>> expr = SQLServerDropFullTextIndexStatement(dialect, "t") >>> sql, params = expr.to_sql() >>> assert sql == "DROP FULLTEXT INDEX ON [t]" """ + KIND_RESERVED = ("SQLServer",) def __init__(self, dialect: "SQLServerDialect", table: str): super().__init__(dialect) @@ -161,5 +165,5 @@ def validate(self, strict: bool = True) -> None: raise ValueError("table is required") def to_sql(self) -> SQLQueryAndParams: - sql, params = self.dialect.format_drop_fulltext_index_statement(self.table) + sql, params = self.dialect.format_drop_full_text_index_statement(self.table) return sql, params diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/functions.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/functions.py index f94a22e..137d3ae 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/functions.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/functions.py @@ -1,7 +1,7 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/expression/functions.py """SQL Server-specific function expressions.""" -from typing import Any, List, Optional, Tuple, TYPE_CHECKING +from typing import Any, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLQueryAndParams @@ -17,6 +17,7 @@ class SQLServerTryCastExpression(BaseExpression): Example: >>> expr = SQLServerTryCastExpression(dialect, "value", "INT") """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -39,7 +40,10 @@ def to_sql(self) -> SQLQueryAndParams: expr_sql, expr_params = self.expression.to_sql() else: expr_sql, expr_params = str(self.expression), () - return f"TRY_CAST({expr_sql} AS {self.target_type})", expr_params + from rhosocial.activerecord.backend.expression.types import DataType + + target_sql, _ = DataType.as_type(self.dialect, self.target_type).to_sql() + return f"TRY_CAST({expr_sql} AS {target_sql})", expr_params class SQLServerTryConvertExpression(BaseExpression): @@ -50,6 +54,7 @@ class SQLServerTryConvertExpression(BaseExpression): Example: >>> expr = SQLServerTryConvertExpression(dialect, "INT", "value", style=100) """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -74,7 +79,10 @@ def to_sql(self) -> SQLQueryAndParams: expr_sql, expr_params = self.expression.to_sql() else: expr_sql, expr_params = str(self.expression), () - sql = f"TRY_CONVERT({self.target_type}, {expr_sql}" + from rhosocial.activerecord.backend.expression.types import DataType + + target_sql, _ = DataType.as_type(self.dialect, self.target_type).to_sql() + sql = f"TRY_CONVERT({target_sql}, {expr_sql}" if self.style is not None: sql += f", {self.style}" sql += ")" @@ -87,6 +95,7 @@ class SQLServerContainsPredicate(BaseExpression): Example: >>> expr = SQLServerContainsPredicate(dialect, "title", "database") """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -123,6 +132,7 @@ class SQLServerFreetextPredicate(BaseExpression): Example: >>> expr = SQLServerFreetextPredicate(dialect, "title", "database design") """ + KIND_RESERVED = ("SQLServer",) def __init__( self, diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/graph.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/graph.py index de60a05..48ae4d3 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/graph.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/graph.py @@ -8,7 +8,7 @@ """ from enum import Enum -from typing import List, Optional, Sequence, Tuple, TYPE_CHECKING +from typing import Any, List, Optional, Sequence, Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLPredicate from rhosocial.activerecord.backend.expression.core import Column @@ -17,18 +17,48 @@ from ..dialect import SQLServerDialect -class SQLServerGraphDirection(Enum): - """Direction of an edge in a MATCH pattern.""" +class SQLServerGraphDirection(str, Enum): + """Direction of an edge in a MATCH pattern. + + A member *is* the word it carries -- ``str.__str__``, not ``Enum.__str__``, + which would win the base order otherwise -- because the member rides inside + the graph nodes' params and a param that travels in an artifact must be a + JSON scalar. Plain ``Enum`` raises "not one of the value kinds" the moment + anything serialises the node, which is how the spelling was settled. + """ + + __str__ = str.__str__ RIGHT = "->" # -(edge)-> LEFT = "<-" # <-(edge)- +def _as_direction(value: Any) -> SQLServerGraphDirection: + """Read a MATCH edge direction as the member it names. + + A member and the word it carries are the same thing here -- the enum is a + ``str`` subclass, so a word *is* the member's value -- but only the member + answers ``.value``, and an artifact hands back the word. Normalising at + construction keeps one spelling inside the node and lets the formatter read + a field rather than guess what it was given. + """ + if isinstance(value, SQLServerGraphDirection): + return value + if isinstance(value, str): + for member in SQLServerGraphDirection: + if member.value == value: + return member + raise ValueError( + f"unknown MATCH edge direction {value!r}; use '->' or '<-'" + ) + + class SQLServerGraphPseudoColumn(Column): """A SQL Graph pseudo-column: ``$node_id`` / ``$edge_id`` / ``$from_id`` / ``$to_id``. Pseudo-columns must never be quoted; the name is validated at construction. """ + KIND_RESERVED = ("SQLServer",) _ALLOWED = frozenset({"$node_id", "$edge_id", "$from_id", "$to_id"}) @@ -49,6 +79,7 @@ class SQLServerGraphNodeRef(BaseExpression): alias: Optional alias used in the ASCII-art pattern. last_node: Render as ``LAST_NODE()``. """ + KIND_RESERVED = ("SQLServer",) def __init__(self, dialect: "SQLServerDialect", table: str, alias: Optional[str] = None, last_node: bool = False): @@ -64,12 +95,13 @@ def format_method(self) -> str: class SQLServerGraphEdgeRef(BaseExpression): """An edge reference inside a MATCH pattern (``-(edge)->`` / ``<-(edge)-``).""" + KIND_RESERVED = ("SQLServer",) def __init__(self, dialect: "SQLServerDialect", edge: str, direction: SQLServerGraphDirection = SQLServerGraphDirection.RIGHT): super().__init__(dialect) self.edge = edge - self.direction = direction + self.direction = _as_direction(direction) @property def format_method(self) -> str: @@ -82,6 +114,7 @@ class SQLServerGraphPattern(BaseExpression): Example: ``Person1-(friend)->Person2`` """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -104,6 +137,7 @@ class SQLServerMatchPredicate(SQLPredicate): Multiple patterns are combined with ``AND`` or ``,`` (the two forms SQL Server accepts). ``OR`` / ``NOT`` are rejected at construction. """ + KIND_RESERVED = ("SQLServer",) _COMBINATORS = ("AND", ",") @@ -132,6 +166,7 @@ class SQLServerShortestPathExpression(BaseExpression): SQL Server restricts the quantifier to ``+`` or ``{1,n}``; ``minimum`` must therefore be ``1``. """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -170,7 +205,7 @@ def patterns(self) -> List: @property def format_method(self) -> str: - return "format_sqlserver_shortest_path" + return "format_shortest_path_expression" class SQLServerGraphPathAggregate(BaseExpression): @@ -179,6 +214,7 @@ class SQLServerGraphPathAggregate(BaseExpression): Example: ``STRING_AGG(Person2.name, '->') WITHIN GROUP (GRAPH PATH) AS Friends`` """ + KIND_RESERVED = ("SQLServer",) def __init__(self, dialect: "SQLServerDialect", function: str, expression: BaseExpression, separator: Optional[str] = None, alias: Optional[str] = None): @@ -207,6 +243,7 @@ class SQLServerForPathTable(BaseExpression): statement and renames nothing in the catalogue, so it is not a schema object. """ + KIND_RESERVED = ("SQLServer",) def __init__( self, diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/index.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/index.py index 1567358..6ef02d1 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/index.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/index.py @@ -18,6 +18,7 @@ class SQLServerIndexDefinition(IndexDefinition): Adds the SQL Server-only ``hash_index`` / ``bucket_count`` options for memory-optimized (in-memory) tables. """ + KIND_RESERVED = ("SQLServer",) def __init__( self, diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/introspection.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/introspection.py new file mode 100644 index 0000000..550f040 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/introspection.py @@ -0,0 +1,223 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/expression/introspection.py +"""SQL Server's own introspection questions. + +Core's statement classes cover the catalogue questions every backend asks: +tables, their columns, indexes, foreign keys, views and triggers. Five families +have no core entry point and are SQL Server's own: + +* **the primary key** -- core reads the column list and asks the backend to + resolve which of those columns the key is made of; +* **schemas** -- ``sys.schemas`` with the owning principal, a namespace level + core models only optionally; +* **extended properties** -- the ``MS_Description`` family, which lives in a + catalogue shared by objects, columns, indexes and more; +* **routines** -- stored procedures and user-defined functions, which this + engine keeps in one relation and separates by ``sys.objects.type``; +* **sequences** -- ``sys.sequences``, whose values are ``sql_variant`` and + must be cast to text before the driver can fetch them. + +Each class collects all its parameters at construction -- keyword-only, since +the question is asked once and never edited -- and names, through +``format_method``, the one dialect formatter that renders it. The SQL lives on +the dialect (``SQLServerIntrospectionMixin`` in ``mixins/introspection.py``); +this module only carries the question. + +Example: + >>> expr = SQLServerPrimaryKeyQueryStatement( + ... dialect, table_name="users", schema="dbo" + ... ) + >>> sql, params = expr.to_sql() +""" + +from typing import Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.introspection import ( + IntrospectionExpression, +) + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + +__all__ = [ + "SQLServerExtendedPropertyListQueryStatement", + "SQLServerPrimaryKeyQueryStatement", + "SQLServerRoutineListQueryStatement", + "SQLServerSchemaListQueryStatement", + "SQLServerSequenceListQueryStatement", +] + + +class SQLServerPrimaryKeyQueryStatement(IntrospectionExpression): + """Ask which columns make up one table's primary key. + + Args: + dialect: The dialect the statement is bound to. + table_name: The table whose key columns are wanted. + schema: The schema the table is resolved in. + """ + + KIND_RESERVED = ("SQLServer",) + + def __init__(self, dialect: "SQLDialectBase", *, table_name: str, schema: str): + super().__init__(dialect, schema=schema) + self.table_name: str = table_name + + @property + def format_method(self) -> str: + """The dialect formatter that renders this statement.""" + return "format_primary_key_query_statement" + + def __repr__(self) -> str: + return ( + f"SQLServerPrimaryKeyQueryStatement(" + f"table_name={self.table_name!r}, schema={self.schema!r})" + ) + + +class SQLServerSchemaListQueryStatement(IntrospectionExpression): + """Ask for this database's schemas and their owning principals. + + The question has no parameters beyond the dialect: ``sys.schemas`` is + scoped to the current database, so there is nothing to narrow. + + Args: + dialect: The dialect the statement is bound to. + """ + + KIND_RESERVED = ("SQLServer",) + + @property + def format_method(self) -> str: + """The dialect formatter that renders this statement.""" + return "format_schema_list_query_statement" + + def __repr__(self) -> str: + return "SQLServerSchemaListQueryStatement()" + + +class SQLServerExtendedPropertyListQueryStatement(IntrospectionExpression): + """Ask for ``sys.extended_properties`` rows of one catalogue object. + + The object is named one of two ways, and exactly one of them: + + * ``major_id`` -- its catalogue id, when the caller already has it. The id + means something different in each ``class_desc`` family; + * ``table_name`` -- a table's name, which the dialect resolves through + ``sys.tables``/``sys.schemas`` in the same query. It is the way a caller + who only has a name can ask, and it costs one round trip rather than + two. + + Naming both (or neither) is a malformed question, refused at construction + rather than answered by guessing which one was meant. + + Args: + dialect: The dialect the statement is bound to. + major_id: The object id for ``OBJECT_OR_COLUMN`` (class 1), or the id + in the family's own space for another class. + table_name: The table whose properties are wanted. Only + ``OBJECT_OR_COLUMN`` has a table, and the dialect refuses any other + class rather than returning nothing. + minor_id: ``None`` for every row of the object, or the exact + ``minor_id`` -- ``0`` for an object-level property, a + ``column_id`` for a column-level one. + class_name: The ``class_desc`` family. The dialect refuses a name + outside SQL Server's documented set, and refuses a schema on a + family that has none. + schema: Restrict to objects in this schema, or ``None``. + """ + + KIND_RESERVED = ("SQLServer",) + + def __init__( + self, + dialect: "SQLDialectBase", + *, + major_id: Optional[int] = None, + table_name: Optional[str] = None, + minor_id: Optional[int] = None, + class_name: str = "OBJECT_OR_COLUMN", + schema: Optional[str] = None, + ): + if (major_id is None) == (table_name is None): + raise ValueError( + "an extended-property question takes exactly one of " + "major_id or table_name" + ) + super().__init__(dialect, schema=schema) + self.major_id: Optional[int] = major_id + self.table_name: Optional[str] = table_name + self.minor_id: Optional[int] = minor_id + self.class_name: str = class_name + + @property + def format_method(self) -> str: + """The dialect formatter that renders this statement.""" + return "format_extended_property_list_query_statement" + + def __repr__(self) -> str: + return ( + f"SQLServerExtendedPropertyListQueryStatement(" + f"major_id={self.major_id!r}, table_name={self.table_name!r}, " + f"minor_id={self.minor_id!r}, class_name={self.class_name!r}, " + f"schema={self.schema!r})" + ) + + +class SQLServerRoutineListQueryStatement(IntrospectionExpression): + """Ask for the routines of one schema, by ``sys.objects.type`` code. + + One class for both families: a procedure is a routine whose type is + ``P``/``PC`` and a function is one of the ``FN``/``IF``/``TF``/``FS``/ + ``FT`` codes, so the only difference between the two questions is the + code set. + + Args: + dialect: The dialect the statement is bound to. + type_codes: The ``sys.objects.type`` codes this question admits. + schema: The schema to read, or ``None`` for the default schema. + """ + + KIND_RESERVED = ("SQLServer",) + + def __init__( + self, + dialect: "SQLDialectBase", + *, + type_codes: Tuple[str, ...], + schema: Optional[str], + ): + super().__init__(dialect, schema=schema) + self.type_codes: Tuple[str, ...] = type_codes + + @property + def format_method(self) -> str: + """The dialect formatter that renders this statement.""" + return "format_routine_list_query_statement" + + def __repr__(self) -> str: + return ( + f"SQLServerRoutineListQueryStatement(" + f"type_codes={self.type_codes!r}, schema={self.schema!r})" + ) + + +class SQLServerSequenceListQueryStatement(IntrospectionExpression): + """Ask for this database's sequences in one schema. + + Args: + dialect: The dialect the statement is bound to. + schema: The schema to read, or ``None`` for the default schema. + """ + + KIND_RESERVED = ("SQLServer",) + + def __init__(self, dialect: "SQLDialectBase", *, schema: Optional[str]): + super().__init__(dialect, schema=schema) + + @property + def format_method(self) -> str: + """The dialect formatter that renders this statement.""" + return "format_sequence_list_query_statement" + + def __repr__(self) -> str: + return f"SQLServerSequenceListQueryStatement(schema={self.schema!r})" diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/json.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/json.py index b0db30d..9142300 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/json.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/json.py @@ -3,7 +3,7 @@ from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING -from rhosocial.activerecord.backend.expression.bases import SQLValueExpression +from rhosocial.activerecord.backend.expression.bases import SQLValue from rhosocial.activerecord.backend.expression.mixins import ( AliasableMixin, ComparisonMixin, @@ -13,12 +13,13 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class SQLServerJSONExtractExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class SQLServerJSONExtractExpression(AliasableMixin, ComparisonMixin, SQLValue): """SQL Server JSON_VALUE expression for extracting scalar values. Example: >>> expr = SQLServerJSONExtractExpression(dialect, "data", "$.name") """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -37,10 +38,10 @@ def __init__( @property def format_method(self) -> str: - return "format_json_extract" + return "format_json_extract_expression" -class SQLServerJSONUnquoteExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class SQLServerJSONUnquoteExpression(AliasableMixin, ComparisonMixin, SQLValue): """SQL Server JSON_UNQUOTE expression. Note: SQL Server's JSON_VALUE returns scalar values already unquoted, @@ -49,6 +50,7 @@ class SQLServerJSONUnquoteExpression(AliasableMixin, ComparisonMixin, SQLValueEx Example: >>> expr = SQLServerJSONUnquoteExpression(dialect, "data") """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -63,15 +65,16 @@ def __init__( @property def format_method(self) -> str: - return "format_json_unquote" + return "format_json_unquote_expression" -class SQLServerJSONObjectExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class SQLServerJSONObjectExpression(AliasableMixin, ComparisonMixin, SQLValue): """SQL Server JSON_OBJECT expression (2022+). Example: >>> expr = SQLServerJSONObjectExpression(dialect, [("name", "Alice"), ("age", 30)]) """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -86,15 +89,16 @@ def __init__( @property def format_method(self) -> str: - return "format_json_object" + return "format_json_object_expression" -class SQLServerJSONArrayExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class SQLServerJSONArrayExpression(AliasableMixin, ComparisonMixin, SQLValue): """SQL Server JSON_ARRAY expression (2022+). Example: >>> expr = SQLServerJSONArrayExpression(dialect, [1, "two", True]) """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -109,15 +113,16 @@ def __init__( @property def format_method(self) -> str: - return "format_json_array" + return "format_json_array_expression" -class SQLServerJSONContainsExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class SQLServerJSONContainsExpression(AliasableMixin, ComparisonMixin, SQLValue): """SQL Server JSON containment check using OPENJSON (2016+). Example: >>> expr = SQLServerJSONContainsExpression(dialect, "data", "value") """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -136,15 +141,16 @@ def __init__( @property def format_method(self) -> str: - return "format_json_contains" + return "format_json_contains_expression" -class SQLServerJSONSetExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class SQLServerJSONSetExpression(AliasableMixin, ComparisonMixin, SQLValue): """SQL Server JSON_MODIFY expression (single path-value pair only). Example: >>> expr = SQLServerJSONSetExpression(dialect, "data", "$.name", "Bob") """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -165,15 +171,16 @@ def __init__( @property def format_method(self) -> str: - return "format_json_set" + return "format_json_set_expression" -class SQLServerJSONRemoveExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class SQLServerJSONRemoveExpression(AliasableMixin, ComparisonMixin, SQLValue): """SQL Server JSON property removal using JSON_MODIFY (single path only). Example: >>> expr = SQLServerJSONRemoveExpression(dialect, "data", "$.name") """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -192,10 +199,10 @@ def __init__( @property def format_method(self) -> str: - return "format_json_remove" + return "format_json_remove_expression" -class SQLServerJSONTypeExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class SQLServerJSONTypeExpression(AliasableMixin, ComparisonMixin, SQLValue): """SQL Server JSON_TYPE expression. Note: SQL Server has no JSON_TYPE; classify values via OPENJSON. @@ -203,6 +210,7 @@ class SQLServerJSONTypeExpression(AliasableMixin, ComparisonMixin, SQLValueExpre Example: >>> expr = SQLServerJSONTypeExpression(dialect, "data") """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -217,15 +225,16 @@ def __init__( @property def format_method(self) -> str: - return "format_json_type" + return "format_json_type_expression" -class SQLServerJSONValidExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class SQLServerJSONValidExpression(AliasableMixin, ComparisonMixin, SQLValue): """SQL Server ISJSON function (equivalent of JSON_VALID). Example: >>> expr = SQLServerJSONValidExpression(dialect, "data") """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -240,4 +249,4 @@ def __init__( @property def format_method(self) -> str: - return "format_json_valid" + return "format_json_valid_expression" diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/locking.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/locking.py index fb6aa01..e2ef675 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/locking.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/locking.py @@ -17,6 +17,8 @@ class SQLServerTableHint: Attributes: name: Hint name (e.g., 'NOLOCK', 'UPDLOCK', 'READPAST', 'HOLDLOCK') """ + KIND_RESERVED = ("SQLServer",) + name: str def to_sql(self) -> str: @@ -25,6 +27,7 @@ def to_sql(self) -> str: class SQLServerReadPastHint(SQLServerTableHint): """SQL Server READPAST hint - skip locked rows (2019+).""" + KIND_RESERVED = ("SQLServer",) def __init__(self): super().__init__("READPAST") @@ -38,6 +41,7 @@ class SQLServerTableHintClause(BaseExpression): Example: >>> hint = SQLServerTableHintClause(dialect, [SQLServerTableHint("NOLOCK")]) """ + KIND_RESERVED = ("SQLServer",) def __init__( self, diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/objects.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/objects.py index 9850a8a..a7ffa2f 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/objects.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/objects.py @@ -28,6 +28,8 @@ :class:`~...mixins.partition.SQLServerPartitionMixin`. """ +from typing import Optional + from rhosocial.activerecord.backend.expression.objects import SchemaObject, Table __all__ = ["SQLServerTable", "PartitionFunction", "PartitionScheme"] @@ -42,20 +44,50 @@ class SQLServerTable(Table): ``CREATE TABLE [stage]`` differ only in the object, not in how the object is rendered. """ + KIND_RESERVED = ("SQLServer",) __slots__ = ("temporary",) - def __init__(self, dialect, name: str, *, temporary: bool = False, **slots) -> None: + def __init__( + self, + dialect, + name: str, + *, + temporary: bool = False, + catalog_name: Optional[str] = None, + schema_name: Optional[str] = None, + catalog_need_quote: bool = True, + schema_need_quote: bool = True, + name_need_quote: bool = True, + ) -> None: """Record the table's identity. Args: dialect: The dialect that will render this table. name: The table's own name, without the temporary marker. temporary: Whether this is a ``#``-prefixed temporary table. - **slots: ``catalog_name`` / ``schema_name`` and the three - ``*_need_quote`` flags, passed straight through. + catalog_name: Outermost namespace, or ``None`` when the table + lives in the default catalog. + schema_name: The schema the table lives in, or ``None``. + catalog_need_quote: Whether the catalog name needs brackets. + schema_need_quote: Whether the schema name needs brackets. + name_need_quote: Whether the table name needs brackets. + + The identity parameters are named rather than collected into a + catch-all: ``get_params()`` reads every constructor parameter under + its own name, and an artifact cannot carry names a ``**kwargs`` + accepted without ever declaring (the core forbids the catch-all on + expression classes for exactly this reason). """ - super().__init__(dialect, name, **slots) + super().__init__( + dialect, + name, + catalog_name=catalog_name, + schema_name=schema_name, + catalog_need_quote=catalog_need_quote, + schema_need_quote=schema_need_quote, + name_need_quote=name_need_quote, + ) self.temporary = bool(temporary) if self.temporary and not self.name.startswith("#"): self.name = f"#{self.name}" diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/openjson.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/openjson.py index b707a9b..9a1ad8d 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/openjson.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/openjson.py @@ -39,6 +39,7 @@ class SQLServerOpenJsonExpression(BaseExpression): ... alias='orders' ... ) """ + KIND_RESERVED = ("SQLServer",) def __init__( self, diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/option_hint.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/option_hint.py index 872d2fd..4eff232 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/option_hint.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/option_hint.py @@ -79,6 +79,7 @@ class SQLServerOptionHintClause(BaseExpression): >>> sql, params = clause.to_sql() >>> assert sql == "OPTION (MAXDOP 4, RECOMPILE, OPTIMIZE FOR (@p = 10))" """ + KIND_RESERVED = ("SQLServer",) def __init__(self, dialect: "SQLServerDialect", hints: Sequence[str]): super().__init__(dialect) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/output.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/output.py index 90150aa..4dd36b1 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/output.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/output.py @@ -17,6 +17,7 @@ class SQLServerOutputInsertedExpression(BaseExpression): Example: >>> expr = SQLServerOutputInsertedExpression(dialect, "id") """ + KIND_RESERVED = ("SQLServer",) def __init__(self, dialect: "SQLDialectBase", column: str): super().__init__(dialect) @@ -37,6 +38,7 @@ class SQLServerOutputDeletedExpression(BaseExpression): Represents OUTPUT deleted.column for DELETE and UPDATE statements. """ + KIND_RESERVED = ("SQLServer",) def __init__(self, dialect: "SQLDialectBase", column: str): super().__init__(dialect) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/partition.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/partition.py index c9edb62..d717c9e 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/partition.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/partition.py @@ -22,11 +22,43 @@ from ..dialect import SQLServerDialect -class SQLServerPartitionRangeDirection(Enum): +class SQLServerPartitionRangeDirection(str, Enum): + """Which side of each boundary value belongs to the partition. + + A member *is* the word it carries -- ``str.__str__``, not ``Enum.__str__``, + which would win the base order otherwise -- because the member rides inside + the partition clauses' params and a param that travels in an artifact must + be a JSON scalar. Plain ``Enum`` raises "not one of the value kinds" the + moment anything serialises the node, which is how the spelling was settled. + """ + + __str__ = str.__str__ + LEFT = "LEFT" RIGHT = "RIGHT" +def _as_range_direction(value: Any) -> SQLServerPartitionRangeDirection: + """Read a boundary direction as the member it names. + + A member and the word it carries are the same thing here -- the enum is a + ``str`` subclass, so a word *is* the member's value -- but only the member + answers ``.value``, and an artifact hands back the word. Normalising at + construction keeps one spelling inside the node and lets the formatter read + a field rather than guess what it was given. + """ + if isinstance(value, SQLServerPartitionRangeDirection): + return value + if isinstance(value, str): + try: + return SQLServerPartitionRangeDirection(value.strip().upper()) + except ValueError: + pass + raise ValueError( + f"unknown RANGE direction {value!r}; use LEFT or RIGHT" + ) + + class SQLServerPartitionByRangeClause(PartitionClause): """SQL Server RANGE partitioning clause with boundary direction. @@ -34,6 +66,7 @@ class SQLServerPartitionByRangeClause(PartitionClause): (LEFT or RIGHT) specifies which side of each boundary value belongs to the partition. RIGHT is the default in SQL Server. """ + KIND_RESERVED = ("SQLServer",) strategy_type = PartitionStrategy @@ -51,7 +84,7 @@ def __init__( keys, ) self.partition_scheme = partition_scheme - self.range_direction = range_direction + self.range_direction = _as_range_direction(range_direction) def to_sql(self) -> SQLQueryAndParams: return self.dialect.format_partition_clause(self) @@ -65,6 +98,7 @@ class SQLServerPartitionFunctionExpression(BaseExpression): CREATE PARTITION FUNCTION pf_name (DATE) AS RANGE RIGHT FOR VALUES ('2020-01-01', '2021-01-01') """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -79,10 +113,10 @@ def __init__( self.function_name = function_name self.data_type = data_type self.boundary_values = list(boundary_values) - self.range_direction = range_direction + self.range_direction = _as_range_direction(range_direction) def to_sql(self) -> SQLQueryAndParams: - return self.dialect.format_sqlserver_partition_function(self) + return self.dialect.format_partition_function_expression(self) class SQLServerPartitionSchemeExpression(BaseExpression): @@ -94,6 +128,7 @@ class SQLServerPartitionSchemeExpression(BaseExpression): AS PARTITION pf_name TO ([PRIMARY], fg1, fg2) """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -113,4 +148,4 @@ def __init__( self.all_filegroup = all_filegroup def to_sql(self) -> SQLQueryAndParams: - return self.dialect.format_sqlserver_partition_scheme(self) + return self.dialect.format_partition_scheme_expression(self) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/pivot.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/pivot.py index 8ebb96b..5ec2924 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/pivot.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/pivot.py @@ -38,6 +38,7 @@ class SQLServerPivotExpression(BaseExpression): >>> sql, params = expr.to_sql() >>> assert sql == "PIVOT (SUM([sales]) FOR [quarter] IN (Q1, Q2)) [p]" """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -90,6 +91,7 @@ class SQLServerUnpivotExpression(BaseExpression): >>> sql, params = expr.to_sql() >>> assert sql == "UNPIVOT ([val] FOR [col] IN ([a], [b])) [u]" """ + KIND_RESERVED = ("SQLServer",) def __init__( self, diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/sequence.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/sequence.py index 27be180..88632c0 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/sequence.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/sequence.py @@ -9,7 +9,7 @@ This module defines the expression used to request the next value from a sequence. SQL generation is delegated to the dialect's -``format_next_value_for`` formatter. +``format_next_value_for_expression`` formatter. """ from typing import TYPE_CHECKING @@ -33,6 +33,7 @@ class SQLServerNextValueForExpression(BaseExpression): >>> sql, params = expr.to_sql() >>> assert sql == "NEXT VALUE FOR [my_seq]" """ + KIND_RESERVED = ("SQLServer",) def __init__(self, dialect: "SQLServerDialect", sequence_name: str): super().__init__(dialect) @@ -45,4 +46,4 @@ def validate(self, strict: bool = True) -> None: raise TypeError(f"sequence_name must be str, got {type(self.sequence_name)}") def to_sql(self) -> SQLQueryAndParams: - return self.dialect.format_next_value_for(self) + return self.dialect.format_next_value_for_expression(self) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/set_type.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/set_type.py index bea64a0..a8f183f 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/set_type.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/set_type.py @@ -3,7 +3,7 @@ from typing import List, Optional, TYPE_CHECKING -from rhosocial.activerecord.backend.expression.bases import SQLValueExpression +from rhosocial.activerecord.backend.expression.bases import SQLValue from rhosocial.activerecord.backend.expression.mixins import ( AliasableMixin, ComparisonMixin, @@ -13,7 +13,7 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class SQLServerSetLiteralExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class SQLServerSetLiteralExpression(AliasableMixin, ComparisonMixin, SQLValue): """SQL Server SET literal expression (comma-separated values in VARCHAR). SQL Server stores comma-separated values in VARCHAR columns. @@ -21,6 +21,7 @@ class SQLServerSetLiteralExpression(AliasableMixin, ComparisonMixin, SQLValueExp Example: >>> expr = SQLServerSetLiteralExpression(dialect, ["a", "b", "c"]) """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -37,10 +38,10 @@ def __init__( @property def format_method(self) -> str: - return "format_set_literal" + return "format_set_literal_expression" -class SQLServerFindInSetExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class SQLServerFindInSetExpression(AliasableMixin, ComparisonMixin, SQLValue): """SQL Server FIND_IN_SET equivalent using LIKE predicate. SQL Server has no FIND_IN_SET; membership is checked with a LIKE predicate. @@ -48,6 +49,7 @@ class SQLServerFindInSetExpression(AliasableMixin, ComparisonMixin, SQLValueExpr Example: >>> expr = SQLServerFindInSetExpression(dialect, "value", "set_column") """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -64,15 +66,16 @@ def __init__( @property def format_method(self) -> str: - return "format_find_in_set" + return "format_find_in_set_expression" -class SQLServerSetContainsExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class SQLServerSetContainsExpression(AliasableMixin, ComparisonMixin, SQLValue): """SQL Server SET containment predicate as AND-ed membership checks. Example: >>> expr = SQLServerSetContainsExpression(dialect, "col", ["a", "b"]) """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -89,4 +92,4 @@ def __init__( @property def format_method(self) -> str: - return "format_set_contains" + return "format_set_contains_expression" diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/spatial.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/spatial.py index e769969..1df4e49 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/spatial.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/spatial.py @@ -3,7 +3,7 @@ from typing import Optional, TYPE_CHECKING -from rhosocial.activerecord.backend.expression.bases import SQLValueExpression +from rhosocial.activerecord.backend.expression.bases import SQLValue from rhosocial.activerecord.backend.expression.mixins import ( AliasableMixin, ComparisonMixin, @@ -13,12 +13,13 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class SQLServerSpatialLiteralExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class SQLServerSpatialLiteralExpression(AliasableMixin, ComparisonMixin, SQLValue): """SQL Server WKT literal using geometry::STGeomFromText. Example: >>> expr = SQLServerSpatialLiteralExpression(dialect, "POINT (1 2)", srid=4326) """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -35,15 +36,16 @@ def __init__( @property def format_method(self) -> str: - return "format_spatial_literal" + return "format_spatial_literal_expression" -class SQLServerSTGeomFromTextExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class SQLServerSTGeomFromTextExpression(AliasableMixin, ComparisonMixin, SQLValue): """SQL Server geometry::STGeomFromText call. Example: >>> expr = SQLServerSTGeomFromTextExpression(dialect, "POINT (1 2)", srid=0) """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -60,15 +62,16 @@ def __init__( @property def format_method(self) -> str: - return "format_st_geom_from_text" + return "format_st_geom_from_text_expression" -class SQLServerSTAsTextExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class SQLServerSTAsTextExpression(AliasableMixin, ComparisonMixin, SQLValue): """SQL Server geometry STAsText method call. Example: >>> expr = SQLServerSTAsTextExpression(dialect, "geom_col") """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -83,15 +86,16 @@ def __init__( @property def format_method(self) -> str: - return "format_st_as_text" + return "format_st_as_text_expression" -class SQLServerSTAsGeoJSONExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class SQLServerSTAsGeoJSONExpression(AliasableMixin, ComparisonMixin, SQLValue): """SQL Server geometry STAsGeoJSON method call. Example: >>> expr = SQLServerSTAsGeoJSONExpression(dialect, "geom_col") """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -106,15 +110,16 @@ def __init__( @property def format_method(self) -> str: - return "format_st_as_geojson" + return "format_st_as_geo_json_expression" -class SQLServerSTDistanceExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class SQLServerSTDistanceExpression(AliasableMixin, ComparisonMixin, SQLValue): """SQL Server geometry STDistance method call. Example: >>> expr = SQLServerSTDistanceExpression(dialect, "geom1", "geom2") """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -131,15 +136,16 @@ def __init__( @property def format_method(self) -> str: - return "format_st_distance" + return "format_st_distance_expression" -class SQLServerSTWithinExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class SQLServerSTWithinExpression(AliasableMixin, ComparisonMixin, SQLValue): """SQL Server geometry STWithin method call. Example: >>> expr = SQLServerSTWithinExpression(dialect, "geom1", "geom2") """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -156,15 +162,16 @@ def __init__( @property def format_method(self) -> str: - return "format_st_within" + return "format_st_within_expression" -class SQLServerSTContainsExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class SQLServerSTContainsExpression(AliasableMixin, ComparisonMixin, SQLValue): """SQL Server geometry STContains method call. Example: >>> expr = SQLServerSTContainsExpression(dialect, "geom1", "geom2") """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -181,10 +188,10 @@ def __init__( @property def format_method(self) -> str: - return "format_st_contains" + return "format_st_contains_expression" -class SQLServerSTDistanceSphereExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class SQLServerSTDistanceSphereExpression(AliasableMixin, ComparisonMixin, SQLValue): """SQL Server spherical distance check. SQL Server has no ST_Distance_Sphere; STDistance is used for planar distance. @@ -192,6 +199,7 @@ class SQLServerSTDistanceSphereExpression(AliasableMixin, ComparisonMixin, SQLVa Example: >>> expr = SQLServerSTDistanceSphereExpression(dialect, "geom1", "geom2") """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -208,15 +216,16 @@ def __init__( @property def format_method(self) -> str: - return "format_st_distance_sphere" + return "format_st_distance_sphere_expression" -class SQLServerSTIntersectsExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class SQLServerSTIntersectsExpression(AliasableMixin, ComparisonMixin, SQLValue): """SQL Server geometry STIntersects method call. Example: >>> expr = SQLServerSTIntersectsExpression(dialect, "geom1", "geom2") """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -233,15 +242,16 @@ def __init__( @property def format_method(self) -> str: - return "format_st_intersects" + return "format_st_intersects_expression" -class SQLServerCreateSpatialIndexExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class SQLServerCreateSpatialIndexStatement(AliasableMixin, ComparisonMixin, SQLValue): """SQL Server CREATE SPATIAL INDEX statement. Example: - >>> expr = SQLServerCreateSpatialIndexExpression(dialect, "idx", "table", "geom") + >>> expr = SQLServerCreateSpatialIndexStatement(dialect, "idx", "table", "geom") """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -260,4 +270,4 @@ def __init__( @property def format_method(self) -> str: - return "format_create_spatial_index" + return "format_create_spatial_index_statement" diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/table_options.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/table_options.py index 8e4f962..a231168 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/table_options.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/table_options.py @@ -30,6 +30,7 @@ class SQLServerCreateTableOptions(CreateTableOptions): Adds the SQL Server-only ``memory_optimized`` / ``durability`` options. """ + KIND_RESERVED = ("SQLServer",) def __init__( self, diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/temporal.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/temporal.py index 4d36abd..4d86a7c 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/temporal.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/temporal.py @@ -18,6 +18,7 @@ class SQLServerTemporalPeriodDefinition(BaseExpression): Example: >>> expr = SQLServerTemporalPeriodDefinition(dialect, "SysStartTime", "SysEndTime") """ + KIND_RESERVED = ("SQLServer",) def __init__( self, @@ -52,6 +53,7 @@ class SQLServerSystemVersioningClause(BaseExpression): ... dialect, Table(dialect, "TestTableHistory", schema_name="dbo") ... ) """ + KIND_RESERVED = ("SQLServer",) def __init__( self, diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/types.py b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/types.py index 2e29200..4727674 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/expression/types.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/expression/types.py @@ -42,6 +42,7 @@ class SQLServerNVarCharType(VarCharType): UCS-2/UTF-16 and cannot hold characters outside it, ``VARCHAR`` follows the database collation's code page. """ + KIND_RESERVED = ("SQLServer",) name = "sqlserver_nvarchar" @@ -57,6 +58,7 @@ class SQLServerNCharType(CharType): length: a shorter value is space-padded to ``n`` rather than stored as written. """ + KIND_RESERVED = ("SQLServer",) name = "sqlserver_nchar" @@ -74,6 +76,7 @@ class SQLServerNVarCharMaxType(VarCharType): than by a length of ``None`` on the base, because ``NVARCHAR`` and ``NVARCHAR(MAX)`` are different declarations in the catalog. """ + KIND_RESERVED = ("SQLServer",) name = "sqlserver_nvarchar_max" @@ -98,6 +101,7 @@ class SQLServerVarBinaryType(VarBinaryType): (:class:`SQLServerVarBinaryMaxType`). Sibling ``SQLServerVarBinaryMaxType`` stays on ``BlobType``, which is the concept that actually has no bound. """ + KIND_RESERVED = ("SQLServer",) name = "sqlserver_varbinary" @@ -116,6 +120,7 @@ class SQLServerVarBinaryMaxType(BlobType): lifted, which is what ``BlobType`` is. Kept as its own class because the declaration differs, and a schema diff compares declarations. """ + KIND_RESERVED = ("SQLServer",) name = "sqlserver_varbinary_max" @@ -131,6 +136,7 @@ class SQLServerImageType(BlobType): the type exists for reading existing schemas rather than for writing new ones. Same storage as the ``(MAX)`` spelling, hence :class:`BlobType`. """ + KIND_RESERVED = ("SQLServer",) name = "sqlserver_image" @@ -154,6 +160,7 @@ class SQLServerXmlType(XmlType): type's identity, so it does not appear here. ``SQLServerXmlType`` and the core ``XmlType`` render the same SQL under their own namespaced names. """ + KIND_RESERVED = ("SQLServer",) name = "sqlserver_xml" @@ -176,6 +183,7 @@ class SQLServerTinyIntType(TinyIntType): a signed :class:`TinyIntType`, which is the whole point of recording the field rather than assuming it. """ + KIND_RESERVED = ("SQLServer",) name = "sqlserver_tinyint" @@ -201,6 +209,7 @@ class SQLServerBitType(BooleanType): concept's own (``boolean`` / ``bool``), not T-SQL's ``BIT``: ``BIT`` is the backend's word for the type, and it belongs to this class's ``name``. """ + KIND_RESERVED = ("SQLServer",) name = "sqlserver_bit" @@ -223,6 +232,7 @@ class SQLServerUniqueIdentifierType(UUIDType): type this dialect really renders, which is the only kind of substitute the contract allows. """ + KIND_RESERVED = ("SQLServer",) name = "sqlserver_uniqueidentifier" diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/functions/aggregate.py b/src/rhosocial/activerecord/backend/impl/sqlserver/functions/aggregate.py index d2ff620..9fed77b 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/functions/aggregate.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/functions/aggregate.py @@ -3,11 +3,20 @@ SQL Server aggregate function factories. Functions: max_, min_, avg + +Each factory builds the core's classed aggregate node, which owns its kind +and dispatches to its own dialect formatter (core 9b559030); the generic +``FunctionCall`` is reserved for UDFs (core c5210245). """ from typing import Union, TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core +from rhosocial.activerecord.backend.expression.functions import ( + AvgFunction, + MaxFunction, + MinFunction, +) if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase @@ -31,22 +40,22 @@ def _convert_to_expression( def max_( dialect: "SQLServerDialect", expr: Union[str, "bases.BaseExpression"], -) -> "core.FunctionCall": - """Creates a MAX function call.""" - return core.FunctionCall(dialect, "MAX", [_convert_to_expression(dialect, expr)]) +) -> "MaxFunction": + """Creates a MAX aggregate through its class.""" + return MaxFunction(dialect, _convert_to_expression(dialect, expr)) def min_( dialect: "SQLServerDialect", expr: Union[str, "bases.BaseExpression"], -) -> "core.FunctionCall": - """Creates a MIN function call.""" - return core.FunctionCall(dialect, "MIN", [_convert_to_expression(dialect, expr)]) +) -> "MinFunction": + """Creates a MIN aggregate through its class.""" + return MinFunction(dialect, _convert_to_expression(dialect, expr)) def avg( dialect: "SQLServerDialect", expr: Union[str, "bases.BaseExpression"], -) -> "core.FunctionCall": - """Creates an AVG function call.""" - return core.FunctionCall(dialect, "AVG", [_convert_to_expression(dialect, expr)]) +) -> "AvgFunction": + """Creates an AVG aggregate through its class.""" + return AvgFunction(dialect, _convert_to_expression(dialect, expr)) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/functions/string.py b/src/rhosocial/activerecord/backend/impl/sqlserver/functions/string.py index a9f3922..a348164 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/functions/string.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/functions/string.py @@ -7,7 +7,7 @@ from typing import Union, Any, TYPE_CHECKING, List, Optional -from rhosocial.activerecord.backend.expression import bases, core +from rhosocial.activerecord.backend.expression import TrimExpression, bases, core if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase @@ -65,15 +65,22 @@ def trim( dialect: "SQLServerDialect", expression: Union[str, "bases.BaseExpression"], characters: Optional[str] = None, -) -> "core.FunctionCall": - """Creates a TRIM function call (SQL Server 2017+).""" +) -> "TrimExpression": + """Creates a TRIM node (SQL Server 2017+ for the characters form). + + Built through the core ``TRIM`` node rather than a generic call, so the + dialect's ``format_trim_expression`` owns the T-SQL spelling -- which + puts the character set *before* the operand (``TRIM(chars FROM x)``): + the two-argument call shape this factory used to emit is not T-SQL at + all. BOTH is the direction SQL Server's TRIM has always trimmed. + """ expr = _convert_to_expression(dialect, expression) - + if characters: char_literal = core.Literal(dialect, characters) - return core.FunctionCall(dialect, "TRIM", [expr, char_literal]) - - return core.FunctionCall(dialect, "TRIM", [expr]) + return TrimExpression(dialect, expr, char_literal) + + return TrimExpression(dialect, expr) def format( diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/functions/utility.py b/src/rhosocial/activerecord/backend/impl/sqlserver/functions/utility.py index 9ac126e..19e4b58 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/functions/utility.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/functions/utility.py @@ -8,6 +8,7 @@ from typing import Union, Any, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core +from rhosocial.activerecord.backend.expression.functions.string import CoalesceFunction if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase @@ -104,10 +105,16 @@ def try_parse( def coalesce( dialect: "SQLServerDialect", *expressions: Any, -) -> "core.FunctionCall": - """Creates a COALESCE function call.""" +) -> "CoalesceFunction": + """Creates a COALESCE node through its class. + + The classed node owns the kind and dispatches to its own formatter + (core 9b559030). The argument conversion this factory has always + applied -- a bare string names a column, a number becomes a literal -- + is unchanged; only the node the operands ride on changed. + """ exprs = [_convert_to_expression(dialect, e) for e in expressions] - return core.FunctionCall(dialect, "COALESCE", exprs) + return CoalesceFunction(dialect, *exprs) def nullif( diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/__init__.py index dda93c2..9640bdd 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/__init__.py @@ -6,10 +6,20 @@ SyncSQLServerStatusIntrospector, AsyncSQLServerStatusIntrospector, ) +from .types import ( + SQLServerExtendedPropertyInfo, + SQLServerRoutineInfo, + SQLServerSchemaInfo, + SQLServerSequenceInfo, +) __all__ = [ "SyncSQLServerIntrospector", "AsyncSQLServerIntrospector", "SyncSQLServerStatusIntrospector", "AsyncSQLServerStatusIntrospector", + "SQLServerExtendedPropertyInfo", + "SQLServerRoutineInfo", + "SQLServerSchemaInfo", + "SQLServerSequenceInfo", ] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/introspector.py b/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/introspector.py index 4a567ac..9f333c2 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/introspector.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/introspector.py @@ -6,34 +6,55 @@ """ import copy -from typing import Any, Dict, List, Optional, Tuple +from decimal import Decimal, InvalidOperation +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.introspection.base import ( IntrospectorMixin, SyncAbstractIntrospector, AsyncAbstractIntrospector, ) +from rhosocial.activerecord.backend.introspection.cache import IntrospectionCache from rhosocial.activerecord.backend.introspection.executor import ( SyncIntrospectorExecutor, AsyncIntrospectorExecutor, ) from rhosocial.activerecord.backend.introspection.types import ( - DatabaseInfo, - TableInfo, + IntrospectionDatabaseInfo, + IntrospectionTableInfo, TableType, - ColumnInfo, + IntrospectionColumnInfo, ColumnNullable, IndexInfo, IndexColumnInfo, IndexType, - ForeignKeyInfo, + IntrospectionForeignKeyInfo, ReferentialAction, - ViewInfo, - TriggerInfo, + IntrospectionViewInfo, + IntrospectionTriggerInfo, IntrospectionScope, ) from rhosocial.activerecord.backend.expression.types._base import DataType +from ..mixins.introspection import DEFAULT_SCHEMA +from ..query_nodes import SQLServerNodeBuilderMixin +from .types import ( + SQLServerExtendedPropertyInfo, + SQLServerRoutineInfo, + SQLServerSchemaInfo, + SQLServerSequenceInfo, +) + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + from ..expression.introspection import ( + SQLServerExtendedPropertyListQueryStatement, + SQLServerPrimaryKeyQueryStatement, + SQLServerRoutineListQueryStatement, + SQLServerSchemaListQueryStatement, + SQLServerSequenceListQueryStatement, + ) + #: ``INFORMATION_SCHEMA.COLUMNS.CHARACTER_MAXIMUM_LENGTH`` reports ``-1`` #: for a MAX-capable column. Only these three spell that marker as a length #: argument (``(MAX)``). ``xml`` reports ``-1`` as well but takes no length @@ -79,197 +100,165 @@ #: catalog rebuild; it is recorded here rather than silently widened. _DATETIME_DEFAULT_PRECISION = 7 - -class SQLServerIntrospectorMixin(IntrospectorMixin): +#: ``sys.objects.type`` codes for user-defined functions: scalar (``FN``), +#: inline table-valued (``IF``), multi-statement table-valued (``TF``), +#: CLR scalar (``FS``) and CLR table-valued (``FT``). ``sys.procedures`` +#: covers the procedure half. +_FUNCTION_TYPE_CODES = ("FN", "IF", "TF", "FS", "FT") + +#: ``sys.objects.type`` -> ``TableType``, with the two-word spellings an +#: ``INFORMATION_SCHEMA``-shaped row would carry kept beside the codes. The +#: catalogue is the only layer that distinguishes a system table from a user +#: one, so the mapping belongs here: folding ``S`` into ``BASE_TABLE`` would +#: erase the one fact the caller asked for. +_TABLE_TYPE_BY_CODE = { + "U": TableType.BASE_TABLE, + "BASE TABLE": TableType.BASE_TABLE, + "V": TableType.VIEW, + "VIEW": TableType.VIEW, + "S": TableType.SYSTEM_TABLE, + "SYSTEM TABLE": TableType.SYSTEM_TABLE, +} + + +class SQLServerIntrospectorMixin(SQLServerNodeBuilderMixin, IntrospectorMixin): """Shared non-I/O logic for SQL Server introspectors.""" - def _get_default_schema(self) -> str: - return "dbo" + @property + def default_schema(self) -> str: + return DEFAULT_SCHEMA + + @property + def _node_dialect(self) -> "SQLDialectBase": + """The dialect the node builders bind their trees to.""" + return self.dialect def _get_version(self) -> tuple: return getattr(self._backend, '_version', (15, 0, 0)) # ------------------------------------------------------------------ - # SQL builders + # Backend-specific statement factories + # + # Every family core models is a statement its dialect renders; this + # layer constructs it and executes what comes back. The families core has + # no statement for are SQL Server's own -- the primary key, schemas, + # extended properties, routines and sequences -- and each gets a factory + # here returning the statement object. Each collects every parameter at + # construction (keyword-only: a question is asked once and never edited), + # the dialect renders it, and each carries the family name into its cache + # key (see the managers below) so two families never answer each other's + # question. + # + # Nothing here renders: a SQL string is the dialect formatter's, so a + # factory below names a statement and stops. # ------------------------------------------------------------------ - def _build_database_info_sql(self) -> Tuple[str, tuple]: - return (""" - SELECT - DB_NAME() AS db_name, - CAST(SERVERPROPERTY('ProductVersion') AS NVARCHAR(128)) AS version, - CAST(SERVERPROPERTY('Collation') AS NVARCHAR(128)) AS collation, - CAST(SERVERPROPERTY('Edition') AS NVARCHAR(128)) AS edition - """, ()) - - def _build_table_list_sql( - self, schema: Optional[str], include_system: bool, - include_views: bool = True, table_type: Optional[str] = None, - ) -> Tuple[str, tuple]: - target = schema or self._get_default_schema() - params: List[str] = [target] - p = self.dialect.p() - conditions = [f"TABLE_SCHEMA = {p}"] - if table_type: - conditions.append(f"TABLE_TYPE = {p}") - params.append(table_type) - elif not include_views: - conditions.append("TABLE_TYPE = 'BASE TABLE'") - where = " AND ".join(conditions) - return (f""" - SELECT TABLE_NAME, TABLE_TYPE - FROM INFORMATION_SCHEMA.TABLES - WHERE {where} - ORDER BY TABLE_NAME - """, tuple(params)) - - def _build_column_info_sql( - self, table_name: str, schema: Optional[str] - ) -> Tuple[str, tuple]: - target = schema or self._get_default_schema() - p = self.dialect.p() - return (f""" - SELECT - c.COLUMN_NAME, c.DATA_TYPE, c.IS_NULLABLE, - c.COLUMN_DEFAULT, c.CHARACTER_MAXIMUM_LENGTH, - c.NUMERIC_PRECISION, c.NUMERIC_SCALE, - c.DATETIME_PRECISION, - c.ORDINAL_POSITION, c.COLLATION_NAME, - COLUMNPROPERTY( - OBJECT_ID(c.TABLE_SCHEMA + '.' + c.TABLE_NAME), - c.COLUMN_NAME, 'IsIdentity' - ) AS IS_IDENTITY - FROM INFORMATION_SCHEMA.COLUMNS c - WHERE c.TABLE_SCHEMA = {p} AND c.TABLE_NAME = {p} - ORDER BY c.ORDINAL_POSITION - """, (target, table_name)) - - def _build_primary_key_sql( - self, table_name: str, schema: str - ) -> Tuple[str, tuple]: - p = self.dialect.p() - return (f""" - SELECT ccu.COLUMN_NAME - FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc - JOIN INFORMATION_SCHEMA.CONSTRAINT_COLUMN_USAGE ccu - ON tc.CONSTRAINT_NAME = ccu.CONSTRAINT_NAME - AND tc.TABLE_SCHEMA = ccu.TABLE_SCHEMA - WHERE tc.TABLE_SCHEMA = {p} - AND tc.TABLE_NAME = {p} - AND tc.CONSTRAINT_TYPE = 'PRIMARY KEY' - """, (schema, table_name)) - - def _build_index_info_sql( - self, table_name: str, schema: Optional[str] - ) -> Tuple[str, tuple]: - target = schema or self._get_default_schema() - p = self.dialect.p() - return (f""" - SELECT - i.name AS index_name, - i.is_unique, - i.is_primary_key, - i.type_desc AS index_type, - c.name AS column_name, - ic.key_ordinal AS column_position, - ic.is_descending_key - FROM sys.indexes i - JOIN sys.tables t ON i.object_id = t.object_id - JOIN sys.schemas s ON t.schema_id = s.schema_id - JOIN sys.index_columns ic - ON i.object_id = ic.object_id AND i.index_id = ic.index_id - JOIN sys.columns c - ON ic.object_id = c.object_id AND ic.column_id = c.column_id - WHERE s.name = {p} AND t.name = {p} - AND i.name IS NOT NULL - ORDER BY i.name, ic.key_ordinal - """, (target, table_name)) - - def _build_foreign_key_sql( - self, table_name: str, schema: Optional[str] - ) -> Tuple[str, tuple]: - target = schema or self._get_default_schema() - p = self.dialect.p() - return (f""" - SELECT - fk.name AS fk_name, - COL_NAME(fc.parent_object_id, fc.parent_column_id) AS col_name, - OBJECT_NAME(fk.referenced_object_id) AS ref_table, - COL_NAME(fc.referenced_object_id, fc.referenced_column_id) AS ref_col, - fk.delete_referential_action_desc AS on_delete, - fk.update_referential_action_desc AS on_update, - fc.constraint_column_id AS col_position - FROM sys.foreign_keys fk - JOIN sys.foreign_key_columns fc - ON fk.object_id = fc.constraint_object_id - JOIN sys.tables t ON fk.parent_object_id = t.object_id - JOIN sys.schemas s ON t.schema_id = s.schema_id - WHERE s.name = {p} AND t.name = {p} - ORDER BY fk.name, fc.constraint_column_id - """, (target, table_name)) - - def _build_view_list_sql( - self, schema: Optional[str], include_system: bool - ) -> Tuple[str, tuple]: - target = schema or self._get_default_schema() - p = self.dialect.p() - return (f""" - SELECT TABLE_NAME AS view_name - FROM INFORMATION_SCHEMA.VIEWS - WHERE TABLE_SCHEMA = {p} - ORDER BY TABLE_NAME - """, (target,)) - - def _build_view_info_sql( - self, view_name: str, schema: Optional[str] - ) -> Tuple[str, tuple]: - target = schema or self._get_default_schema() - p = self.dialect.p() - return (f""" - SELECT - TABLE_NAME AS view_name, - VIEW_DEFINITION AS definition, - IS_UPDATABLE - FROM INFORMATION_SCHEMA.VIEWS - WHERE TABLE_SCHEMA = {p} AND TABLE_NAME = {p} - """, (target, view_name)) - - def _build_trigger_list_sql( - self, table_name: Optional[str], schema: Optional[str] - ) -> Tuple[str, tuple]: - target = schema or self._get_default_schema() - params: List[str] = [target] - p = self.dialect.p() - table_filter = f"AND t.name = {p}" if table_name else "" - if table_name: - params.append(table_name) - return (f""" - SELECT - tr.name AS trigger_name, - t.name AS table_name, - tr.is_disabled, - tr.is_instead_of_trigger, - OBJECT_DEFINITION(tr.object_id) AS definition - FROM sys.triggers tr - JOIN sys.tables t ON tr.parent_id = t.object_id - JOIN sys.schemas s ON t.schema_id = s.schema_id - WHERE s.name = {p} {table_filter} - ORDER BY tr.name - """, tuple(params)) + def primary_key_statement( + self, *, table_name: str, schema: str + ) -> "SQLServerPrimaryKeyQueryStatement": + """Return the statement that lists one table's primary-key columns.""" + from ..expression.introspection import SQLServerPrimaryKeyQueryStatement + + return SQLServerPrimaryKeyQueryStatement(self.dialect, table_name=table_name, schema=schema) + + def schema_list_statement(self) -> "SQLServerSchemaListQueryStatement": + """Return the statement that lists this database's schemas.""" + from ..expression.introspection import SQLServerSchemaListQueryStatement + + return SQLServerSchemaListQueryStatement(self.dialect) + + def extended_property_list_statement( + self, + *, + major_id: Optional[int] = None, + table_name: Optional[str] = None, + minor_id: Optional[int] = None, + class_name: str = "OBJECT_OR_COLUMN", + schema: Optional[str] = None, + ) -> "SQLServerExtendedPropertyListQueryStatement": + """Return the statement that reads one object's extended properties. + + The object is named by ``major_id`` -- the object id for + ``OBJECT_OR_COLUMN`` (class 1) and the id in the family's own space for + the other classes -- or by ``table_name``, which the statement resolves + through ``sys.tables``. The statement takes exactly one of the two; + ``minor_id`` narrows to one column's properties. ``schema`` only means + something for the object-scoped classes, and the formatter refuses it + for a family that has none rather than returning nothing. + """ + from ..expression.introspection import ( + SQLServerExtendedPropertyListQueryStatement, + ) + + return SQLServerExtendedPropertyListQueryStatement( + self.dialect, + major_id=major_id, + table_name=table_name, + minor_id=minor_id, + class_name=class_name, + schema=schema, + ) + + def routine_list_statement( + self, *, type_codes: Tuple[str, ...], schema: Optional[str] + ) -> "SQLServerRoutineListQueryStatement": + """Return the statement that lists the routines with these type codes. + + One statement class for both routine families -- what separates a + procedure from a function is the ``sys.objects.type`` code set. + """ + from ..expression.introspection import SQLServerRoutineListQueryStatement + + return SQLServerRoutineListQueryStatement( + self.dialect, type_codes=type_codes, schema=schema + ) + + def procedure_list_statement( + self, *, schema: Optional[str] + ) -> "SQLServerRoutineListQueryStatement": + """Return the statement that lists procedures (types ``P``/``PC``).""" + return self.routine_list_statement(type_codes=("P", "PC"), schema=schema) + + def function_list_statement( + self, *, schema: Optional[str] + ) -> "SQLServerRoutineListQueryStatement": + """Return the statement that lists user-defined functions.""" + return self.routine_list_statement(type_codes=_FUNCTION_TYPE_CODES, schema=schema) + + def sequence_list_statement( + self, *, schema: Optional[str] + ) -> "SQLServerSequenceListQueryStatement": + """Return the statement that lists this database's sequences.""" + from ..expression.introspection import SQLServerSequenceListQueryStatement + + return SQLServerSequenceListQueryStatement(self.dialect, schema=schema) # ------------------------------------------------------------------ # Parse methods — pure Python, no I/O # ------------------------------------------------------------------ - def _parse_database_info(self, rows: List[Dict[str, Any]]) -> DatabaseInfo: + def parse_database_info(self, rows: List[Dict[str, Any]]) -> IntrospectionDatabaseInfo: + """Read the database row, taking the version from the server. + + ``server_version`` is the version the dialect was adapted to, or the + one the backend cached from its last handshake; reading it never + raises. The row's own ``version`` is the fallback, and ``"unknown"`` + with ``(0, 0, 0)`` is the answer when nothing knows -- rather than the + strict ``dialect.version``, which would refuse to report a database + just because nothing had adapted yet. + + The statement answers only these three facts, so ``table_count`` and + ``size_bytes`` stay ``None``: they are questions of their own and this + parser does not ask them on the side. + """ row = rows[0] if rows else {} - version_str = row.get("version", "Unknown") - parts = version_str.split(".") - try: - version_tuple = tuple(int(p) for p in parts[:3]) - except (ValueError, IndexError): - version_tuple = (0, 0, 0) - return DatabaseInfo( + version_tuple = self.server_version + if version_tuple is not None: + version_str = ".".join(str(part) for part in version_tuple) + else: + version_str = str(row.get("version") or "unknown") + version_tuple = self.parse_version_tuple(version_str) + return IntrospectionDatabaseInfo( name=row.get("db_name", ""), version=version_str, version_tuple=version_tuple, @@ -278,28 +267,47 @@ def _parse_database_info(self, rows: List[Dict[str, Any]]) -> DatabaseInfo: collation=row.get("collation"), ) - def _parse_tables( + @staticmethod + def parse_version_tuple(version_str: str) -> Tuple[int, int, int]: + """Read up to three numeric components, ``(0, 0, 0)`` when unreadable.""" + parts = version_str.split(".") + try: + parsed = tuple(int(part) for part in parts[:3]) + except (ValueError, TypeError): + return (0, 0, 0) + return parsed + (0,) * (3 - len(parsed)) + + def parse_tables( self, rows: List[Dict[str, Any]], schema: Optional[str] - ) -> List[TableInfo]: - target = schema or self._get_default_schema() + ) -> List[IntrospectionTableInfo]: + """Map ``sys.objects.type`` onto the shared table-kind vocabulary. + + ``U`` is a base table, ``V`` a view and ``S`` a system table; the + two-word spellings are accepted too, so a row that came from an + ``INFORMATION_SCHEMA``-shaped question still parses. An unknown code + reads as a base table, which is the least surprising answer for a + relation this backend listed as one. + """ + target = schema or self.default_schema return [ - TableInfo( + IntrospectionTableInfo( name=row["TABLE_NAME"], schema=target, - table_type=( - TableType.VIEW if row.get("TABLE_TYPE") == "VIEW" - else TableType.BASE_TABLE + table_type=_TABLE_TYPE_BY_CODE.get( + str(row.get("TABLE_TYPE") or "").strip().upper(), + TableType.BASE_TABLE, ), + comment=row.get("table_comment"), ) for row in rows ] - def _parse_columns( + def parse_columns( self, rows: List[Dict[str, Any]], table_name: str, schema: str, primary_keys: Optional[List[str]] = None, - ) -> List[ColumnInfo]: + ) -> List[IntrospectionColumnInfo]: pk_set = set(primary_keys or []) - columns: List[ColumnInfo] = [] + columns: List[IntrospectionColumnInfo] = [] for row in rows: data_type = (row.get("DATA_TYPE") or "").lower() max_len = row.get("CHARACTER_MAXIMUM_LENGTH") @@ -353,7 +361,7 @@ def _parse_columns( except Exception: pass - columns.append(ColumnInfo( + columns.append(IntrospectionColumnInfo( name=col_name, table_name=table_name, schema=schema, @@ -370,6 +378,7 @@ def _parse_columns( is_primary_key=col_name in pk_set, is_unique=False, is_auto_increment=bool(row.get("IS_IDENTITY")), + comment=row.get("column_comment"), character_maximum_length=max_len, numeric_precision=precision, numeric_scale=scale, @@ -377,7 +386,7 @@ def _parse_columns( )) return columns - def _parse_indexes( + def parse_indexes( self, rows: List[Dict[str, Any]], table_name: str, schema: str ) -> List[IndexInfo]: type_map = { @@ -406,22 +415,22 @@ def _parse_indexes( )) return list(index_map.values()) - def _parse_foreign_keys( + def parse_foreign_keys( self, rows: List[Dict[str, Any]], table_name: str, schema: str - ) -> List[ForeignKeyInfo]: + ) -> List[IntrospectionForeignKeyInfo]: action_map = { "NO_ACTION": ReferentialAction.NO_ACTION, "CASCADE": ReferentialAction.CASCADE, "SET_NULL": ReferentialAction.SET_NULL, "SET_DEFAULT": ReferentialAction.SET_DEFAULT, } - fk_map: Dict[str, ForeignKeyInfo] = {} + fk_map: Dict[str, IntrospectionForeignKeyInfo] = {} for row in rows: fk_name = row.get("fk_name", "") if fk_name not in fk_map: on_del = (row.get("on_delete") or "NO_ACTION").upper() on_upd = (row.get("on_update") or "NO_ACTION").upper() - fk_map[fk_name] = ForeignKeyInfo( + fk_map[fk_name] = IntrospectionForeignKeyInfo( name=fk_name, table_name=table_name, schema=schema, @@ -441,49 +450,170 @@ def _parse_foreign_keys( ) return list(fk_map.values()) - def _parse_views( + def parse_views( self, rows: List[Dict[str, Any]], schema: str - ) -> List[ViewInfo]: + ) -> List[IntrospectionViewInfo]: + """Read the list rows into view info, definition included. + + The list statement projects ``VIEW_DEFINITION`` and ``IS_UPDATABLE`` + (see ``format_view_list_query_statement``), so the two facts the + single-view question answers are on every row here as well. + ``definition`` stays ``None`` only when the server withheld it -- a view + whose text the caller may not read answers with ``NULL``, which is a + different thing from a parser that never selected the column. + """ return [ - ViewInfo( + IntrospectionViewInfo( name=row.get("view_name", ""), schema=schema, - definition=None, + definition=row.get("definition"), + is_updatable=row.get("IS_UPDATABLE") == "YES", ) for row in rows ] - def _parse_view_info( + def parse_view_info( self, rows: List[Dict[str, Any]], view_name: str, schema: str - ) -> Optional[ViewInfo]: + ) -> Optional[IntrospectionViewInfo]: if not rows: return None row = rows[0] - return ViewInfo( + return IntrospectionViewInfo( name=row.get("view_name", view_name), schema=schema, definition=row.get("definition"), is_updatable=row.get("IS_UPDATABLE") == "YES", ) - def _parse_triggers( + def parse_triggers( self, rows: List[Dict[str, Any]], schema: str - ) -> List[TriggerInfo]: + ) -> List[IntrospectionTriggerInfo]: + """Fold the one-row-per-event answer back into one row per trigger. + + The list statement joins ``sys.trigger_events``, so a trigger that + fires on ``INSERT`` and ``UPDATE`` arrives twice. Each row's + ``is_disabled`` and ``definition`` are the trigger's, repeated per + event; ``status`` is read from the first instead of being written as + ``ENABLED`` for every trigger, which is what the hard-coded default + amounted to. + """ + triggers: Dict[str, IntrospectionTriggerInfo] = {} + for row in rows: + name = row.get("trigger_name", "") + trigger = triggers.get(name) + if trigger is None: + trigger = IntrospectionTriggerInfo( + name=name, + table_name=row.get("table_name", ""), + schema=schema, + timing=( + "INSTEAD OF" if row.get("is_instead_of_trigger") + else "AFTER" + ), + events=[], + definition=row.get("definition"), + status="DISABLED" if row.get("is_disabled") else "ENABLED", + ) + triggers[name] = trigger + event = row.get("event") + # A LEFT JOIN row for a trigger with no event of its own carries a + # NULL, which is the absence of an event rather than one. + if event and event not in trigger.events: + trigger.events.append(event) + return list(triggers.values()) + + # ------------------------------------------------------------------ + # Backend-specific parsers + # ------------------------------------------------------------------ + + def parse_schemas( + self, rows: List[Dict[str, Any]] + ) -> List[SQLServerSchemaInfo]: return [ - TriggerInfo( - name=row.get("trigger_name", ""), - table_name=row.get("table_name", ""), - schema=schema, - timing=( - "INSTEAD OF" if row.get("is_instead_of_trigger") - else "AFTER" - ), - events=[], - definition=row.get("definition"), + SQLServerSchemaInfo( + name=row.get("schema_name", ""), + schema_id=int(row.get("schema_id") or 0), + owner=row.get("owner"), + ) + for row in rows + ] + + def parse_extended_properties( + self, rows: List[Dict[str, Any]] + ) -> List[SQLServerExtendedPropertyInfo]: + properties: List[SQLServerExtendedPropertyInfo] = [] + for row in rows: + value = row.get("property_value") + properties.append( + SQLServerExtendedPropertyInfo( + name=row.get("property_name", ""), + # The cast widened a sql_variant to text; a NULL value is + # a property that was set to NULL, not a missing property, + # so it stays None rather than becoming "". + value=None if value is None else str(value), + class_id=int(row.get("class_id") or 0), + class_name=row.get("class_name") or "", + major_id=int(row.get("major_id") or 0), + minor_id=int(row.get("minor_id") or 0), + ) + ) + return properties + + def parse_routines( + self, rows: List[Dict[str, Any]] + ) -> List[SQLServerRoutineInfo]: + return [ + SQLServerRoutineInfo( + name=row.get("routine_name", ""), + schema=row.get("schema_name"), + type_code=(row.get("type_code") or "").strip(), + type_desc=row.get("type_desc") or "", + created_at=row.get("created_at"), + modified_at=row.get("modified_at"), + is_ms_shipped=bool(row.get("is_ms_shipped")), ) for row in rows ] + def parse_sequences( + self, rows: List[Dict[str, Any]] + ) -> List[SQLServerSequenceInfo]: + sequences: List[SQLServerSequenceInfo] = [] + for row in rows: + cache_size = row.get("cache_size") + sequences.append( + SQLServerSequenceInfo( + name=row.get("sequence_name", ""), + schema=row.get("schema_name"), + data_type=row.get("data_type"), + start_value=self._as_decimal(row.get("start_value")), + increment=self._as_decimal(row.get("increment")), + minimum_value=self._as_decimal(row.get("minimum_value")), + maximum_value=self._as_decimal(row.get("maximum_value")), + current_value=self._as_decimal(row.get("current_value")), + is_cycling=bool(row.get("is_cycling")), + is_cached=bool(row.get("is_cached")), + cache_size=int(cache_size) if cache_size is not None else None, + ) + ) + return sequences + + @staticmethod + def _as_decimal(value: Any) -> Optional[Decimal]: + """Read a cast-to-text sequence value back as an exact number. + + ``None`` means the server reported NULL (an unstarted sequence has no + ``current_value``); unparseable text also degrades to ``None`` rather + than aborting the whole listing, matching the contract + ``parsed_data_type`` follows for columns. + """ + if value is None: + return None + try: + return Decimal(str(value)) + except (InvalidOperation, ValueError): + return None + class SyncSQLServerIntrospector( SQLServerIntrospectorMixin, SyncAbstractIntrospector @@ -491,17 +621,20 @@ class SyncSQLServerIntrospector( """Synchronous introspector for SQL Server backends.""" def __init__( - self, backend: Any, executor: SyncIntrospectorExecutor + self, + backend: Any, + executor: SyncIntrospectorExecutor, + cache: Optional[IntrospectionCache] = None, ) -> None: - super().__init__(backend, executor) + super().__init__(backend, executor, cache) def get_table_info( self, table_name: str, schema: Optional[str] = None - ) -> Optional[TableInfo]: - key = self._make_cache_key( + ) -> Optional[IntrospectionTableInfo]: + key = self._cache.key( IntrospectionScope.TABLE, table_name, schema=schema ) - cached = self._get_cached(key) + cached = self._cache.get(key) if cached is not None: return cached @@ -516,32 +649,166 @@ def get_table_info( table.columns = self.list_columns(table_name, schema) table.indexes = self.list_indexes(table_name, schema) table.foreign_keys = self.list_foreign_keys(table_name, schema) - self._set_cached(key, table) + self._cache.set(key, table) return table def list_columns( self, table_name: str, schema: Optional[str] = None - ) -> List[ColumnInfo]: - target = schema if schema is not None else self._get_default_schema() - key = self._make_cache_key( + ) -> List[IntrospectionColumnInfo]: + target = schema if schema is not None else self.default_schema + key = self._cache.key( IntrospectionScope.COLUMN, table_name, schema=target ) - cached = self._get_cached(key) + cached = self._cache.get(key) if cached is not None: return cached - pk_sql, pk_params = self._build_primary_key_sql(table_name, target) + pk_sql, pk_params = self.primary_key_statement( + table_name=table_name, schema=target + ).to_sql() pk_rows = self._executor.execute(pk_sql, pk_params) primary_keys = [r.get("COLUMN_NAME") for r in pk_rows] - sql, params = self._build_column_info_sql(table_name, schema) + sql, params = self.column_info_statement( + table_name=table_name, schema=schema + ).to_sql() rows = self._executor.execute(sql, params) - columns = self._parse_columns( + columns = self.parse_columns( rows, table_name, target, primary_keys ) - self._set_cached(key, columns) + self._cache.set(key, columns) return columns + # ------------------------------------------------------------------ + # Backend-specific questions (sync) + # + # The cache keys follow the same discipline as core's shared methods: + # the scope comes from IntrospectionScope, the schema rides the + # dedicated argument, and a family that shares a scope with another + # family names itself in ``extra``. Comparison is by scope value and + # schema, so ``list_procedures(schema="dbo")`` and + # ``list_functions(schema="dbo")`` never answer each other. + # ------------------------------------------------------------------ + + def list_schemas(self) -> List[SQLServerSchemaInfo]: + """List the database's schemas with their owning principal.""" + key = self._cache.key(IntrospectionScope.SCHEMA) + cached = self._cache.get(key) + if cached is not None: + return cached + + sql, params = self.schema_list_statement().to_sql() + schemas = self.parse_schemas(self._executor.execute(sql, params)) + self._cache.set(key, schemas) + return schemas + + def list_extended_properties( + self, + major_id: Optional[int] = None, + *, + table_name: Optional[str] = None, + minor_id: Optional[int] = None, + class_name: str = "OBJECT_OR_COLUMN", + schema: Optional[str] = None, + ) -> List[SQLServerExtendedPropertyInfo]: + """List ``sys.extended_properties`` rows for one catalogue object. + + The object is named by id or by table name, and exactly one of the + two: ``major_id`` when the caller has the object id, ``table_name`` + when all it has is the name -- the statement resolves that name + through ``sys.tables`` itself, so it is one query rather than a lookup + followed by a read. + + Args: + major_id: The object id for ``OBJECT_OR_COLUMN`` (class 1), or the + id in the family's own space for other classes. + table_name: The table whose properties are wanted. Only + ``OBJECT_OR_COLUMN`` has a table, and the statement refuses any + other class rather than returning nothing. + minor_id: ``None`` for every row of the object (the object's own + properties plus its columns'), or the exact ``minor_id`` -- + ``0`` for an object-level property, a ``column_id`` for a + column-level one. + class_name: The ``class_desc`` family, ``OBJECT_OR_COLUMN`` by + default -- the family ``MS_Description`` normally lives in. + schema: Restrict to objects in this schema. Only meaningful for + the object-scoped classes; asking for a schema on a family + that has none raises ``ValueError`` instead of returning + nothing. + + The cache entry is keyed on whichever name was given, so + ``invalidate_cache(TABLE, "
")`` drops the one asked by table + name but not the one asked by id; clear the scope for the latter. + """ + key = self._cache.key( + IntrospectionScope.TABLE, + table_name if table_name is not None else str(major_id), + schema=schema, + extra=f"extended_properties:{class_name}:{minor_id}", + ) + cached = self._cache.get(key) + if cached is not None: + return cached + + sql, params = self.extended_property_list_statement( + major_id=major_id, + table_name=table_name, + minor_id=minor_id, + class_name=class_name, + schema=schema, + ).to_sql() + properties = self.parse_extended_properties( + self._executor.execute(sql, params) + ) + self._cache.set(key, properties) + return properties + + def list_procedures( + self, schema: Optional[str] = None + ) -> List[SQLServerRoutineInfo]: + """List stored procedures in *schema* (``sys.objects`` types P/PC).""" + key = self._cache.key( + IntrospectionScope.TABLE, schema=schema, extra="procedures" + ) + cached = self._cache.get(key) + if cached is not None: + return cached + + sql, params = self.procedure_list_statement(schema=schema).to_sql() + procedures = self.parse_routines(self._executor.execute(sql, params)) + self._cache.set(key, procedures) + return procedures + + def list_functions( + self, schema: Optional[str] = None + ) -> List[SQLServerRoutineInfo]: + """List user-defined functions in *schema*.""" + key = self._cache.key( + IntrospectionScope.TABLE, schema=schema, extra="functions" + ) + cached = self._cache.get(key) + if cached is not None: + return cached + + sql, params = self.function_list_statement(schema=schema).to_sql() + functions = self.parse_routines(self._executor.execute(sql, params)) + self._cache.set(key, functions) + return functions + + def list_sequences( + self, schema: Optional[str] = None + ) -> List[SQLServerSequenceInfo]: + """List sequences in *schema* (``sys.sequences``).""" + key = self._cache.key(IntrospectionScope.SEQUENCE, schema=schema) + cached = self._cache.get(key) + if cached is not None: + return cached + + sql, params = self.sequence_list_statement(schema=schema).to_sql() + sequences = self.parse_sequences(self._executor.execute(sql, params)) + self._cache.set(key, sequences) + return sequences + class AsyncSQLServerIntrospector( SQLServerIntrospectorMixin, AsyncAbstractIntrospector @@ -549,17 +816,20 @@ class AsyncSQLServerIntrospector( """Asynchronous introspector for SQL Server backends.""" def __init__( - self, backend: Any, executor: AsyncIntrospectorExecutor + self, + backend: Any, + executor: AsyncIntrospectorExecutor, + cache: Optional[IntrospectionCache] = None, ) -> None: - super().__init__(backend, executor) + super().__init__(backend, executor, cache) async def get_table_info( self, table_name: str, schema: Optional[str] = None - ) -> Optional[TableInfo]: - key = self._make_cache_key( + ) -> Optional[IntrospectionTableInfo]: + key = self._cache.key( IntrospectionScope.TABLE, table_name, schema=schema ) - cached = self._get_cached(key) + cached = self._cache.get(key) if cached is not None: return cached @@ -576,28 +846,141 @@ async def get_table_info( table.foreign_keys = await self.list_foreign_keys( table_name, schema ) - self._set_cached(key, table) + self._cache.set(key, table) return table async def list_columns( self, table_name: str, schema: Optional[str] = None - ) -> List[ColumnInfo]: - target = schema if schema is not None else self._get_default_schema() - key = self._make_cache_key( + ) -> List[IntrospectionColumnInfo]: + target = schema if schema is not None else self.default_schema + key = self._cache.key( IntrospectionScope.COLUMN, table_name, schema=target ) - cached = self._get_cached(key) + cached = self._cache.get(key) if cached is not None: return cached - pk_sql, pk_params = self._build_primary_key_sql(table_name, target) + pk_sql, pk_params = self.primary_key_statement( + table_name=table_name, schema=target + ).to_sql() pk_rows = await self._executor.execute(pk_sql, pk_params) primary_keys = [r.get("COLUMN_NAME") for r in pk_rows] - sql, params = self._build_column_info_sql(table_name, schema) + sql, params = self.column_info_statement( + table_name=table_name, schema=schema + ).to_sql() rows = await self._executor.execute(sql, params) - columns = self._parse_columns( + columns = self.parse_columns( rows, table_name, target, primary_keys ) - self._set_cached(key, columns) + self._cache.set(key, columns) return columns + + # ------------------------------------------------------------------ + # Backend-specific questions (async) + # + # Same signatures as the sync manager, same cache keys, same parsers; + # only the executor is awaited. + # ------------------------------------------------------------------ + + async def list_schemas(self) -> List[SQLServerSchemaInfo]: + """List the database's schemas with their owning principal.""" + key = self._cache.key(IntrospectionScope.SCHEMA) + cached = self._cache.get(key) + if cached is not None: + return cached + + sql, params = self.schema_list_statement().to_sql() + schemas = self.parse_schemas(await self._executor.execute(sql, params)) + self._cache.set(key, schemas) + return schemas + + async def list_extended_properties( + self, + major_id: Optional[int] = None, + *, + table_name: Optional[str] = None, + minor_id: Optional[int] = None, + class_name: str = "OBJECT_OR_COLUMN", + schema: Optional[str] = None, + ) -> List[SQLServerExtendedPropertyInfo]: + """List ``sys.extended_properties`` rows for one catalogue object. + + Same signature and same cache key as the synchronous manager: the + object is named by id or by table name, and exactly one of the two. + """ + key = self._cache.key( + IntrospectionScope.TABLE, + table_name if table_name is not None else str(major_id), + schema=schema, + extra=f"extended_properties:{class_name}:{minor_id}", + ) + cached = self._cache.get(key) + if cached is not None: + return cached + + sql, params = self.extended_property_list_statement( + major_id=major_id, + table_name=table_name, + minor_id=minor_id, + class_name=class_name, + schema=schema, + ).to_sql() + properties = self.parse_extended_properties( + await self._executor.execute(sql, params) + ) + self._cache.set(key, properties) + return properties + + async def list_procedures( + self, schema: Optional[str] = None + ) -> List[SQLServerRoutineInfo]: + """List stored procedures in *schema* (``sys.objects`` types P/PC).""" + key = self._cache.key( + IntrospectionScope.TABLE, schema=schema, extra="procedures" + ) + cached = self._cache.get(key) + if cached is not None: + return cached + + sql, params = self.procedure_list_statement(schema=schema).to_sql() + procedures = self.parse_routines( + await self._executor.execute(sql, params) + ) + self._cache.set(key, procedures) + return procedures + + async def list_functions( + self, schema: Optional[str] = None + ) -> List[SQLServerRoutineInfo]: + """List user-defined functions in *schema*.""" + key = self._cache.key( + IntrospectionScope.TABLE, schema=schema, extra="functions" + ) + cached = self._cache.get(key) + if cached is not None: + return cached + + sql, params = self.function_list_statement(schema=schema).to_sql() + functions = self.parse_routines( + await self._executor.execute(sql, params) + ) + self._cache.set(key, functions) + return functions + + async def list_sequences( + self, schema: Optional[str] = None + ) -> List[SQLServerSequenceInfo]: + """List sequences in *schema* (``sys.sequences``).""" + key = self._cache.key(IntrospectionScope.SEQUENCE, schema=schema) + cached = self._cache.get(key) + if cached is not None: + return cached + + sql, params = self.sequence_list_statement(schema=schema).to_sql() + sequences = self.parse_sequences( + await self._executor.execute(sql, params) + ) + self._cache.set(key, sequences) + return sequences + diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/status_introspector.py b/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/status_introspector.py index 14c54b2..26d1144 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/status_introspector.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/status_introspector.py @@ -4,9 +4,15 @@ Provides server status information by querying SQL Server DMVs: sys.configurations, sys.dm_exec_sessions, sys.dm_os_performance_counters, sys.databases, sys.server_principals, SERVERPROPERTY(). + +Every query below is built from expression nodes and rendered by the dialect +(org guide §16.2): this layer asks the catalogue a question, it does not spell +the SQL. The row shapes the parsers read -- the projection order and the +aliases -- are the ones the previous statement text produced, so the parsing +above and below is unchanged. """ -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.introspection.status import ( StatusItem, @@ -21,10 +27,20 @@ AsyncAbstractStatusIntrospector, ) +from ..query_nodes import SQLServerNodeBuilderMixin + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase -class SQLServerStatusIntrospectorMixin: + +class SQLServerStatusIntrospectorMixin(SQLServerNodeBuilderMixin): """Shared non-I/O logic for SQL Server status introspectors.""" + @property + def dialect(self) -> "SQLDialectBase": + """The dialect these status queries are rendered by.""" + return self._backend.dialect + def _get_vendor_name(self) -> str: return "SQL Server" @@ -61,6 +77,178 @@ def _build_server_overview( users=users, ) + # ------------------------------------------------------------------ + # The status queries, expressed once for both sync and async + # ------------------------------------------------------------------ + + def _version_sql(self) -> Tuple[str, tuple]: + """The engine's product version as text.""" + return self._select([self._cast(self._server_property("ProductVersion"), "NVARCHAR(128)")]) + + def _server_property(self, name: str): + """``SERVERPROPERTY('')``; the name is part of the question.""" + return self._fn("SERVERPROPERTY", self._lit(name, inline=True)) + + def _configuration_sql(self) -> Tuple[str, tuple]: + """``sys.configurations``, one row per server setting, ordered by name.""" + return self._select( + [ + self._col("name"), + self._cast(self._col("value_in_use"), "NVARCHAR(4000)").as_("value_in_use"), + self._col("is_dynamic"), + self._col("is_advanced"), + ], + from_=self._relation("configurations", "sys"), + order_by=[self._col("name")], + ) + + def _performance_metrics_sql(self, limit: Optional[int] = 50) -> Tuple[str, tuple]: + """The busiest performance counters, highest first, capped at *limit*. + + The cap travels as the pagination node, which SQL Server spells + ``OFFSET 0 ROWS FETCH NEXT n ROWS ONLY`` -- the same rows, in the same + order, that ``SELECT TOP 50`` returned. That spelling is 2012+, so the + caller passes ``None`` on an older server: the ordering still holds and + every counter is read, where dropping the cap into an unsupported + clause would raise and the caller's handler would report no metrics at + all. Both shapes are the dialect's; the choice between them is this + backend's. + """ + return self._select( + [ + self._fn("RTRIM", self._col("counter_name"), alias="counter_name"), + self._col("cntr_value"), + self._fn("RTRIM", self._col("object_name"), alias="object_name"), + ], + from_=self._relation("dm_os_performance_counters", "sys"), + where=self._gt(self._col("cntr_value"), self._lit(0, inline=True)), + order_by=[(self._col("cntr_value"), "DESC")], + limit=limit, + ) + + def _performance_metrics_limit(self) -> Optional[int]: + """The row cap to ask for, or ``None`` where pagination is unavailable. + + SQL Server learned ``OFFSET``/``FETCH`` in 2012. An unadapted dialect + (no version yet) is treated as current: the backend adapts before the + status introspector runs, and guessing "old" would silently uncap a + modern server. + """ + from ..mixins.version_constants import SQL_SERVER_2012 + + try: + version = self.dialect.version + except Exception: + return 50 + return 50 if version >= SQL_SERVER_2012 else None + + def _connection_info_sql(self) -> Tuple[str, tuple]: + """Live connection count plus the server's configured ceiling. + + Both halves are scalar subqueries: they read different catalogues, and + the row is one row of answers rather than a join. + """ + return self._select( + [ + self._subquery( + [self._fn("COUNT", self._row_count_wildcard())], + from_=self._relation("dm_exec_connections", "sys"), + alias="active", + ), + self._subquery( + [self._cast(self._col("value_in_use"), "INT")], + from_=self._relation("configurations", "sys"), + where=self._eq( + self._col("name"), self._lit("user connections", inline=True) + ), + alias="max_conn", + ), + ] + ) + + def _storage_info_sql(self) -> Tuple[str, tuple]: + """Total bytes across the file set, in the engine's 8 KiB page units.""" + size_bytes = self._mul( + self._mul(self._col("size"), self._lit(8, inline=True)), + self._lit(1024, inline=True), + ) + return self._select( + [self._fn("SUM", size_bytes, alias="total_bytes")], + from_=self._relation("master_files", "sys"), + where=self._gt(self._col("database_id"), self._lit(0, inline=True)), + ) + + def _database_list_sql(self) -> Tuple[str, tuple]: + """Each database with its data-file bytes (``mf.type = 0``).""" + size_bytes = self._mul( + self._mul(self._col("size", "mf"), self._lit(8, inline=True)), + self._lit(1024, inline=True), + ) + joins = self._join( + self._relation("databases", "sys", alias="d"), + self._relation("master_files", "sys", alias="mf"), + join_type="LEFT JOIN", + condition=self._and( + self._eq( + self._col("database_id", "d"), self._col("database_id", "mf") + ), + self._eq(self._col("type", "mf"), self._lit(0, inline=True)), + ), + ) + return self._select( + [ + self._col("name", "d", alias="db_name"), + self._fn("SUM", size_bytes, alias="size_bytes"), + ], + from_=joins, + group_by=[self._col("name", "d")], + order_by=[self._col("name", "d")], + ) + + def _user_list_sql(self) -> Tuple[str, tuple]: + """Server principals that can log in, with the sysadmin flag.""" + return self._select( + [ + self._col("name"), + self._col("type_desc"), + self._fn( + "IS_SRVROLEMEMBER", + self._lit("sysadmin", inline=True), + self._col("name"), + alias="is_sysadmin", + ), + ], + from_=self._relation("server_principals", "sys"), + where=self._in(self._col("type"), ("S", "U", "G")), + order_by=[self._col("name")], + ) + + def _session_info_sql(self) -> Tuple[str, tuple]: + """The current session: login, database, host, transport, TLS flag. + + ``SYSTEM_USER`` is the niladic value function, not a column of that + name, so it is built as a call with no parentheses; quoting it as an + identifier would ask the catalogue for a column instead. + """ + return self._select( + [ + self._fn("SYSTEM_USER", alias="session_user", niladic=True), + self._fn("DB_NAME", alias="cur_db"), + self._fn("HOST_NAME", alias="host"), + self._fn( + "CONNECTIONPROPERTY", + self._lit("net_transport", inline=True), + alias="transport", + ), + self._fn( + "CONNECTIONPROPERTY", + self._lit("encrypt_option", inline=True), + alias="encrypt", + ), + ] + ) + + class SyncSQLServerStatusIntrospector(SQLServerStatusIntrospectorMixin, SyncAbstractStatusIntrospector): """Synchronous SQL Server status introspector.""" @@ -97,9 +285,7 @@ def get_overview(self) -> ServerOverview: def _get_version_string(self) -> str: try: - rows = self._execute_query( - "SELECT CAST(SERVERPROPERTY('ProductVersion') AS NVARCHAR(128))" - ) + rows = self._execute_query(self._version_sql()[0]) return rows[0][0] if rows else "Unknown" except Exception: return "Unknown" @@ -107,13 +293,7 @@ def _get_version_string(self) -> str: def list_configuration(self, category: Optional[StatusCategory] = None) -> List[StatusItem]: items: List[StatusItem] = [] try: - rows = self._execute_query_dict(""" - SELECT name, - CAST(value_in_use AS NVARCHAR(4000)) AS value_in_use, - is_dynamic, is_advanced - FROM sys.configurations - ORDER BY name - """) + rows = self._execute_query_dict(self._configuration_sql()[0]) except Exception: return items @@ -130,15 +310,9 @@ def list_configuration(self, category: Optional[StatusCategory] = None) -> List[ def list_performance_metrics(self, category: Optional[StatusCategory] = None) -> List[StatusItem]: items: List[StatusItem] = [] try: - rows = self._execute_query_dict(""" - SELECT TOP 50 - RTRIM(counter_name) AS counter_name, - cntr_value, - RTRIM(object_name) AS object_name - FROM sys.dm_os_performance_counters - WHERE cntr_value > 0 - ORDER BY cntr_value DESC - """) + rows = self._execute_query_dict( + self._performance_metrics_sql(self._performance_metrics_limit())[0] + ) except Exception: return items @@ -152,13 +326,7 @@ def list_performance_metrics(self, category: Optional[StatusCategory] = None) -> def get_connection_info(self) -> ConnectionInfo: try: - rows = self._execute_query_dict(""" - SELECT - (SELECT COUNT(*) FROM sys.dm_exec_connections) AS active, - (SELECT CAST(value_in_use AS INT) - FROM sys.configurations - WHERE name = 'user connections') AS max_conn - """) + rows = self._execute_query_dict(self._connection_info_sql()[0]) if rows: return ConnectionInfo( active_count=rows[0].get("active"), @@ -170,10 +338,7 @@ def get_connection_info(self) -> ConnectionInfo: def get_storage_info(self) -> StorageInfo: try: - rows = self._execute_query_dict(""" - SELECT SUM(size * 8 * 1024) AS total_bytes - FROM sys.master_files WHERE database_id > 0 - """) + rows = self._execute_query_dict(self._storage_info_sql()[0]) if rows: return StorageInfo( total_size_bytes=int(rows[0]["total_bytes"] or 0), @@ -184,16 +349,7 @@ def get_storage_info(self) -> StorageInfo: def list_databases(self) -> List[DatabaseBriefInfo]: try: - rows = self._execute_query_dict(""" - SELECT - d.name AS db_name, - SUM(mf.size * 8 * 1024) AS size_bytes - FROM sys.databases d - LEFT JOIN sys.master_files mf - ON d.database_id = mf.database_id AND mf.type = 0 - GROUP BY d.name - ORDER BY d.name - """) + rows = self._execute_query_dict(self._database_list_sql()[0]) except Exception: return [] @@ -207,13 +363,7 @@ def list_databases(self) -> List[DatabaseBriefInfo]: def list_users(self) -> List[UserInfo]: try: - rows = self._execute_query_dict(""" - SELECT name, type_desc, - IS_SRVROLEMEMBER('sysadmin', name) AS is_sysadmin - FROM sys.server_principals - WHERE type IN ('S', 'U', 'G') - ORDER BY name - """) + rows = self._execute_query_dict(self._user_list_sql()[0]) except Exception: return [] @@ -228,14 +378,7 @@ def list_users(self) -> List[UserInfo]: def get_session_info(self) -> SessionInfo: try: - rows = self._execute_query_dict(""" - SELECT - SYSTEM_USER AS session_user, - DB_NAME() AS cur_db, - HOST_NAME() AS host, - CONNECTIONPROPERTY('net_transport') AS transport, - CONNECTIONPROPERTY('encrypt_option') AS encrypt - """) + rows = self._execute_query_dict(self._session_info_sql()[0]) except Exception: return SessionInfo() @@ -286,9 +429,7 @@ async def get_overview(self) -> ServerOverview: async def _get_version_string(self) -> str: try: - rows = await self._execute_query( - "SELECT CAST(SERVERPROPERTY('ProductVersion') AS NVARCHAR(128))" - ) + rows = await self._execute_query(self._version_sql()[0]) return rows[0][0] if rows else "Unknown" except Exception: return "Unknown" @@ -296,13 +437,7 @@ async def _get_version_string(self) -> str: async def list_configuration(self, category: Optional[StatusCategory] = None) -> List[StatusItem]: items: List[StatusItem] = [] try: - rows = await self._execute_query_dict(""" - SELECT name, - CAST(value_in_use AS NVARCHAR(4000)) AS value_in_use, - is_dynamic, is_advanced - FROM sys.configurations - ORDER BY name - """) + rows = await self._execute_query_dict(self._configuration_sql()[0]) except Exception: return items @@ -319,15 +454,9 @@ async def list_configuration(self, category: Optional[StatusCategory] = None) -> async def list_performance_metrics(self, category: Optional[StatusCategory] = None) -> List[StatusItem]: items: List[StatusItem] = [] try: - rows = await self._execute_query_dict(""" - SELECT TOP 50 - RTRIM(counter_name) AS counter_name, - cntr_value, - RTRIM(object_name) AS object_name - FROM sys.dm_os_performance_counters - WHERE cntr_value > 0 - ORDER BY cntr_value DESC - """) + rows = await self._execute_query_dict( + self._performance_metrics_sql(self._performance_metrics_limit())[0] + ) except Exception: return items @@ -341,13 +470,7 @@ async def list_performance_metrics(self, category: Optional[StatusCategory] = No async def get_connection_info(self) -> ConnectionInfo: try: - rows = await self._execute_query_dict(""" - SELECT - (SELECT COUNT(*) FROM sys.dm_exec_connections) AS active, - (SELECT CAST(value_in_use AS INT) - FROM sys.configurations - WHERE name = 'user connections') AS max_conn - """) + rows = await self._execute_query_dict(self._connection_info_sql()[0]) if rows: return ConnectionInfo( active_count=rows[0].get("active"), @@ -359,10 +482,7 @@ async def get_connection_info(self) -> ConnectionInfo: async def get_storage_info(self) -> StorageInfo: try: - rows = await self._execute_query_dict(""" - SELECT SUM(size * 8 * 1024) AS total_bytes - FROM sys.master_files WHERE database_id > 0 - """) + rows = await self._execute_query_dict(self._storage_info_sql()[0]) if rows: return StorageInfo( total_size_bytes=int(rows[0]["total_bytes"] or 0), @@ -373,16 +493,7 @@ async def get_storage_info(self) -> StorageInfo: async def list_databases(self) -> List[DatabaseBriefInfo]: try: - rows = await self._execute_query_dict(""" - SELECT - d.name AS db_name, - SUM(mf.size * 8 * 1024) AS size_bytes - FROM sys.databases d - LEFT JOIN sys.master_files mf - ON d.database_id = mf.database_id AND mf.type = 0 - GROUP BY d.name - ORDER BY d.name - """) + rows = await self._execute_query_dict(self._database_list_sql()[0]) except Exception: return [] @@ -396,13 +507,7 @@ async def list_databases(self) -> List[DatabaseBriefInfo]: async def list_users(self) -> List[UserInfo]: try: - rows = await self._execute_query_dict(""" - SELECT name, type_desc, - IS_SRVROLEMEMBER('sysadmin', name) AS is_sysadmin - FROM sys.server_principals - WHERE type IN ('S', 'U', 'G') - ORDER BY name - """) + rows = await self._execute_query_dict(self._user_list_sql()[0]) except Exception: return [] @@ -417,14 +522,7 @@ async def list_users(self) -> List[UserInfo]: async def get_session_info(self) -> SessionInfo: try: - rows = await self._execute_query_dict(""" - SELECT - SYSTEM_USER AS session_user, - DB_NAME() AS cur_db, - HOST_NAME() AS host, - CONNECTIONPROPERTY('net_transport') AS transport, - CONNECTIONPROPERTY('encrypt_option') AS encrypt - """) + rows = await self._execute_query_dict(self._session_info_sql()[0]) except Exception: return SessionInfo() diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/types.py b/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/types.py new file mode 100644 index 0000000..568817c --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/types.py @@ -0,0 +1,111 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/introspection/types.py +"""Result types for this backend's own introspection questions. + +Core's ``rhosocial.activerecord.backend.introspection.types`` describes the +objects every backend has: databases, tables, columns, indexes, foreign keys, +views, triggers. SQL Server's catalogue also answers questions core has no +shape for -- which schemas exist and who owns them, which documentation +properties (``MS_Description`` and friends) an object carries, which stored +procedures and user-defined functions live in a schema, and which sequences +are declared. Those answers get their own types here rather than being +crammed into ``extra`` dictionaries: a caller that wants the owner of a +schema should read an attribute, not a string key. + +Every field is populated from a catalogue column this backend selected +explicitly; nothing is inferred, and a value the server reports as NULL stays +``None``. Numeric sequence values are :class:`~decimal.Decimal` because a +sequence can be declared on any exact numeric type, and turning one into a +``float`` here would silently round every value read back. +""" + +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal +from typing import Optional + + +@dataclass +class SQLServerSchemaInfo: + """One row of ``sys.schemas``, with its owning principal. + + ``owner`` is the *database principal* name resolved through + ``sys.schemas.principal_id``. It is ``None`` when the principal has been + dropped while the schema remains, which the catalogue permits. + """ + + name: str + schema_id: int = 0 + owner: Optional[str] = None + + +@dataclass +class SQLServerExtendedPropertyInfo: + """One row of ``sys.extended_properties``. + + ``class_id``/``class_name`` say what ``major_id`` identifies: for + ``OBJECT_OR_COLUMN`` (class 1) ``major_id`` is an ``object_id`` and + ``minor_id`` is 0 for the object itself or the ``column_id`` for a + column. ``value`` is read through a ``CAST(... AS NVARCHAR)`` because the + catalogue stores it as ``sql_variant``, which the driver cannot fetch; + the cast is why a value always arrives as text. + """ + + name: str + value: Optional[str] = None + class_id: int = 1 + class_name: str = "OBJECT_OR_COLUMN" + major_id: int = 0 + minor_id: int = 0 + + +@dataclass +class SQLServerRoutineInfo: + """One stored procedure or user-defined function. + + ``type_code`` is the two-character ``sys.objects.type`` spelling + (``P``/``PC`` for procedures, ``FN``/``IF``/``TF``/``FS``/``FT`` for + functions) and ``type_desc`` its long form. Both are kept because the + short code is what T-SQL scripts branch on and the description is what a + person reads. + """ + + name: str + schema: Optional[str] = None + type_code: str = "" + type_desc: str = "" + created_at: Optional[datetime] = None + modified_at: Optional[datetime] = None + is_ms_shipped: bool = False + + +@dataclass +class SQLServerSequenceInfo: + """One row of ``sys.sequences``. + + Every numeric field arrives through a ``CAST(... AS NVARCHAR)`` as well: + ``start_value``, ``increment``, ``minimum_value``, ``maximum_value`` and + ``current_value`` are ``sql_variant``, which the driver refuses to fetch. + They are parsed back into :class:`~decimal.Decimal` here rather than + returned as the catalog's text, so a caller comparing an increment with + a bound does arithmetic, not string comparison. + """ + + name: str + schema: Optional[str] = None + data_type: Optional[str] = None + start_value: Optional[Decimal] = None + increment: Optional[Decimal] = None + minimum_value: Optional[Decimal] = None + maximum_value: Optional[Decimal] = None + current_value: Optional[Decimal] = None + is_cycling: bool = False + is_cached: bool = False + cache_size: Optional[int] = None + + +__all__ = [ + "SQLServerExtendedPropertyInfo", + "SQLServerRoutineInfo", + "SQLServerSchemaInfo", + "SQLServerSequenceInfo", +] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py index 20a908e..9ccdd9c 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/__init__.py @@ -43,6 +43,7 @@ from .function import SQLServerFunctionMixin from .ddl_type import SQLServerTypeDDLMixin from .trim import SQLServerTrimMixin +from .literal import SQLServerLiteralValueMixin __all__ = [ "SQLServerTypeSupportMixin", @@ -89,4 +90,5 @@ "SQLServerFunctionMixin", "SQLServerTypeDDLMixin", "SQLServerTrimMixin", + "SQLServerLiteralValueMixin", ] 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 9f11827..326e5e1 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/backend_mixin.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/backend_mixin.py @@ -2,6 +2,13 @@ import re from typing import Any, Dict, Optional, Set, Tuple, Type, TYPE_CHECKING +from ..query_nodes import ( + identity_columns_sql, + identity_current_sql, + server_property_value_sql, + session_identity_sql, +) + if TYPE_CHECKING: from ..dialect import SQLServerDialect @@ -86,19 +93,19 @@ def _relocate_table_hint(self, sql: str) -> str: return (body[:table_end] + " " + hint + body[table_end:]).strip() def _get_identity_columns(self, table_name: str) -> Set[str]: - """Return the set of identity column names for a table (cached).""" + """Return the set of identity column names for a table (cached). + + The catalogue question is built from nodes and rendered by the + dialect; this method only executes it. + """ cache_key = table_name.lower() if cache_key in self._identity_columns_cache: return self._identity_columns_cache[cache_key] columns: Set[str] = set() try: + sql, params = identity_columns_sql(self.dialect, table_name) cursor = self._get_cursor() - cursor.execute( - "SELECT c.name FROM sys.identity_columns c " - "JOIN sys.tables t ON c.object_id = t.object_id " - f"WHERE t.name = {self.dialect.p()} AND SCHEMA_NAME(t.schema_id) = 'dbo'", - (table_name,), - ) + cursor.execute(sql, params) for row in cursor.fetchall(): columns.add(str(row[0]).lower()) cursor.close() @@ -175,8 +182,9 @@ def set_identity_insert(self, table: str, on: bool = True) -> None: cursor.close() def get_scope_identity(self) -> Optional[int]: + sql, params = session_identity_sql(self.dialect) cursor = self._get_cursor() - cursor.execute("SELECT @@IDENTITY") + cursor.execute(sql, params) row = cursor.fetchone() cursor.close() if row and row[0] is not None: @@ -184,8 +192,9 @@ def get_scope_identity(self) -> Optional[int]: return None def get_identity_current(self, table: str) -> Optional[int]: + sql, params = identity_current_sql(self.dialect, table) cursor = self._get_cursor() - cursor.execute(f"SELECT IDENT_CURRENT({self.dialect.p()})", (table,)) + cursor.execute(sql, params) row = cursor.fetchone() cursor.close() if row and row[0] is not None: @@ -264,47 +273,33 @@ def set_dateformat(self, format: str) -> None: cursor.close() def get_product_version(self) -> str: - cursor = self._get_cursor() - cursor.execute("SELECT SERVERPROPERTY('ProductVersion')") - row = cursor.fetchone() - cursor.close() - return row[0] if row else "" + return self._server_property_value("ProductVersion") def get_product_level(self) -> str: - cursor = self._get_cursor() - cursor.execute("SELECT SERVERPROPERTY('ProductLevel')") - row = cursor.fetchone() - cursor.close() - return row[0] if row else "" + return self._server_property_value("ProductLevel") def get_edition(self) -> str: - cursor = self._get_cursor() - cursor.execute("SELECT SERVERPROPERTY('Edition')") - row = cursor.fetchone() - cursor.close() - return row[0] if row else "" + return self._server_property_value("Edition") def get_machine_name(self) -> str: - cursor = self._get_cursor() - cursor.execute("SELECT SERVERPROPERTY('MachineName')") - row = cursor.fetchone() - cursor.close() - return row[0] if row else "" + return self._server_property_value("MachineName") def get_server_name(self) -> str: + return self._server_property_value("ServerName") + + def _server_property_value(self, name: str) -> str: + """Read one ``SERVERPROPERTY`` value; the name is part of the query.""" + sql, params = server_property_value_sql(self.dialect, name) cursor = self._get_cursor() - cursor.execute("SELECT SERVERPROPERTY('ServerName')") + cursor.execute(sql, params) row = cursor.fetchone() cursor.close() return row[0] if row else "" def is_case_sensitive(self) -> bool: - cursor = self._get_cursor() - cursor.execute("SELECT SERVERPROPERTY('Collation')") - row = cursor.fetchone() - cursor.close() - if row and row[0]: - collation = str(row[0]).upper() + collation = self._server_property_value("Collation") + if collation: + collation = str(collation).upper() return "_CS" in collation or collation.endswith("_CS_AS") return False @@ -392,7 +387,7 @@ def _is_connection_error(self, error: Exception) -> bool: ] return any(pattern in error_str for pattern in connection_error_patterns) - def _adapt_row_types(self, row_dict: Dict[str, Any], column_adapters) -> Dict[str, Any]: + def adapt_row_types(self, row_dict: Dict[str, Any], column_adapters) -> Dict[str, Any]: """Adapt row values, stripping SQL Server CHAR column padding. SQL Server pads ``CHAR``/``NCHAR`` values with trailing spaces, so a @@ -402,7 +397,7 @@ def _adapt_row_types(self, row_dict: Dict[str, Any], column_adapters) -> Dict[st that legitimately carry trailing whitespace are not produced by the column adapters used here). """ - processed_row = super()._adapt_row_types(row_dict, column_adapters) + processed_row = super().adapt_row_types(row_dict, column_adapters) for col_name, value in processed_row.items(): if isinstance(value, str): processed_row[col_name] = value.rstrip(" ") diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/collation.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/collation.py index f7151e9..fa61eaa 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/collation.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/collation.py @@ -1,11 +1,12 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/mixins/collation.py -from typing import Any, Tuple, TYPE_CHECKING +from enum import Enum +from typing import TYPE_CHECKING from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from ..collation import validate_sqlserver_collation_name if TYPE_CHECKING: - from rhosocial.activerecord.backend.expression.collation import CollateExpression + from rhosocial.activerecord.backend.expression.collation import CollateFunction class SQLServerCollationMixin: @@ -15,15 +16,17 @@ def supports_collate_expression(self) -> bool: """SQL Server supports expression-level COLLATE.""" return True - def validate_collation_name(self, expr: "CollateExpression") -> str: + def validate_collation_name(self, expr: "CollateFunction") -> str: """Validate SQL Server collation names and return their SQL representation.""" - if "schema" in expr.collation_options: + # Schema-qualified COLLATE (``schema.collation``) is the one option + # the core node names; SQL Server's grammar does not accept a + # qualified collation here, so the field is refused rather than + # silently dropped. + if expr.schema is not None: raise UnsupportedFeatureError(self.name, "schema-qualified COLLATE") - keyword = expr.collation_options.get("keyword", False) - unsupported = set(expr.collation_options) - {"keyword", "schema"} - if unsupported: - options = ", ".join(sorted(unsupported)) - raise UnsupportedFeatureError(self.name, f"COLLATE options: {options}") - if keyword and expr.collation_name != "DATABASE_DEFAULT": - raise ValueError(f"Unsupported SQL Server collation keyword: {expr.collation_name!r}") - return validate_sqlserver_collation_name(expr.collation_name, getattr(self, "version", None)) + # The core accepts a bare string or an enum member; the validator + # speaks strings, so a member is read through its value. + collation = expr.collation + if isinstance(collation, Enum): + collation = collation.value + return validate_sqlserver_collation_name(collation, getattr(self, "version", None)) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/columnstore.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/columnstore.py index f527587..10413ec 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/columnstore.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/columnstore.py @@ -1,8 +1,8 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/mixins/columnstore.py """SQL Server columnstore index mixin. -Implements the ``format_create_columnstore_index_statement`` formatter used -by ``SQLServerColumnstoreIndexExpression`` with version gating: +Implements the ``format_columnstore_index_statement`` formatter used +by ``SQLServerColumnstoreIndexStatement`` with version gating: - NONCLUSTERED COLUMNSTORE INDEX: SQL Server 2012+ (read-only) - CLUSTERED COLUMNSTORE INDEX: SQL Server 2014+ (updatable) @@ -14,7 +14,7 @@ from rhosocial.activerecord.backend.expression.objects import Index, Table if TYPE_CHECKING: - from ..expression.columnstore import SQLServerColumnstoreIndexExpression + from ..expression.columnstore import SQLServerColumnstoreIndexStatement _SQL_SERVER_NONCLUSTERED_COLUMNSTORE_VERSION = (11, 0, 0) @@ -27,18 +27,18 @@ class SQLServerColumnstoreIndexMixin: def supports_nonclustered_columnstore(self) -> bool: """NONCLUSTERED columnstore requires SQL Server 2012+.""" - return self.version >= _SQL_SERVER_NONCLUSTERED_COLUMNSTORE_VERSION # type: ignore[attr-defined] + return self.version_at_least(_SQL_SERVER_NONCLUSTERED_COLUMNSTORE_VERSION) # type: ignore[attr-defined] def supports_clustered_columnstore(self) -> bool: """CLUSTERED columnstore requires SQL Server 2014+.""" - return self.version >= _SQL_SERVER_CLUSTERED_COLUMNSTORE_VERSION # type: ignore[attr-defined] + return self.version_at_least(_SQL_SERVER_CLUSTERED_COLUMNSTORE_VERSION) # type: ignore[attr-defined] def supports_columnstore_order(self) -> bool: """ORDER (...) requires SQL Server 2022+.""" - return self.version >= _SQL_SERVER_COLUMNSTORE_ORDER_VERSION # type: ignore[attr-defined] + return self.version_at_least(_SQL_SERVER_COLUMNSTORE_ORDER_VERSION) # type: ignore[attr-defined] - def format_create_columnstore_index_statement( - self, expr: "SQLServerColumnstoreIndexExpression" + def format_columnstore_index_statement( + self, expr: "SQLServerColumnstoreIndexStatement" ) -> Tuple[str, tuple]: """Format a CREATE COLUMNSTORE INDEX statement with version gating. @@ -50,7 +50,7 @@ def format_create_columnstore_index_statement( They were listed as such because a reader compared them with the :class:`Index` and :class:`Table` objects ``CREATE INDEX`` takes, and found no field of that name. Both belong to - :class:`SQLServerColumnstoreIndexExpression`, which names its target as + :class:`SQLServerColumnstoreIndexStatement`, which names its target as text; each is turned into the matching object and rendered through the one qualified-name entry point, so the bracket-free shape below is the only shape the statement can produce. diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/concurrency.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/concurrency.py index 1f0ec5d..bcd51d3 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/concurrency.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/concurrency.py @@ -16,10 +16,11 @@ def connect(self): def _fetch_concurrency_hint(self) -> None: try: + from ..query_nodes import max_worker_threads_sql + + sql, params = max_worker_threads_sql(self.dialect) cursor = self._connection.cursor() - cursor.execute( - "SELECT value_in_use FROM sys.configurations WHERE name = 'max worker threads'" - ) + cursor.execute(sql, params) row = cursor.fetchone() cursor.close() diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/constraint.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/constraint.py index 00b7875..eca15ed 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/constraint.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/constraint.py @@ -1,17 +1,391 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/mixins/constraint.py +"""SQL Server constraint capability declarations. + +Every value in this module is grounded in a live measurement against the +instance ``tests/config/sqlserver_scenarios.yaml`` names ``sqlserver_2019``: + +* **SQL Server 2019 (RTM-CU32-GDR), 15.0.4465.1, Developer Edition**, + reached at ``127.0.0.1:11433`` with pyodbc 5.3.0 + ``ODBC Driver 17 for + SQL Server``. + +Message numbers and wordings below are the server's own, as returned in the +driver's error payload. Nothing here is transcribed from documentation; the +two places where a *documentation* claim is carried rather than a measurement +(``DROP CONSTRAINT IF EXISTS`` and ``DROP COLUMN IF EXISTS`` being available +"since 2016") say so where they are stated, in +``alter_table_modifier.py``. + +The load-bearing shape of T-SQL, stated once here so the individual docstrings +can stay short: **SQL Server has no SQL-standard constraint clauses at all.** +``ENFORCED`` / ``NOT ENFORCED``, ``DEFERRABLE`` / ``INITIALLY``, +``NOT VALID``, ``VALIDATE CONSTRAINT`` and ``ENABLE`` / ``DISABLE CONSTRAINT`` +are all syntax errors (Msg 102 or 156) -- not accepted-and-ignored. What T-SQL +offers instead is a *state* vocabulary, and it is not interchangeable with +the clause vocabulary the core protocol is named after: + +* ``ALTER TABLE ... WITH NOCHECK ADD CONSTRAINT ...`` -- accepted, leaves the + new constraint not trusted (``is_not_trusted = 1``). +* ``ALTER TABLE ... NOCHECK CONSTRAINT name`` -- accepted, sets + ``is_not_trusted = 1`` **and** ``is_disabled = 1``. Works on both CHECK + and FOREIGN KEY constraints. +* ``ALTER TABLE ... CHECK CONSTRAINT name`` and + ``WITH CHECK CHECK CONSTRAINT name`` -- accepted, re-validates existing rows + and clears ``is_not_trusted`` / ``is_disabled``. +* ``NOT FOR REPLICATION`` -- accepted at ADD CONSTRAINT time on both a foreign + key and a CHECK, and it sets ``is_not_for_replication = 1`` **with** + ``is_not_trusted = 1``. On CHECK the spelling precedes the parenthesised + condition: ``CHECK NOT FOR REPLICATION (c > 0)``. + +Naming note: the switches for the state vocabulary +(``supports_enable_disable_constraint``, ``supports_rename_constraint``) use +the names from ``.claude/plan/2026-10-11/sqlserver-constraint-coverage.md``. +The core ``AlterTableConstraintSupport`` protocol they belong to has not +landed yet, so **core may still rename them**; the measured value is what +matters and is asserted in the tests either way. + +Where the standard clause *is* answered by a probe and the *kind* is not +(``supports_constraint_enforced``, ``supports_deferrable_constraint``, +``supports_alter_constraint_enforced``, ``supports_constraint_novalidate``, +``supports_validate_constraint``), the answer is ``False`` for every kind, so +there is nothing left to narrow and this package declares **no** ``_require_*`` +helper. A narrowing helper earns its place when the probe answers True and +the *kind* is what turns it away -- SQL Server's one real narrowing is the +column-level ``REFERENCES`` clause, and it is a formatter override +(:meth:`SQLServerConstraintMixin.format_column_fk_constraint`) because the +probe it has to disagree with is the table-level one. + +The trait refusals live at the two public seams, both of which are named +rather than private: :meth:`SQLServerConstraintMixin.format_column_constraint` +for the column level, and ``SQLServerDialect.format_table_constraint`` (which +also owns the memory-optimized ``NONCLUSTERED`` spellings, so it cannot move +into this mixin) for the table level. Both go through +:meth:`SQLServerConstraintMixin._refuse_constraint_traits` so the two levels +cannot drift apart. +""" + +from typing import TYPE_CHECKING, Tuple + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, + ColumnForeignKeyConstraint, + ) class SQLServerConstraintMixin: """SQL Server constraint capability declarations.""" def supports_constraint_enforced(self) -> bool: - """SQL Server does not support ENFORCED/NOT ENFORCED constraint control.""" + """False: T-SQL has no ``ENFORCED`` / ``NOT ENFORCED`` clause. + + Measured on 2019 (15.0.4465.1), against a CHECK constraint:: + + ALTER TABLE dbo.probe_t ADD CONSTRAINT ck10 CHECK (c > 0) NOT ENFORCED + -> Incorrect syntax near the keyword 'NOT'. (156) + ALTER TABLE dbo.probe_t ADD CONSTRAINT ck11 CHECK (c > 0) ENFORCED + -> Incorrect syntax near 'ENFORCED'. (102) + + Enforcement control is a *state* on the constraint in T-SQL + (``sys.check_constraints.is_not_trusted`` / ``is_disabled``), not a + clause inside it; see the module docstring. An explicitly requested + spelling is refused by name at the two public seams + (:meth:`SQLServerConstraintMixin.format_column_constraint` and + ``SQLServerDialect.format_table_constraint``) rather than silently + dropped. + """ return False def supports_fk_match(self) -> bool: - """SQL Server does not support MATCH {SIMPLE|PARTIAL|FULL}.""" + """False: T-SQL has no ``MATCH {SIMPLE|PARTIAL|FULL}`` clause. + + Measured on 2019 (15.0.4465.1):: + + ALTER TABLE dbo.probe_t ADD CONSTRAINT fk5 FOREIGN KEY (id) + REFERENCES dbo.probe_r (id) MATCH FULL + -> Incorrect syntax near 'MATCH'. (102) + + Unlike MySQL 8 (which parses ``MATCH FULL`` and then silently drops + it), SQL Server rejects it at the parser, so a renderer that emitted + it would fail loudly rather than mislead. + """ return False def supports_deferrable_constraint(self) -> bool: - """SQL Server does not support DEFERRABLE constraints.""" + """False: T-SQL has neither ``DEFERRABLE`` nor ``INITIALLY``. + + Measured on 2019 (15.0.4465.1), against a CHECK constraint:: + + ALTER TABLE dbo.probe_t ADD CONSTRAINT ck9 CHECK (c > 0) DEFERRABLE + -> Incorrect syntax near 'DEFERRABLE'. (102) + ALTER TABLE dbo.probe_t ADD CONSTRAINT ck12 CHECK (c > 0) NOT DEFERRABLE + -> Incorrect syntax near the keyword 'NOT'. (156) + + Constraint checking is always immediate. ``NOCHECK CONSTRAINT`` + *disables* a constraint until it is re-checked -- it does not defer + evaluation to end of transaction, so it is not a fallback for + ``DEFERRABLE``. + """ + return False + + def supports_add_constraint(self) -> bool: + """True: ``ALTER TABLE ... ADD CONSTRAINT`` covers all four constraint kinds. + + Measured on 2019 (15.0.4465.1), all four accepted at ADD CONSTRAINT + time:: + + ALTER TABLE dbo.probe_t ADD CONSTRAINT ck1 CHECK NOT FOR REPLICATION (c > 0) + ALTER TABLE dbo.probe_t ADD CONSTRAINT pk1 PRIMARY KEY (id ASC, s DESC) + ALTER TABLE dbo.probe_t ADD CONSTRAINT uq1 UNIQUE (id ASC, s DESC) + ALTER TABLE dbo.probe_t ADD CONSTRAINT fk1 FOREIGN KEY (id) + REFERENCES dbo.probe_r (id) NOT FOR REPLICATION + + Two facts come with it: a table may hold **only one** primary key + (a second ``ADD CONSTRAINT ... PRIMARY KEY`` is + ``Table 'probe_t' already has a primary key defined on it. (1779)`` + plus ``Could not create constraint or index. (1750)``), and the + ``ASC`` / ``DESC`` direction on a table-level PRIMARY KEY / UNIQUE is + not ignored -- ``sys.index_columns.is_descending_key`` reads ``True`` + for the ``DESC`` column. + + Core already defaults this to ``True``; the declaration pins it to the + measurement. + """ + return True + + def supports_drop_constraint(self) -> bool: + """True: ``ALTER TABLE ... DROP CONSTRAINT name`` is accepted. + + Measured on 2019 (15.0.4465.1):: + + ALTER TABLE dbo.probe_t DROP CONSTRAINT ck2 -- accepted + ALTER TABLE dbo.probe_t DROP CONSTRAINT nope + -> 'nope' is not a constraint. (3728) + -> Could not drop constraint. See previous errors. (3727) + + So a missing constraint is an **error**, not a silent no-op: the + idempotent path is ``DROP CONSTRAINT IF EXISTS``, declared separately + by ``SQLServerAlterColumnModifierMixin``. + """ + return True + + def supports_alter_constraint_enforced(self) -> bool: + """False: ``ALTER TABLE ... ALTER CONSTRAINT`` is not T-SQL at all. + + Measured on 2019 (15.0.4465.1):: + + ALTER TABLE dbo.probe_t ALTER CONSTRAINT fk4 ENFORCED + -> Incorrect syntax near the keyword 'CONSTRAINT'. (156) + + There is no ALTER-side counterpart to the ADD-side state vocabulary + either; ``NOCHECK CONSTRAINT`` / ``CHECK CONSTRAINT`` change state, + they do not re-declare the constraint. Core defaults this to + ``False`` as well; the declaration records the measurement. + + The signature is ``(self)``. A probe answers one question -- "is this + capability family present on this server" -- and the kind is not part + of that question, so the parameter this switch used to take had no + answer to give that the parameter-less form did not already give. + """ return False + + def supports_validate_constraint(self) -> bool: + """False: T-SQL has no ``VALIDATE CONSTRAINT`` clause. + + Measured on 2019 (15.0.4465.1):: + + ALTER TABLE dbo.probe_t VALIDATE CONSTRAINT fk4 + -> Incorrect syntax near 'VALIDATE'. (102) + + The measured equivalent of "validate now" is + ``ALTER TABLE ... CHECK CONSTRAINT name`` or + ``WITH CHECK CHECK CONSTRAINT name``, both accepted, both clearing + ``is_not_trusted``. + """ + return False + + def supports_constraint_novalidate(self) -> bool: + """False: T-SQL has no ``NOT VALID`` clause. + + Measured on 2019 (15.0.4465.1):: + + ALTER TABLE dbo.probe_t ADD CONSTRAINT ck8 CHECK (c > 0) NOT VALID + -> Incorrect syntax near the keyword 'NOT'. (156) + + A not-yet-validated constraint is expressed the other way round: + ``ALTER TABLE ... WITH NOCHECK ADD CONSTRAINT ...`` is accepted and + leaves ``sys.check_constraints.is_not_trusted = 1``. That is the + closest T-SQL analogue to ``NOT VALID`` -- an ADD-time spelling, not a + clause trailing the constraint. + """ + return False + + def supports_enable_disable_constraint(self) -> bool: + """False: ``ENABLE CONSTRAINT`` / ``DISABLE CONSTRAINT`` are not T-SQL keywords. + + Measured on 2019 (15.0.4465.1), against a live CHECK constraint:: + + ALTER TABLE dbo.probe_t ENABLE CONSTRAINT ck2 + -> Incorrect syntax near the keyword 'CONSTRAINT'. (156) + ALTER TABLE dbo.probe_t DISABLE CONSTRAINT ck2 + -> Incorrect syntax near the keyword 'CONSTRAINT'. (156) + + Of the ten backends only Oracle spells ``ENABLE`` / ``DISABLE + CONSTRAINT``; SQL Server's state-changing pair is + ``NOCHECK CONSTRAINT name`` (disable) and + ``CHECK CONSTRAINT name`` (re-enable), which also **re-validate** + rather than merely flipping a flag. PostgreSQL's ``ENABLE TRIGGER`` + and other near-relative spellings do not make this one exist. + + Name note: this is the ``AlterTableConstraintSupport`` switch named in + the constraint-coverage plan; the core protocol has not landed it yet, + so core may still rename this. + """ + return False + + def supports_rename_constraint(self) -> bool: + """False: no ``RENAME CONSTRAINT ... TO ...`` clause, and ``sp_rename`` is narrower than it looks. + + Measured on 2019 (15.0.4465.1). The three-argument ``sp_rename`` + form fails for every constraint kind, with or without a schema + qualifier:: + + EXEC sp_rename 'dbo.probe_key.pk_new', 'pk_newer', 'OBJECT' + -> Either the parameter @objname is ambiguous or the claimed + @objtype (OBJECT) is wrong. (15248) + + The **two-argument** form (no ``@objtype``) is the one that works, + and it is counter-intuitive in two ways: passing ``@objtype`` is what + breaks it, and it only reaches **PRIMARY KEY and UNIQUE** constraints:: + + EXEC sp_rename 'dbo.probe_key.pk_old', 'pk_new' -- accepted, + -- renamed in sys.key_constraints + EXEC sp_rename 'dbo.probe_key.uq_old', 'uq_new' -- accepted + EXEC sp_rename 'dbo.probe_ck.ck_old', 'ck_new' -- CHECK + -> No item by the name of 'dbo.probe_ck.ck_old' could be found in + the current database 'master', given that @itemtype was input + as '(null)'. (15225) + EXEC sp_rename 'dbo.probe_key.fk_old', 'fk_new' -- FOREIGN KEY + -> No item by the name of 'dbo.probe_key.fk_old' could be found ... (15225) + + ``@objtype = 'CONSTRAINT'`` is not even a recognised value + (``Explicit @objtype 'CONSTRAINT' is unrecognized. (15249)``). + + So: no ALTER-level rename (hence ``False``), and the escape hatch is a + stored procedure, two-argument form, primary-key/unique only -- a + rename that has to be re-implemented per constraint kind is not a + dialect capability this switch can promise generically. + """ + return False + + # ------------------------------------------------------------------ + # Narrowing seams + # ------------------------------------------------------------------ + + def format_column_constraint( + self, + constraint: "ColumnConstraint", + ) -> Tuple[str, tuple]: + """Refuse the SQL-standard traits on a column constraint, by name. + + The public seam, not ``_format_constraint_enforcement``: that private + hook is shared with the table-level trait renderer, so an override + there would have to tell the two families apart by hand. Overriding + the column seam makes the family the override's own business. + + Every trait asked for here is refused before delegation, with the + spelling the caller asked for quoted in the message, so the + ``DEFERRABLE`` / ``NOT DEFERRABLE`` and ``ENFORCED`` / ``NOT ENFORCED`` + pairs stay distinguishable (see ``test_clause_pair_guard_sqlserver``). + The refusal is the measured one -- Msg 102 / 156 for all four + spellings on 2019. + + The branch, not a tag, says which kind this is; the ``CONSTRAINT + `` prefix and the branch body are core's to render. + """ + self._refuse_constraint_traits( + constraint, + deferrable=constraint.deferrable, + not_deferrable=constraint.not_deferrable, + initially_deferred=constraint.initially_deferred, + initially_immediate=constraint.initially_immediate, + enforced=constraint.enforced, + not_enforced=constraint.not_enforced, + ) + return super().format_column_constraint(constraint) + + def format_column_fk_constraint( + self, + constraint: "ColumnForeignKeyConstraint", + ) -> Tuple[str, tuple]: + """Refuse the column-level ``REFERENCES`` clause. + + **The known coupling this has to work around.** Core's + ``format_column_fk_constraint`` gates the column-level grammar on + ``supports_foreign_key_constraint()``, which is the *table-level* + probe -- and SQL Server needs that probe to be ``True``: + ``ALTER TABLE ... ADD CONSTRAINT fk1 FOREIGN KEY (id) REFERENCES + dbo.probe_r (id)`` is accepted on 2019 (see + :meth:`supports_add_constraint`). So the probe cannot be flipped to + turn the column level off, and the two levels have no separate probes + yet. Until they do, this override is where the column-level refusal + lives: it is the public formatter for the column ``REFERENCES`` + branch, so narrowing here needs no private hook and no new probe. + + A column-level reference is written as ``col INT REFERENCES t (c)``, + and T-SQL's column grammar has no ``REFERENCES`` production: a table + constraint is the only spelling, which is what + :meth:`format_table_foreign_key_constraint` renders. + """ + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + + raise UnsupportedFeatureError( + self.name, + "REFERENCES column constraint", + f"{self.name} does not support a column-level REFERENCES " + f"constraint; declare the FOREIGN KEY as a table constraint.", + ) + + def _refuse_constraint_traits( + self, + constraint, + *, + deferrable: bool, + not_deferrable: bool, + initially_deferred: bool, + initially_immediate: bool, + enforced: bool, + not_enforced: bool, + ) -> None: + """Raise on any SQL-standard constraint trait SQL Server cannot spell. + + One place for the four refusals so the column seam and the table seam + cannot drift apart: the message quotes the *requested* spelling, so + ``DEFERRABLE`` and ``NOT DEFERRABLE`` (and ``ENFORCED`` / + ``NOT ENFORCED``) are distinguishable in the error. + + Measured on SQL Server 2019 (15.0.4465.1): every one of the four + spellings is a parser error (Msg 102 or 156), not an + accepted-and-ignored clause. So this is a refusal, not a narrowing: + there is no kind for which T-SQL accepts the trait. + """ + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + + for wants, spelling in ( + (enforced or not_enforced, "ENFORCED" if enforced else "NOT ENFORCED"), + (deferrable or not_deferrable, "DEFERRABLE" if deferrable else "NOT DEFERRABLE"), + ( + initially_deferred or initially_immediate, + "INITIALLY DEFERRED" if initially_deferred else "INITIALLY IMMEDIATE", + ), + ): + if not wants: + continue + raise UnsupportedFeatureError( + self.name, + f"constraint {spelling}", + f"{self.name} does not support the {spelling} constraint.", + ) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/datetime.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/datetime.py index 249c400..73b665d 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/datetime.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/datetime.py @@ -10,6 +10,48 @@ class SQLServerDateTimeMixin: """SQL Server datetime function implementations (DATEADD, DATEDIFF, DATETRUNC).""" + def format_now_function(self, expr: "Any") -> Tuple[str, tuple]: + """``NOW()`` in T-SQL is ``SYSDATETIME()``. + + A precision argument has no spelling here -- ``SYSDATETIME()`` takes + none -- so it is refused rather than silently dropped. + """ + if expr.precision is not None: + raise UnsupportedFeatureError( + self.name, + "NOW with a precision", + "SYSDATETIME() takes no precision argument.", + ) + return self.apply_alias("SYSDATETIME()", (), expr) + + def format_current_date_function(self, expr: "Any") -> Tuple[str, tuple]: + """``CURRENT_DATE`` in T-SQL is ``CAST(GETDATE() AS DATE)``. + + A precision argument has no spelling here, so it is refused rather + than silently dropped. + """ + if expr.precision is not None: + raise UnsupportedFeatureError( + self.name, + "CURRENT_DATE with a precision", + "CAST(GETDATE() AS DATE) takes no precision argument.", + ) + return self.apply_alias("CAST(GETDATE() AS DATE)", (), expr) + + def format_current_time_function(self, expr: "Any") -> Tuple[str, tuple]: + """``CURRENT_TIME`` in T-SQL is ``CAST(GETDATE() AS TIME)``. + + A precision argument has no spelling here, so it is refused rather + than silently dropped. + """ + if expr.precision is not None: + raise UnsupportedFeatureError( + self.name, + "CURRENT_TIME with a precision", + "CAST(GETDATE() AS TIME) takes no precision argument.", + ) + return self.apply_alias("CAST(GETDATE() AS TIME)", (), expr) + def format_date_trunc_expression(self, expr: "Any") -> Tuple[str, tuple]: source_sql, source_params = expr.source.to_sql() field = expr.field.value.upper() diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_database.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_database.py index bc14af1..a563422 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_database.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_database.py @@ -8,9 +8,9 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements.ddl_database import ( - AlterDatabaseExpression, - CreateDatabaseExpression, - DropDatabaseExpression, + AlterDatabaseStatement, + CreateDatabaseStatement, + DropDatabaseStatement, ) @@ -46,7 +46,7 @@ def supports_database_tablespace(self) -> bool: return True def format_create_database_statement( - self, expr: CreateDatabaseExpression + self, expr: CreateDatabaseStatement ) -> Tuple[str, tuple]: """Format CREATE DATABASE for SQL Server. @@ -57,7 +57,7 @@ def format_create_database_statement( """ if not isinstance(expr.database, Database): raise TypeError( - f"CreateDatabaseExpression.database must be a Database, " + f"CreateDatabaseStatement.database must be a Database, " f"got {type(expr.database).__name__}" ) parts = ["CREATE DATABASE"] @@ -69,7 +69,7 @@ def format_create_database_statement( return " ".join(parts), () def format_drop_database_statement( - self, expr: DropDatabaseExpression + self, expr: DropDatabaseStatement ) -> Tuple[str, tuple]: """Format DROP DATABASE for SQL Server. @@ -80,13 +80,13 @@ def format_drop_database_statement( """ if not isinstance(expr.database, Database): raise TypeError( - f"DropDatabaseExpression.database must be a Database, " + f"DropDatabaseStatement.database must be a Database, " f"got {type(expr.database).__name__}" ) return f"DROP DATABASE {expr.database.to_sql()[0]}", () def format_alter_database_statement( - self, expr: AlterDatabaseExpression + self, expr: AlterDatabaseStatement ) -> Tuple[str, tuple]: """Format ALTER DATABASE for SQL Server. @@ -99,7 +99,7 @@ def format_alter_database_statement( if not isinstance(expr.database, Database): raise TypeError( - f"AlterDatabaseExpression.database must be a Database, " + f"AlterDatabaseStatement.database must be a Database, " f"got {type(expr.database).__name__}" ) database = expr.database.to_sql()[0] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_table.py index ce4d507..65d82f8 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_table.py @@ -1,18 +1,6 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_table.py -from typing import Tuple, TYPE_CHECKING - from .version_constants import SQL_SERVER_2016 -if TYPE_CHECKING: - from rhosocial.activerecord.backend.expression.statements import ( - CreateTableExpression, - DropTableExpression, - AlterTableExpression, - ColumnDefinition, - TableConstraint, - IndexDefinition, - ) - class SQLServerTableMixin: """SQL Server table DDL capability declarations.""" @@ -28,7 +16,7 @@ def supports_truncate(self) -> bool: def supports_if_exists_table(self) -> bool: """SQL Server supports DROP TABLE IF EXISTS (2016+).""" - return self.version >= SQL_SERVER_2016 + return self.version_at_least(SQL_SERVER_2016) def supports_drop_table_cascade(self) -> bool: """SQL Server does not support CASCADE for DROP TABLE.""" diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_type.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_type.py index 1dd7ebf..8e86a7e 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_type.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_type.py @@ -5,22 +5,28 @@ 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.objects import Index, Type from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - ColumnConstraintType, + ColumnCheckConstraint, + ColumnDefaultConstraint, + ColumnNullableConstraint, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, ColumnDefinition, IndexDefinition, + TableCheckConstraint, TableConstraint, - TableConstraintType, + TablePrimaryKeyConstraint, + TableUniqueConstraint, ) from rhosocial.activerecord.backend.expression.statements.ddl_type import ( - AlterTypeExpression, - CreateTypeExpression, - DropTypeExpression, + AlterTypeStatement, + CreateTypeStatement, + DropTypeStatement, TypeAlterAction, TypeDefinition, ) @@ -35,7 +41,7 @@ SQLServerAliasTypeDefinition, SQLServerClrTypeDefinition, SQLServerDropTypeExpression, - SQLServerRenameTypeExpression, + SQLServerRenameTypeStatement, SQLServerTableTypeDefinition, ) @@ -240,13 +246,13 @@ def _format_table_type_column( """ if not isinstance(column, ColumnDefinition): raise TypeError("table TYPE columns must be ColumnDefinition instances") - if getattr(column, "comment", None) is not None: + if column.comment 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: + if column.generated_expression is not None: raise UnsupportedFeatureError( self.name, "TYPE table computed column", @@ -259,43 +265,35 @@ def _format_table_type_column( "SPARSE and ROWGUIDCOL are not supported in table TYPE definitions.", ) allowed = { - ColumnConstraintType.PRIMARY_KEY, - ColumnConstraintType.NOT_NULL, - ColumnConstraintType.NULL, - ColumnConstraintType.DEFAULT, + ColumnPrimaryKeyConstraint, + ColumnNotNullConstraint, + ColumnNullableConstraint, + ColumnDefaultConstraint, } if not memory_optimized or self._version_at_least(SQL_SERVER_2016): allowed.update({ - ColumnConstraintType.UNIQUE, - ColumnConstraintType.CHECK, + ColumnUniqueConstraint, + ColumnCheckConstraint, }) for constraint in column.constraints: - if memory_optimized and constraint.constraint_type not in allowed: - if constraint.constraint_type in { - ColumnConstraintType.UNIQUE, - ColumnConstraintType.CHECK, - }: + branch = constraint.branch + if memory_optimized and type(branch) not in allowed: + if type(branch) in {ColumnUniqueConstraint, ColumnCheckConstraint}: raise UnsupportedFeatureError( self.name, - f"memory-optimized TYPE table column constraint {constraint.constraint_type}", + f"memory-optimized TYPE table column constraint {type(branch).__name__}", "requires SQL Server 2016+.", ) raise UnsupportedFeatureError( self.name, - f"memory-optimized TYPE table column constraint {constraint.constraint_type}", + f"memory-optimized TYPE table column constraint {type(branch).__name__}", "SQL Server memory-optimized table TYPEs allow only key constraints and DEFAULT.", ) - if constraint.constraint_type not in allowed: + if type(branch) not in allowed: raise UnsupportedFeatureError( self.name, - f"TYPE table column constraint {constraint.constraint_type}", + f"TYPE table column constraint {type(branch).__name__}", ) - 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, @@ -313,50 +311,40 @@ def _format_table_type_constraint( """ if not isinstance(constraint, TableConstraint): raise TypeError("table TYPE constraints must be TableConstraint instances") + branch = constraint.branch if memory_optimized: - if constraint.constraint_type in { - TableConstraintType.UNIQUE, - TableConstraintType.CHECK, + if type(branch) in { + TableUniqueConstraint, + TableCheckConstraint, } and not self._version_at_least(SQL_SERVER_2016): raise UnsupportedFeatureError( self.name, - f"memory-optimized TYPE table constraint {constraint.constraint_type}", + f"memory-optimized TYPE table constraint {type(branch).__name__}", "requires SQL Server 2016+.", ) - if constraint.constraint_type not in { - TableConstraintType.PRIMARY_KEY, - TableConstraintType.UNIQUE, - TableConstraintType.CHECK, + if type(branch) not in { + TablePrimaryKeyConstraint, + TableUniqueConstraint, + TableCheckConstraint, }: raise UnsupportedFeatureError( self.name, - f"memory-optimized TYPE table constraint {constraint.constraint_type}", + f"memory-optimized TYPE table constraint {type(branch).__name__}", "SQL Server memory-optimized table TYPEs allow only key constraints and CHECK.", ) - supported_types = { - TableConstraintType.PRIMARY_KEY, - TableConstraintType.UNIQUE, - TableConstraintType.CHECK, + supported_branches = { + TablePrimaryKeyConstraint, + TableUniqueConstraint, + TableCheckConstraint, } - if constraint.constraint_type not in supported_types: + if type(branch) not in supported_branches: raise UnsupportedFeatureError( self.name, - f"TYPE table constraint {constraint.constraint_type}", + f"TYPE table constraint {type(branch).__name__}", ) - 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") + if type(branch) in {TablePrimaryKeyConstraint, TableUniqueConstraint}: + if not branch.columns: + raise ValueError("table TYPE key constraint requires columns") # The enforcement / deferrability options are two-spelling pairs on the # node now: their defaults are ``False``, not ``None``, so an # ``is not None`` test would refuse every constraint. Only an @@ -388,14 +376,6 @@ def _format_table_type_constraint( self.name, "TYPE table constraint option validation", ) - 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, @@ -421,11 +401,11 @@ def _format_table_type_index( 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)), + ("partial_condition", index.partial_condition), + ("if_not_exists", index.if_not_exists), + ("tablespace", index.tablespace), + ("if_exists", index.if_exists), + ("concurrent", index.concurrent), ) for field_name, value in unsupported_values: if value: @@ -461,7 +441,7 @@ def _format_table_type_index( ) if bucket_count is None: raise ValueError("bucket_count is required for a HASH index") - index_type = getattr(index, "type", None) + index_type = index.type if index_type is not None: if not isinstance(index_type, str): raise TypeError("index type must be a string or None") @@ -499,7 +479,7 @@ def _format_table_type_index( elif memory_optimized: parts.append("NONCLUSTERED") parts.append(f"({', '.join(column_parts)})") - include_columns = getattr(index, "include_columns", None) + include_columns = index.include_columns if memory_optimized and include_columns and not self._version_at_least(SQL_SERVER_2019): raise UnsupportedFeatureError( self.name, @@ -562,19 +542,13 @@ def _format_table_type_definition( 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, - } + type(constraint.branch) + in {ColumnPrimaryKeyConstraint, ColumnUniqueConstraint} for column in expr.columns for constraint in column.constraints ) + sum( - constraint.constraint_type - in { - TableConstraintType.PRIMARY_KEY, - TableConstraintType.UNIQUE, - } + type(constraint.branch) + in {TablePrimaryKeyConstraint, TableUniqueConstraint} for constraint in constraint_definitions ) if len(expr.indexes) + key_constraint_count > 8: @@ -585,19 +559,13 @@ def _format_table_type_definition( ) if expr.memory_optimized: has_key_constraint = any( - constraint.constraint_type - in { - ColumnConstraintType.PRIMARY_KEY, - ColumnConstraintType.UNIQUE, - } + type(constraint.branch) + in {ColumnPrimaryKeyConstraint, ColumnUniqueConstraint} for column in expr.columns for constraint in column.constraints ) or any( - constraint.constraint_type - in { - TableConstraintType.PRIMARY_KEY, - TableConstraintType.UNIQUE, - } + type(constraint.branch) + in {TablePrimaryKeyConstraint, TableUniqueConstraint} for constraint in constraint_definitions ) if not has_key_constraint and not expr.indexes: @@ -612,18 +580,18 @@ def _format_table_type_definition( def format_create_type_statement( self, - expr: CreateTypeExpression, + expr: CreateTypeStatement, ) -> Tuple[str, tuple]: """Format CREATE TYPE for SQL Server. Raises: - TypeError: ``expr`` is not a CreateTypeExpression. + TypeError: ``expr`` is not a CreateTypeStatement. TypeError: ``expr.type`` is not a Type. A table handed here would render as a well-formed ``CREATE TYPE`` over that table's name. TypeError: ``expr.definition`` is not a TypeDefinition. """ - if not isinstance(expr, CreateTypeExpression): - raise TypeError("expr must be a CreateTypeExpression") + if not isinstance(expr, CreateTypeStatement): + raise TypeError("expr must be a CreateTypeStatement") 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(): @@ -634,7 +602,7 @@ def format_create_type_statement( raise UnsupportedFeatureError(self.name, "CREATE OR REPLACE TYPE") if not isinstance(expr.type, Type): raise TypeError( - f"CreateTypeExpression.type must be a Type, " + f"CreateTypeStatement.type must be a Type, " f"got {type(expr.type).__name__}" ) if not isinstance(expr.definition, TypeDefinition): @@ -651,7 +619,7 @@ def format_create_type_statement( def format_alter_type_statement( self, - expr: AlterTypeExpression, + expr: AlterTypeStatement, ) -> Tuple[str, tuple]: """Report that SQL Server has no ALTER TYPE. @@ -661,11 +629,11 @@ def format_alter_type_statement( the wrong kind should not be reported as a missing feature. Raises: - TypeError: ``expr`` is not an AlterTypeExpression. + TypeError: ``expr`` is not an AlterTypeStatement. UnsupportedFeatureError: Always, once *expr* is the right kind. """ - if not isinstance(expr, AlterTypeExpression): - raise TypeError("expr must be an AlterTypeExpression") + if not isinstance(expr, AlterTypeStatement): + raise TypeError("expr must be an AlterTypeStatement") raise UnsupportedFeatureError( self.name, "ALTER TYPE", @@ -674,20 +642,20 @@ def format_alter_type_statement( def format_drop_type_statement( self, - expr: Union[DropTypeExpression, SQLServerDropTypeExpression], + expr: Union[DropTypeStatement, SQLServerDropTypeExpression], ) -> Tuple[str, tuple]: """Format DROP TYPE for SQL Server. Raises: - TypeError: ``expr`` is not a DropTypeExpression. + TypeError: ``expr`` is not a DropTypeStatement. TypeError: ``expr.type`` is not a Type. A table handed here would render as a well-formed ``DROP TYPE`` over that table's name. """ - if not isinstance(expr, DropTypeExpression): - raise TypeError("expr must be a DropTypeExpression") + if not isinstance(expr, DropTypeStatement): + raise TypeError("expr must be a DropTypeStatement") if not isinstance(expr.type, Type): raise TypeError( - f"DropTypeExpression.type must be a Type, " + f"DropTypeStatement.type must be a Type, " f"got {type(expr.type).__name__}" ) if not self.supports_type_objects() or not self.supports_drop_type(): @@ -698,8 +666,14 @@ def format_drop_type_statement( "DROP TYPE IF EXISTS", "requires SQL Server 2016+.", ) - cascade = bool(getattr(expr, "cascade", False)) - restrict = bool(getattr(expr, "restrict", False)) + # CASCADE / RESTRICT are declared by the SQL Server DROP TYPE node; + # the generic statement carries neither, so the dispatch is on the + # declared class and the generic node never reaches the refusals. + cascade = False + restrict = False + if isinstance(expr, SQLServerDropTypeExpression): + cascade = expr.cascade + restrict = expr.restrict if cascade and restrict: raise ValueError("CASCADE and RESTRICT are mutually exclusive") if cascade: @@ -728,14 +702,12 @@ def format_type_definition( if not self.supports_type_definition(type(expr)): raise UnsupportedFeatureError( self.name, - f"TYPE definition {getattr(expr, 'definition_kind', type(expr).__name__)}", + f"TYPE definition {expr.definition_kind}", ) 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") @@ -752,7 +724,7 @@ def format_type_definition( return self._format_table_type_definition(expr) raise UnsupportedFeatureError( self.name, - f"TYPE definition {getattr(expr, 'definition_kind', type(expr).__name__)}", + f"TYPE definition {expr.definition_kind}", ) def format_type_alter_action( @@ -769,29 +741,29 @@ def format_type_alter_action( raise TypeError("expr must be a TypeAlterAction instance") raise UnsupportedFeatureError( self.name, - f"ALTER TYPE action {getattr(expr, 'action_kind', type(expr).__name__)}", + f"ALTER TYPE action {expr.action_kind}", ) def format_rename_type_statement( self, - expr: SQLServerRenameTypeExpression, + expr: SQLServerRenameTypeStatement, ) -> Tuple[str, tuple]: """Format a TYPE rename as ``sp_rename``. ``expr.type_name`` and ``expr.schema_name`` are *not* stale attributes. They were listed as such because a reader compared this formatter with the ``Type`` object ``CREATE TYPE`` takes, and found no field of that - name. Both belong to :class:`SQLServerRenameTypeExpression`: ``sp_rename`` + name. Both belong to :class:`SQLServerRenameTypeStatement`: ``sp_rename`` names its target as a string literal, so this statement never took the object. The name is turned into a :class:`Type` and rendered through the shared entry point by :meth:`_type_object_name` precisely so that the literal is spelled the way every other name is. Raises: - TypeError: ``expr`` is not a SQLServerRenameTypeExpression. + TypeError: ``expr`` is not a SQLServerRenameTypeStatement. """ - if not isinstance(expr, SQLServerRenameTypeExpression): - raise TypeError("expr must be a SQLServerRenameTypeExpression") + if not isinstance(expr, SQLServerRenameTypeStatement): + raise TypeError("expr must be a SQLServerRenameTypeStatement") if not self.supports_rename_type(): raise UnsupportedFeatureError( self.name, 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 e12b54a..9f753fa 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_view.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_view.py @@ -9,9 +9,9 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements import ( - CreateViewExpression, - DropViewExpression, - CreateMaterializedViewExpression, + CreateViewStatement, + DropViewStatement, + CreateMaterializedViewStatement, ) @@ -20,7 +20,7 @@ class SQLServerViewMixin: def supports_indexed_view(self) -> bool: """SQL Server supports indexed views (similar to materialized views).""" - return self.version >= SQL_SERVER_2005 + return self.version_at_least(SQL_SERVER_2005) def supports_create_or_replace_view(self) -> bool: """SQL Server does not support CREATE OR REPLACE VIEW (uses CREATE OR ALTER).""" @@ -32,7 +32,29 @@ def supports_if_not_exists_view(self) -> bool: def supports_if_exists_view(self) -> bool: """SQL Server supports DROP VIEW IF EXISTS (2016+).""" - return self.version >= SQL_SERVER_2016 + return self.version_at_least(SQL_SERVER_2016) + + + def supports_create_view_bind_parameters(self) -> bool: + """SQL Server refuses bind parameters in a view body. + + Measured on 2019 / 2022 / 2025: a view whose definition binds is + rejected -- the placeholder is not a T-SQL variable, so the parser + stops at it -- while the same view with an inlined literal is + accepted. + """ + return False + def supports_create_table_as(self) -> bool: + """SQL Server has no ``CREATE TABLE ... AS`` statement. + + Measured on 2019 / 2022 / 2025 (``{SQL Server}`` driver): the portable + spelling is a syntax error near the keyword ``SELECT`` -- + ``CREATE TABLE t AS SELECT ...`` is rejected whole. SQL Server's own + population spelling is ``SELECT ... INTO
``, which is a + different statement shape, so the portable one is refused by name + rather than emitted in a form the server rejects. + """ + return False def supports_with_data_clause(self) -> bool: """SQL Server has no ``WITH [NO] DATA`` population clause. @@ -51,7 +73,7 @@ def supports_view_check_option(self) -> bool: return True def format_create_view_statement( - self, expr: "CreateViewExpression" + self, expr: "CreateViewStatement" ) -> Tuple[str, tuple]: """Format CREATE VIEW statement for SQL Server. @@ -66,7 +88,7 @@ def format_create_view_statement( """ if not isinstance(expr.view, View): raise TypeError( - f"CreateViewExpression.view must be a View, " + f"CreateViewStatement.view must be a View, " f"got {type(expr.view).__name__}" ) parts = ["CREATE OR ALTER VIEW" if expr.replace else "CREATE VIEW"] @@ -78,12 +100,14 @@ def format_create_view_statement( parts.append(f"({cols})") query_sql, query_params = expr.query.to_sql() + if query_params and not self.supports_create_view_bind_parameters(): + self.refuse_ddl_bind_parameters("CREATE VIEW with bound parameters") as_sql = f"AS {query_sql}" - if expr.options and getattr(expr.options, "schemabinding", False): + if expr.options.schemabinding: as_sql = f"WITH SCHEMABINDING {as_sql}" parts.append(as_sql) - if expr.options and hasattr(expr.options, 'check_option') and expr.options.check_option: + if expr.options.check_option: if not self.supports_view_check_option(): from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError raise UnsupportedFeatureError( @@ -95,7 +119,7 @@ def format_create_view_statement( return " ".join(parts), query_params def format_drop_view_statement( - self, expr: "DropViewExpression" + self, expr: "DropViewStatement" ) -> Tuple[str, tuple]: """Format DROP VIEW statement for SQL Server. @@ -115,7 +139,7 @@ def format_drop_view_statement( """ if not isinstance(expr.view, View): raise TypeError( - f"DropViewExpression.view must be a View, " + f"DropViewStatement.view must be a View, " f"got {type(expr.view).__name__}" ) if expr.cascade and not self.supports_cascade_view(): @@ -138,7 +162,7 @@ def format_drop_view_statement( return " ".join(parts), () def format_create_materialized_view_statement( - self, expr: "CreateMaterializedViewExpression" + self, expr: "CreateMaterializedViewStatement" ) -> Tuple[str, tuple]: """Format CREATE MATERIALIZED VIEW - not supported.""" raise UnsupportedFeatureError(self.name, "CREATE MATERIALIZED VIEW", _SUGGESTION_MATERIALIZED_VIEW) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dml.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dml.py index f1dff3b..28d510b 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dml.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dml.py @@ -5,16 +5,16 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements import ( - InsertExpression, - UpdateExpression, - DeleteExpression, + InsertStatement, + UpdateStatement, + DeleteStatement, ) class SQLServerDMLMixin: """SQL Server INSERT / UPDATE / DELETE statement formatting.""" - def format_insert_statement(self, expr: "InsertExpression") -> Tuple[str, tuple]: + def format_insert_statement(self, expr: "InsertStatement") -> Tuple[str, tuple]: """ Format INSERT statement with SQL Server-specific options. @@ -30,7 +30,7 @@ def format_insert_statement(self, expr: "InsertExpression") -> Tuple[str, tuple] """ if not isinstance(expr.into, Table): raise TypeError( - f"InsertExpression.into must be a Table, " + f"InsertStatement.into must be a Table, " f"got {type(expr.into).__name__}" ) if self.strict_validation: @@ -62,17 +62,14 @@ def format_insert_statement(self, expr: "InsertExpression") -> Tuple[str, tuple] if isinstance(expr.source, DefaultValuesSource): parts.append("DEFAULT VALUES") elif isinstance(expr.source, ValuesSource): - all_rows_sql = [] - for row in expr.source.values_list: - row_sql = [] - row_params = [] - for val in row: - s, p = val.to_sql() - row_sql.append(s) - row_params.extend(p) - all_rows_sql.append(f"({', '.join(row_sql)})") - all_params.extend(row_params) - parts.append("VALUES " + ", ".join(all_rows_sql)) + # The core owns the per-cell decision an INSERT's VALUES list + # turns on: an expression renders through its own to_sql(), a + # bare host value becomes a bind parameter directly (core + # 8e2e4d82). Re-implementing the loop here is what used to make + # a bare scalar cell fail instead of bind. + values_sql, values_params = self.format_insert_values_source(expr.source) + parts.append(values_sql) + all_params.extend(values_params) elif isinstance(expr.source, SelectSource): s_sql, s_params = expr.source.select_query.to_sql() parts.append(s_sql) @@ -82,7 +79,7 @@ def format_insert_statement(self, expr: "InsertExpression") -> Tuple[str, tuple] return sql, tuple(all_params) - def format_update_statement(self, expr: "UpdateExpression") -> Tuple[str, tuple]: + def format_update_statement(self, expr: "UpdateStatement") -> Tuple[str, tuple]: """Format UPDATE statement with OUTPUT clause support. Raises: @@ -91,7 +88,7 @@ def format_update_statement(self, expr: "UpdateExpression") -> Tuple[str, tuple] """ if not isinstance(expr.table, Table): raise TypeError( - f"UpdateExpression.table must be a Table, " + f"UpdateStatement.table must be a Table, " f"got {type(expr.table).__name__}" ) if self.strict_validation: @@ -124,7 +121,7 @@ def format_update_statement(self, expr: "UpdateExpression") -> Tuple[str, tuple] return " ".join(parts), tuple(all_params) - def format_delete_statement(self, expr: "DeleteExpression") -> Tuple[str, tuple]: + def format_delete_statement(self, expr: "DeleteStatement") -> Tuple[str, tuple]: """Format DELETE statement with OUTPUT clause support. Raises: @@ -135,7 +132,7 @@ def format_delete_statement(self, expr: "DeleteExpression") -> Tuple[str, tuple] for _table in expr.tables or (): if not isinstance(_table, Table): raise TypeError( - f"DeleteExpression.tables must hold Table instances, " + f"DeleteStatement.tables must hold Table instances, " f"got {type(_table).__name__}" ) if self.strict_validation: @@ -143,7 +140,7 @@ def format_delete_statement(self, expr: "DeleteExpression") -> Tuple[str, tuple] all_params: List[Any] = [] - # DeleteExpression has .tables (list), not .table + # DeleteStatement has .tables (list), not .table if expr.tables: table_refs = [] for tbl in expr.tables: diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dql.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dql.py index 3f3552f..4ee797f 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dql.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/dql.py @@ -6,7 +6,7 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.query_parts import LimitOffsetClause - from rhosocial.activerecord.backend.expression.statements import QueryExpression + from rhosocial.activerecord.backend.expression.statements import QueryStatement class SQLServerDQLMixin: @@ -53,7 +53,7 @@ def format_limit_offset( def format_limit_offset_clause(self, clause: "LimitOffsetClause") -> Tuple[str, tuple]: """Format LIMIT/OFFSET clause for SQL Server using OFFSET FETCH syntax.""" - if getattr(clause, "with_ties", False): + if clause.with_ties: raise UnsupportedFeatureError( self.name, "FETCH NEXT ... ROWS WITH TIES", "SQL Server supports WITH TIES only with TOP, not OFFSET FETCH." @@ -79,7 +79,7 @@ def format_limit_offset_clause(self, clause: "LimitOffsetClause") -> Tuple[str, return " ".join(parts), () - def format_query_statement(self, expr: "QueryExpression") -> Tuple[str, tuple]: + def format_query_statement(self, expr: "QueryStatement") -> Tuple[str, tuple]: sql, params = super().format_query_statement(expr) if expr.limit_offset is not None and expr.order_by is None: lo_sql = expr.limit_offset.to_sql()[0] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/explain.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/explain.py index ef93e22..042570d 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/explain.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/explain.py @@ -2,7 +2,7 @@ from typing import Tuple, TYPE_CHECKING if TYPE_CHECKING: - from rhosocial.activerecord.backend.expression.statements import ExplainExpression + from rhosocial.activerecord.backend.expression.statements import ExplainStatement class SQLServerExplainMixin: @@ -16,7 +16,7 @@ def supports_explain_format(self, format_type: str) -> bool: """Check if specific EXPLAIN format is supported.""" return format_type.upper() in ["XML", "TEXT"] - def format_explain_statement(self, explain_expr: "ExplainExpression") -> Tuple[str, tuple]: + def format_explain_statement(self, explain_expr: "ExplainStatement") -> Tuple[str, tuple]: """ Format EXPLAIN for SQL Server. diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/function.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/function.py index d5a799b..3f52e14 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/function.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/function.py @@ -41,10 +41,15 @@ def _is_sqlserver_function_supported(self, func_name: str) -> bool: min_version = version_range[0] max_version = version_range[1] - if min_version is not None and self.version < min_version: + version = self.version_or_none + if version is None: + # A capability nobody has confirmed is not claimed. return False - if max_version is not None and self.version > max_version: + if min_version is not None and version < min_version: + return False + + if max_version is not None and version > max_version: return False return True diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/graph.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/graph.py index e6ef4e0..39f6a2a 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/graph.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/graph.py @@ -39,33 +39,33 @@ class SQLServerGraphMixin: def supports_sql_graph(self) -> bool: """SQL Graph (node/edge tables + MATCH predicate) requires 2017+.""" - return self.version >= SQL_SERVER_2017 + return self.version_at_least(SQL_SERVER_2017) def supports_graph_table_kind(self) -> bool: """``CREATE TABLE ... AS NODE | AS EDGE`` requires SQL Server 2017+.""" - return self.version >= SQL_SERVER_2017 + return self.version_at_least(SQL_SERVER_2017) def supports_graph_pseudo_columns(self) -> bool: """Graph pseudo-columns (``$node_id`` etc.) require SQL Server 2017+.""" - return self.version >= SQL_SERVER_2017 + return self.version_at_least(SQL_SERVER_2017) def supports_shortest_path(self) -> bool: """``SHORTEST_PATH`` requires SQL Server 2019+.""" - return self.version >= SQL_SERVER_2019 + return self.version_at_least(SQL_SERVER_2019) def supports_graph_path_aggregates(self) -> bool: """Path aggregates require SQL Server 2019+.""" - return self.version >= SQL_SERVER_2019 + return self.version_at_least(SQL_SERVER_2019) def supports_edge_constraints(self) -> bool: """``CONNECTION`` edge constraints require SQL Server 2019+.""" - return self.version >= SQL_SERVER_2019 + return self.version_at_least(SQL_SERVER_2019) # endregion # region DDL formatting - def format_sqlserver_graph_table_kind( + def format_as_graph_table_expression( self, expr: "SQLServerAsGraphTableExpression" ) -> Tuple[str, tuple]: """Format the ``AS NODE`` / ``AS EDGE`` table-kind fragment.""" @@ -176,7 +176,7 @@ def format_sqlserver_match_predicate( params.extend(pattern_params) return f"MATCH({separator.join(parts)})", tuple(params) - def format_sqlserver_shortest_path( + def format_shortest_path_expression( self, expr: "SQLServerShortestPathExpression" ) -> Tuple[str, tuple]: """Format ``SHORTEST_PATH(())``.""" diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/index.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/index.py index 5454907..80f24e4 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/index.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/index.py @@ -7,7 +7,7 @@ class SQLServerIndexMixin: def supports_index_if_exists(self) -> bool: """SQL Server supports DROP INDEX IF EXISTS (2016+).""" - return self.version >= SQL_SERVER_2016 + return self.version_at_least(SQL_SERVER_2016) def supports_partial_index(self) -> bool: """SQL Server supports filtered indexes (WHERE clause).""" diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/introspection.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/introspection.py new file mode 100644 index 0000000..ae357b5 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/introspection.py @@ -0,0 +1,1006 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/introspection.py +"""Which catalogue questions SQL Server can answer, and how it writes them. + +This backend introspects fully -- catalog views, ``sys.tables``/``sys.columns``/ +``sys.indexes``, ``OBJECT_DEFINITION`` for routine bodies, and the live CLI is +covered by tests against a real server. It nevertheless reported +``supports_introspection() = False`` and every per-kind answer with it, because +it never declared them and inherited core's default of ``False``. + +That default is conservative on purpose: a backend that cannot answer says so, +and a caller gating on the switch is not misled. The cost is the opposite error +-- a backend that *can* answer but says it cannot, which is what happened here. +A caller gating on these switches would have concluded SQL Server has no +introspection at all, while its CLI offered twelve types. + +So the switches are declared, one per family, and each says what SQL Server +actually answers. They are placed in a mixin of their own, because this +backend keeps its capability answers next to the concern they describe, and +because a caller should be able to find "what can I ask" in one place. + +The mixin precedes the core bundle it overrides in ``SQLServerDialect``'s base +list; see the comment there for why the position matters. + +The questions themselves are expression trees, and the text they render is +written here too, in ``format_*_query_statement``: a SQL string is a formatting +artefact and belongs to the dialect (org guide §16.2). That covers the families +core models *and* the ones only SQL Server has -- the primary key, schemas, +extended properties, routines and sequences -- whose statements live in +``expression/introspection.py``. The introspector above this layer constructs a +statement and executes what these return, so no family is spelled anywhere but +in this module. +""" + +from typing import List, Optional, Tuple, TYPE_CHECKING + +from ..query_nodes import SQLServerNodeBuilderMixin + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + from rhosocial.activerecord.backend.introspection.types import IntrospectionScope + from rhosocial.activerecord.backend.expression.introspection import ( + ColumnInfoQueryStatement, + DatabaseInfoQueryStatement, + ForeignKeyQueryStatement, + IndexInfoQueryStatement, + TableListQueryStatement, + TriggerListQueryStatement, + ViewInfoQueryStatement, + ViewListQueryStatement, + ) + from ..expression.introspection import ( + SQLServerExtendedPropertyListQueryStatement, + SQLServerPrimaryKeyQueryStatement, + SQLServerRoutineListQueryStatement, + SQLServerSchemaListQueryStatement, + SQLServerSequenceListQueryStatement, + ) + +__all__ = ["DEFAULT_SCHEMA", "SQLServerIntrospectionMixin"] + +#: The schema a name is resolved in when the caller names none. Declared once +#: so the introspector's ``default_schema`` and the statements below cannot +#: drift apart. +DEFAULT_SCHEMA = "dbo" + +#: The ``sys.extended_properties.class_desc`` values SQL Server documents. +#: The property table is shared by objects, columns, schemas, indexes, +#: parameters and so on, and ``major_id``/``minor_id`` mean something +#: different for each; accepting a name outside this set would silently +#: return nothing instead of saying the family is unknown. +_EXTENDED_PROPERTY_CLASSES = frozenset( + { + "DATABASE", + "OBJECT_OR_COLUMN", + "PARAMETER", + "SCHEMA", + "DATABASE_PRINCIPAL", + "ASSEMBLY", + "TYPE", + "INDEX", + "XML_SCHEMA_COLLECTION", + "XML_SCHEMA_NAMESPACE", + "TYPE_SCHEMA", + "XML_INDEX", + "PLAN_GUIDE", + } +) + +#: The extended-property classes whose ``major_id`` is an object id, and which +#: therefore have a schema the ``sys.objects``/``sys.schemas`` join can narrow. +#: Every other class names an id in its own space, so a schema filter would be +#: a silent "no rows" rather than a contradiction -- refused before the query +#: is built, below. +_OBJECT_SCOPED_PROPERTY_CLASSES = frozenset({"OBJECT_OR_COLUMN", "INDEX"}) + +#: ``sys.objects.type`` codes the table question admits, and the spellings a +#: caller can name a kind with. ``U`` is a user table, ``V`` a view and ``S`` +#: a system table; ``INFORMATION_SCHEMA.TABLES`` would have said "BASE TABLE" +#: or "VIEW" and dropped the ``S`` rows altogether, which is why the table +#: list reads ``sys.objects`` instead. See ``format_table_list_query_statement``. +_TABLE_KIND_CODES = { + "TABLE": ("U",), + "BASE TABLE": ("U",), + "VIEW": ("V",), + "SYSTEM TABLE": ("S",), + "SYSTEM_TABLE": ("S",), +} + +#: ``sys.sequences`` stores these as ``sql_variant``. The ODBC driver refuses +#: to fetch that type (``HY106``), so every one of them is cast to text in the +#: statement. Keeping the list in one place is what stops a later column from +#: being added to the projection without its cast. +_SEQUENCE_NUMERIC_COLUMNS = ( + "start_value", + "increment", + "minimum_value", + "maximum_value", + "current_value", +) + + +class SQLServerIntrospectionMixin(SQLServerNodeBuilderMixin): + """SQL Server's answers to the shared catalogue capability questions. + + Each ``supports_*`` says which catalogue view answers the family; each + ``format_*_query_statement`` renders the question from the nodes the + statement carries. + """ + + @property + def _node_dialect(self) -> "SQLDialectBase": + """This object *is* the dialect, so nodes bind straight to it.""" + return self + + def supports_introspection(self) -> bool: + """SQL Server introspects through its catalog views.""" + return True + + def supports_database_info(self) -> bool: + """``sys.databases`` and ``@@VERSION`` answer for the instance.""" + return True + + def supports_table_introspection(self) -> bool: + """``sys.tables`` joined to ``sys.schemas``.""" + return True + + def supports_column_introspection(self) -> bool: + """``sys.columns``, which also carries identity and nullable.""" + return True + + def supports_index_introspection(self) -> bool: + """``sys.indexes`` with ``sys.index_columns`` for the ordering.""" + return True + + def supports_foreign_key_introspection(self) -> bool: + """``sys.foreign_keys`` and ``sys.foreign_key_columns``.""" + return True + + def supports_view_introspection(self) -> bool: + """``sys.views``, with the definition from ``OBJECT_DEFINITION``.""" + return True + + def supports_trigger_introspection(self) -> bool: + """``sys.triggers``, which also distinguishes INSTEAD OF.""" + return True + + def supports_extended_property_introspection(self) -> bool: + """``sys.extended_properties``, where ``MS_Description`` lives. + + Declared as its own family rather than folded into + ``supports_table_introspection``: "can this server list and describe + tables" and "can it read the properties hanging off their objects" are + two questions, and a caller gating on the first would be told nothing + about the second. + """ + return True + + def get_supported_introspection_scopes(self) -> List["IntrospectionScope"]: + """The catalogue families this dialect answers, as core names them. + + Core's default is the empty list -- the right answer for a backend that + cannot introspect, and the same wrong answer the switches above were + declared to stop giving here: a caller asking "what can I introspect" + was told "nothing" by a server that answers eleven families. + ``SEQUENCE`` is included because ``IntrospectionScope`` has it and + ``sys.sequences`` answers it. + """ + from rhosocial.activerecord.backend.introspection.types import ( + IntrospectionScope, + ) + + return [ + IntrospectionScope.DATABASE, + IntrospectionScope.TABLE, + IntrospectionScope.COLUMN, + IntrospectionScope.INDEX, + IntrospectionScope.FOREIGN_KEY, + IntrospectionScope.VIEW, + IntrospectionScope.TRIGGER, + IntrospectionScope.SEQUENCE, + ] + + # ========== Query Formatting ========== + # + # The parameter positions are the ones the concatenated form used: a + # schema or table name the caller supplied stays bound, and a value that + # is part of the question (``'BASE TABLE'``, ``'PRIMARY KEY'``, + # ``'IsIdentity'``) stays an inline literal, so every caller keeps the + # parameter tuple it already had. + + def _cast_server_property(self, name: str, alias: Optional[str] = None): + """``CAST(SERVERPROPERTY('') AS NVARCHAR(128))``, optionally aliased. + + The property name is a literal of the statement, not data, so it stays + inline: the request carries no parameters. + """ + from rhosocial.activerecord.backend.expression import FunctionCall + from ..expression.types import SQLServerNVarCharType + + node = FunctionCall( + self._node_dialect, "SERVERPROPERTY", self._lit(name, inline=True) + ).cast(SQLServerNVarCharType(self._node_dialect, 128)) + return node.as_(alias) if alias else node + + def format_database_info_query_statement( + self, expr: "DatabaseInfoQueryStatement" + ) -> Tuple[str, tuple]: + """Format the database information query. + + Args: + expr: The statement; SQL Server identifies itself, so no + parameter of it is consulted. + + Returns: + Tuple of (SQL string, parameters tuple). + """ + from rhosocial.activerecord.backend.expression import FunctionCall + + # No FROM: this query reads no table, it asks the engine a question. + return self._select( + [ + FunctionCall(self._node_dialect, "DB_NAME", alias="db_name"), + self._cast_server_property("ProductVersion", "version"), + self._cast_server_property("Collation", "collation"), + self._cast_server_property("Edition", "edition"), + ] + ) + + def format_table_list_query_statement( + self, expr: "TableListQueryStatement" + ) -> Tuple[str, tuple]: + """Format the table list query. + + ``sys.objects`` is read rather than ``INFORMATION_SCHEMA.TABLES`` + because the information-schema view folds the object kinds into two + two-word spellings and omits every ``is_ms_shipped`` row: the one row + that says "this is a system table" never reached the parser, so a + caller asking for system tables got a shorter list with no word about + it. The catalogue view carries ``sys.objects.type`` (``U`` user + table, ``V`` view, ``S`` system table) and ``is_ms_shipped``, which + are the two facts the question is about. + + ``include_system`` is therefore honoured here: adopting ``S`` and + dropping the ``is_ms_shipped`` filter is what makes the CLI's + ``--include-system`` flag change the answer instead of being ignored. + An explicit ``table_type`` narrows the admitted codes and, when it + names system tables, implies ``include_system`` for that query. + + ``MS_Description`` is joined in as ``table_comment``: SQL Server has no + ``COMMENT`` clause, and ``sys.extended_properties`` is the only place a + table's comment lives. Without the join ``TableInfo.comment`` was + permanently ``None`` -- a declared field no backend could fill. The + join is a ``LEFT JOIN`` on the object-level property alone + (``minor_id = 0``), so a table without a comment still row-appears and a + column-level description cannot be mistaken for the table's. + """ + params = expr.get_params() + target = params.get("schema") or DEFAULT_SCHEMA + include_views = params.get("include_views", True) + include_system = params.get("include_system", False) + table_type = params.get("table_type") + + if table_type: + codes = _TABLE_KIND_CODES.get( + str(table_type).upper(), (str(table_type),) + ) + else: + codes = ("U", "V") if include_views else ("U",) + if include_system: + codes = codes + ("S",) + + conditions = [self._eq(self._col("name", "s"), self._lit(target))] + conditions.append(self._in(self._col("type", "o"), codes)) + if not include_system and "S" not in codes: + # A system table is an ``is_ms_shipped`` object; without this the + # flag would still be ignored for the kinds a caller named. + conditions.append( + self._eq(self._col("is_ms_shipped", "o"), self._lit(0, inline=True)) + ) + + joined = self._join( + self._join( + self._relation("objects", "sys", alias="o"), + self._relation("schemas", "sys", alias="s"), + condition=self._eq( + self._col("schema_id", "o"), self._col("schema_id", "s") + ), + ), + self._relation("extended_properties", "sys", alias="ep"), + join_type="LEFT JOIN", + condition=self._and( + self._eq(self._col("major_id", "ep"), self._col("object_id", "o")), + self._eq(self._col("minor_id", "ep"), self._lit(0, inline=True)), + self._eq(self._col("class", "ep"), self._lit(1, inline=True)), + self._eq( + self._col("name", "ep"), + self._lit("MS_Description", inline=True), + ), + ), + ) + return self._select( + [ + self._col("name", "o", alias="TABLE_NAME"), + self._col("type", "o", alias="TABLE_TYPE"), + self._cast(self._col("value", "ep"), "NVARCHAR(4000)").as_( + "table_comment" + ), + ], + from_=joined, + where=self._and(*conditions), + order_by=[self._col("name", "o")], + ) + + def format_column_info_query_statement( + self, expr: "ColumnInfoQueryStatement" + ) -> Tuple[str, tuple]: + """Format the column information query. + + ``DATETIME_PRECISION`` is projected because it is the only place the + fractional-seconds precision of ``time``/``datetime2``/ + ``datetimeoffset`` lives. ``include_hidden`` is part of the shared + question; SQL Server's catalog reports no hidden columns. + + ``MS_Description`` is joined in as ``column_comment``, the second half + of the comment story the table list tells: the property is keyed by the + object id the column belongs to and the column's own id, neither of + which ``INFORMATION_SCHEMA.COLUMNS`` carries, so ``sys.columns`` is + joined to reach them. ``OBJECT_ID`` is evaluated on the qualified name + this statement already builds, and the join is a ``LEFT JOIN`` so a + column without a description keeps its row. + """ + from rhosocial.activerecord.backend.expression import FunctionCall, JoinClause + from rhosocial.activerecord.backend.expression.operators import ( + ArithmeticOperation, + ArithmeticOperator, + ) + + params = expr.get_params() + table_name = params.get("table_name") + target = params.get("schema") or DEFAULT_SCHEMA + + # OBJECT_ID takes the qualified object name as one string. SQL Server + # spells string concatenation with ``+`` (``||`` only from 2025), so + # this is the arithmetic ``add`` node, not the standard concatenation. + qualified_name = ArithmeticOperation( + self._node_dialect, + ArithmeticOperator.ADD, + ArithmeticOperation( + self._node_dialect, + ArithmeticOperator.ADD, + self._col("TABLE_SCHEMA", "c"), + self._lit(".", inline=True), + ), + self._col("TABLE_NAME", "c"), + ) + object_id = FunctionCall(self._node_dialect, "OBJECT_ID", qualified_name) + is_identity = FunctionCall( + self._node_dialect, + "COLUMNPROPERTY", + object_id, + self._col("COLUMN_NAME", "c"), + self._lit("IsIdentity", inline=True), + alias="IS_IDENTITY", + ) + joined = JoinClause( + self._node_dialect, + JoinClause( + self._node_dialect, + self._relation("COLUMNS", "INFORMATION_SCHEMA", alias="c"), + self._relation("columns", "sys", alias="sc"), + join_type="LEFT JOIN", + condition=self._and( + self._eq(self._col("object_id", "sc"), object_id), + self._eq(self._col("name", "sc"), self._col("COLUMN_NAME", "c")), + ), + ), + self._relation("extended_properties", "sys", alias="epc"), + join_type="LEFT JOIN", + condition=self._and( + self._eq(self._col("class", "epc"), self._lit(1, inline=True)), + self._eq(self._col("major_id", "epc"), self._col("object_id", "sc")), + self._eq(self._col("minor_id", "epc"), self._col("column_id", "sc")), + self._eq( + self._col("name", "epc"), + self._lit("MS_Description", inline=True), + ), + ), + ) + return self._select( + [ + self._col("COLUMN_NAME", "c"), + self._col("DATA_TYPE", "c"), + self._col("IS_NULLABLE", "c"), + self._col("COLUMN_DEFAULT", "c"), + self._col("CHARACTER_MAXIMUM_LENGTH", "c"), + self._col("NUMERIC_PRECISION", "c"), + self._col("NUMERIC_SCALE", "c"), + self._col("DATETIME_PRECISION", "c"), + self._col("ORDINAL_POSITION", "c"), + self._col("COLLATION_NAME", "c"), + is_identity, + self._cast(self._col("value", "epc"), "NVARCHAR(4000)").as_( + "column_comment" + ), + ], + from_=joined, + where=self._and( + self._eq(self._col("TABLE_SCHEMA", "c"), self._lit(target)), + self._eq(self._col("TABLE_NAME", "c"), self._lit(table_name)), + ), + order_by=[self._col("ORDINAL_POSITION", "c")], + ) + + def format_index_info_query_statement( + self, expr: "IndexInfoQueryStatement" + ) -> Tuple[str, tuple]: + """Format the index information query.""" + from rhosocial.activerecord.backend.expression import IsNullPredicate, JoinClause + + params = expr.get_params() + table_name = params.get("table_name") + target = params.get("schema") or DEFAULT_SCHEMA + + joins = JoinClause( + self._node_dialect, + JoinClause( + self._node_dialect, + JoinClause( + self._node_dialect, + JoinClause( + self._node_dialect, + self._relation("indexes", "sys", alias="i"), + self._relation("tables", "sys", alias="t"), + join_type="JOIN", + condition=self._eq( + self._col("object_id", "i"), self._col("object_id", "t") + ), + ), + self._relation("schemas", "sys", alias="s"), + join_type="JOIN", + condition=self._eq( + self._col("schema_id", "t"), self._col("schema_id", "s") + ), + ), + self._relation("index_columns", "sys", alias="ic"), + join_type="JOIN", + condition=self._and( + self._eq(self._col("object_id", "i"), self._col("object_id", "ic")), + self._eq(self._col("index_id", "i"), self._col("index_id", "ic")), + ), + ), + self._relation("columns", "sys", alias="c"), + join_type="JOIN", + condition=self._and( + self._eq(self._col("object_id", "ic"), self._col("object_id", "c")), + self._eq(self._col("column_id", "ic"), self._col("column_id", "c")), + ), + ) + return self._select( + [ + self._col("name", "i", alias="index_name"), + self._col("is_unique", "i"), + self._col("is_primary_key", "i"), + self._col("type_desc", "i", alias="index_type"), + self._col("name", "c", alias="column_name"), + self._col("key_ordinal", "ic", alias="column_position"), + self._col("is_descending_key", "ic"), + ], + from_=joins, + where=self._and( + self._eq(self._col("name", "s"), self._lit(target)), + self._eq(self._col("name", "t"), self._lit(table_name)), + IsNullPredicate(self._node_dialect, self._col("name", "i"), is_not=True), + ), + order_by=[self._col("name", "i"), self._col("key_ordinal", "ic")], + ) + + def format_foreign_key_query_statement( + self, expr: "ForeignKeyQueryStatement" + ) -> Tuple[str, tuple]: + """Format the foreign key information query.""" + from rhosocial.activerecord.backend.expression import FunctionCall, JoinClause + + params = expr.get_params() + table_name = params.get("table_name") + target = params.get("schema") or DEFAULT_SCHEMA + + joins = JoinClause( + self._node_dialect, + JoinClause( + self._node_dialect, + JoinClause( + self._node_dialect, + self._relation("foreign_keys", "sys", alias="fk"), + self._relation("foreign_key_columns", "sys", alias="fc"), + join_type="JOIN", + condition=self._eq( + self._col("object_id", "fk"), + self._col("constraint_object_id", "fc"), + ), + ), + self._relation("tables", "sys", alias="t"), + join_type="JOIN", + condition=self._eq( + self._col("parent_object_id", "fk"), self._col("object_id", "t") + ), + ), + self._relation("schemas", "sys", alias="s"), + join_type="JOIN", + condition=self._eq( + self._col("schema_id", "t"), self._col("schema_id", "s") + ), + ) + return self._select( + [ + self._col("name", "fk", alias="fk_name"), + FunctionCall( + self._node_dialect, + "COL_NAME", + self._col("parent_object_id", "fc"), + self._col("parent_column_id", "fc"), + alias="col_name", + ), + FunctionCall( + self._node_dialect, + "OBJECT_NAME", + self._col("referenced_object_id", "fk"), + alias="ref_table", + ), + FunctionCall( + self._node_dialect, + "COL_NAME", + self._col("referenced_object_id", "fc"), + self._col("referenced_column_id", "fc"), + alias="ref_col", + ), + self._col("delete_referential_action_desc", "fk", alias="on_delete"), + self._col("update_referential_action_desc", "fk", alias="on_update"), + self._col("constraint_column_id", "fc", alias="col_position"), + ], + from_=joins, + where=self._and( + self._eq(self._col("name", "s"), self._lit(target)), + self._eq(self._col("name", "t"), self._lit(table_name)), + ), + order_by=[ + self._col("name", "fk"), + self._col("constraint_column_id", "fc"), + ], + ) + + def format_view_list_query_statement( + self, expr: "ViewListQueryStatement" + ) -> Tuple[str, tuple]: + """Format the view list query. + + ``include_system`` is not consulted for the same reason as in the table + list: ``INFORMATION_SCHEMA.VIEWS`` holds no server-owned row here. + + The definition is read here, not only in the single-view query: the + list used to project ``TABLE_NAME`` alone, so every row of ``introspect + views`` carried ``definition = null`` although the single-view question + right below had always projected it. Both read ``VIEW_DEFINITION`` from + the same ``INFORMATION_SCHEMA.VIEWS`` row, so the list and the + single-view answer cannot disagree. + + ``VIEW_DEFINITION`` is ``NULL`` for a view whose text the caller may not + read -- unlike ``OBJECT_DEFINITION()``, which returns the text to a + caller holding ``VIEW DEFINITION``. Keeping both questions on the + information-schema view is deliberate: one source, one visibility rule. + """ + params = expr.get_params() + target = params.get("schema") or DEFAULT_SCHEMA + return self._select( + [ + self._col("TABLE_NAME", alias="view_name"), + self._col("VIEW_DEFINITION", alias="definition"), + self._col("IS_UPDATABLE"), + ], + from_=self._relation("VIEWS", "INFORMATION_SCHEMA"), + where=self._eq(self._col("TABLE_SCHEMA"), self._lit(target)), + order_by=[self._col("TABLE_NAME")], + ) + + def format_view_info_query_statement( + self, expr: "ViewInfoQueryStatement" + ) -> Tuple[str, tuple]: + """Format the single-view query. + + ``include_columns`` is part of the shared question; the columns of a + view are read through the column statement, so this one always returns + the view's own row. + """ + params = expr.get_params() + view_name = params.get("view_name") + target = params.get("schema") or DEFAULT_SCHEMA + return self._select( + [ + self._col("TABLE_NAME", alias="view_name"), + self._col("VIEW_DEFINITION", alias="definition"), + self._col("IS_UPDATABLE"), + ], + from_=self._relation("VIEWS", "INFORMATION_SCHEMA"), + where=self._and( + self._eq(self._col("TABLE_SCHEMA"), self._lit(target)), + self._eq(self._col("TABLE_NAME"), self._lit(view_name)), + ), + ) + + def format_trigger_list_query_statement( + self, expr: "TriggerListQueryStatement" + ) -> Tuple[str, tuple]: + """Format the trigger list query, optionally for one table. + + The firing events are not a column of ``sys.triggers``: they live one + table over, in ``sys.trigger_events``, one row per event + (``INSERT``/``UPDATE``/``DELETE``). Joining it is what makes ``events`` + answerable at all -- the parser used to write ``[]`` for every trigger + because nothing selected the events. The join is a ``LEFT JOIN``: a + trigger row with no event of its own is still a trigger, and dropping it + would trade an empty list for a missing trigger. + + One trigger now answers with one row per event, so the rows of a + trigger are contiguous (the list is ordered by trigger name, then by + event) and the parser folds them back together. + """ + from rhosocial.activerecord.backend.expression import FunctionCall, JoinClause + + params = expr.get_params() + table_name = params.get("table_name") + target = params.get("schema") or DEFAULT_SCHEMA + + joins = JoinClause( + self._node_dialect, + JoinClause( + self._node_dialect, + JoinClause( + self._node_dialect, + self._relation("triggers", "sys", alias="tr"), + self._relation("trigger_events", "sys", alias="te"), + join_type="LEFT JOIN", + condition=self._eq( + self._col("object_id", "te"), self._col("object_id", "tr") + ), + ), + self._relation("tables", "sys", alias="t"), + join_type="JOIN", + condition=self._eq( + self._col("parent_id", "tr"), self._col("object_id", "t") + ), + ), + self._relation("schemas", "sys", alias="s"), + join_type="JOIN", + condition=self._eq( + self._col("schema_id", "t"), self._col("schema_id", "s") + ), + ) + conditions = [self._eq(self._col("name", "s"), self._lit(target))] + if table_name: + conditions.append(self._eq(self._col("name", "t"), self._lit(table_name))) + return self._select( + [ + self._col("name", "tr", alias="trigger_name"), + self._col("name", "t", alias="table_name"), + self._col("is_disabled", "tr"), + self._col("is_instead_of_trigger", "tr"), + self._col("type_desc", "te", alias="event"), + FunctionCall( + self._node_dialect, + "OBJECT_DEFINITION", + self._col("object_id", "tr"), + alias="definition", + ), + ], + from_=joins, + where=self._and(*conditions), + order_by=[self._col("name", "tr"), self._col("type_desc", "te")], + ) + + # ------------------------------------------------------------------ # + # SQL Server's own object families + # + # The primary key, schemas, extended properties, routines and sequences + # have no core statement class: the question is this backend's own. Each + # formatter below renders one of those statements from the nodes, and the + # construction lives here because the SQL is the dialect's -- the + # introspector only constructs the statement and executes what comes back. + # ------------------------------------------------------------------ # + + def format_primary_key_query_statement( + self, expr: "SQLServerPrimaryKeyQueryStatement" + ) -> Tuple[str, tuple]: + """Render the primary-key column list of one table. + + The two catalogue views are joined on the constraint name *and* the + schema, so a same-named constraint in another schema cannot contribute + a column to this table's key. + """ + from rhosocial.activerecord.backend.expression import JoinClause + + params = expr.get_params() + table_name = params.get("table_name") + target = params.get("schema") + + join = JoinClause( + self._node_dialect, + self._relation("TABLE_CONSTRAINTS", "INFORMATION_SCHEMA", alias="tc"), + self._relation("CONSTRAINT_COLUMN_USAGE", "INFORMATION_SCHEMA", alias="ccu"), + join_type="JOIN", + condition=self._and( + self._eq( + self._col("CONSTRAINT_NAME", "tc"), + self._col("CONSTRAINT_NAME", "ccu"), + ), + self._eq( + self._col("TABLE_SCHEMA", "tc"), self._col("TABLE_SCHEMA", "ccu") + ), + ), + ) + return self._select( + [self._col("COLUMN_NAME", "ccu")], + from_=join, + where=self._and( + self._eq(self._col("TABLE_SCHEMA", "tc"), self._lit(target)), + self._eq(self._col("TABLE_NAME", "tc"), self._lit(table_name)), + self._eq( + self._col("CONSTRAINT_TYPE", "tc"), + self._lit("PRIMARY KEY", inline=True), + ), + ), + ) + + def format_schema_list_query_statement( + self, expr: "SQLServerSchemaListQueryStatement" + ) -> Tuple[str, tuple]: + """Render ``sys.schemas`` with the owning principal's name. + + ``sys.schemas`` is joined to ``sys.database_principals`` on + ``principal_id`` -- not on ``name`` -- because a schema and its owner + need not share a name. The join is a ``LEFT JOIN``: the catalogue + keeps a schema after its owning principal is dropped, leaving + ``principal_id`` dangling, and that row is still a schema. + """ + joined = self._join( + self._relation("schemas", "sys", alias="s"), + self._relation("database_principals", "sys", alias="p"), + join_type="LEFT JOIN", + condition=self._eq( + self._col("principal_id", "s"), self._col("principal_id", "p") + ), + ) + return self._select( + [ + self._col("schema_id", "s"), + self._col("name", "s", alias="schema_name"), + self._col("name", "p", alias="owner"), + ], + from_=joined, + order_by=[self._col("name", "s")], + ) + + def format_extended_property_list_query_statement( + self, expr: "SQLServerExtendedPropertyListQueryStatement" + ) -> Tuple[str, tuple]: + """Render ``sys.extended_properties`` for one object, optionally one column. + + Two ways to name the object, one query each: by ``major_id`` when the + caller already has the id, or by ``table_name`` when all it has is a + name. The named form joins ``sys.tables`` and ``sys.schemas`` -- the + one place a table's name resolves to an id -- so the caller needs no + first round trip to turn a name into a ``major_id``. + + ``value`` is ``sql_variant`` and the driver cannot fetch it, so it is + cast to ``NVARCHAR(4000)`` in the statement: the property text is what + a caller wants, and truncation beyond 4000 characters is impossible to + avoid without a second round trip. + + The schema filter is the one part that is not a column of + ``sys.extended_properties``: it resolves ``major_id`` through + ``sys.objects`` and ``sys.schemas``. That join only means something + for the object-scoped classes (``OBJECT_OR_COLUMN`` above all), so + asking for a schema while listing, say, ``DATABASE`` properties is a + contradiction rather than an empty answer -- refused before the query + is built. + + A ``table_name`` narrows the question even further: only + ``OBJECT_OR_COLUMN`` properties hang off a table, so any other class + asked by table name is refused rather than answered with no rows. The + named selection can only reach what ``sys.tables`` holds -- base tables. + A view's properties have no row there and are reachable through the + ``major_id`` selection only. + """ + params = expr.get_params() + major_id = params.get("major_id") + table_name = params.get("table_name") + minor_id = params.get("minor_id") + class_name = params.get("class_name") + schema = params.get("schema") + + if class_name not in _EXTENDED_PROPERTY_CLASSES: + raise ValueError( + f"unknown extended-property class {class_name!r}; " + f"expected one of {sorted(_EXTENDED_PROPERTY_CLASSES)}" + ) + if schema is not None and class_name not in _OBJECT_SCOPED_PROPERTY_CLASSES: + raise ValueError( + f"a schema can only narrow object-scoped extended properties; " + f"{class_name!r} has no schema" + ) + + if table_name is not None: + if class_name != "OBJECT_OR_COLUMN": + raise ValueError( + f"a table name only names OBJECT_OR_COLUMN properties; " + f"{class_name!r} keeps its objects in another catalogue" + ) + target = schema or DEFAULT_SCHEMA + joined = self._join( + self._join( + self._relation("extended_properties", "sys", alias="ep"), + self._relation("tables", "sys", alias="t"), + join_type="JOIN", + condition=self._eq( + self._col("major_id", "ep"), self._col("object_id", "t") + ), + ), + self._relation("schemas", "sys", alias="s"), + join_type="JOIN", + condition=self._eq( + self._col("schema_id", "t"), self._col("schema_id", "s") + ), + ) + conditions = [ + self._eq(self._col("class_desc", "ep"), self._lit(class_name)), + self._eq(self._col("name", "t"), self._lit(table_name)), + self._eq(self._col("name", "s"), self._lit(target)), + ] + if minor_id is not None: + conditions.append( + self._eq(self._col("minor_id", "ep"), self._lit(minor_id)) + ) + return self._select( + self._extended_property_projection(), + from_=joined, + where=self._and(*conditions), + order_by=[self._col("minor_id", "ep"), self._col("name", "ep")], + ) + + joined = self._join( + self._relation("extended_properties", "sys", alias="ep"), + self._relation("objects", "sys", alias="o"), + join_type="LEFT JOIN", + condition=self._eq( + self._col("major_id", "ep"), self._col("object_id", "o") + ), + ) + joined = self._join( + joined, + self._relation("schemas", "sys", alias="s"), + join_type="LEFT JOIN", + condition=self._eq( + self._col("schema_id", "o"), self._col("schema_id", "s") + ), + ) + + conditions = [ + self._eq(self._col("major_id", "ep"), self._lit(major_id)), + self._eq(self._col("class_desc", "ep"), self._lit(class_name)), + ] + if minor_id is not None: + conditions.append( + self._eq(self._col("minor_id", "ep"), self._lit(minor_id)) + ) + if schema is not None: + conditions.append(self._eq(self._col("name", "s"), self._lit(schema))) + + return self._select( + self._extended_property_projection(), + from_=joined, + where=self._and(*conditions), + order_by=[self._col("minor_id", "ep"), self._col("name", "ep")], + ) + + def _extended_property_projection(self) -> list: + + """The extended-property columns both selections of that family project.""" + return [ + self._col("class", "ep", alias="class_id"), + self._col("class_desc", "ep", alias="class_name"), + self._col("major_id", "ep", alias="major_id"), + self._col("minor_id", "ep", alias="minor_id"), + self._col("name", "ep", alias="property_name"), + self._cast(self._col("value", "ep"), "NVARCHAR(4000)").as_( + "property_value" + ), + ] + + def format_routine_list_query_statement( + self, expr: "SQLServerRoutineListQueryStatement" + ) -> Tuple[str, tuple]: + """Render the schema-scoped routines of the admitted ``sys.objects.type`` codes. + + One formatter for both families: a procedure is a routine whose + ``sys.objects.type`` is ``P``/``PC`` and a function is one of the + ``FN``/``IF``/``TF``/``FS``/``FT`` codes. What differs is the code + set, and that is the parameter the statement carries. + """ + params = expr.get_params() + type_codes = params.get("type_codes") + target = params.get("schema") or DEFAULT_SCHEMA + + joined = self._join( + self._relation("objects", "sys", alias="o"), + self._relation("schemas", "sys", alias="s"), + join_type="JOIN", + condition=self._eq( + self._col("schema_id", "o"), self._col("schema_id", "s") + ), + ) + return self._select( + [ + self._col("name", "o", alias="routine_name"), + self._col("name", "s", alias="schema_name"), + self._col("type", "o", alias="type_code"), + self._col("type_desc", "o"), + self._col("create_date", "o", alias="created_at"), + self._col("modify_date", "o", alias="modified_at"), + self._col("is_ms_shipped", "o"), + ], + from_=joined, + where=self._and( + self._in(self._col("type", "o"), type_codes), + self._eq(self._col("name", "s"), self._lit(target)), + ), + order_by=[self._col("name", "o")], + ) + + def format_sequence_list_query_statement( + self, expr: "SQLServerSequenceListQueryStatement" + ) -> Tuple[str, tuple]: + """Render ``sys.sequences`` with every ``sql_variant`` value cast to text. + + ``start_value``/``increment``/``minimum_value``/``maximum_value``/ + ``current_value`` are ``sql_variant``. pyodbc raises + ``ODBC SQL type -25 is not yet supported`` on the very first of them, + so a projection that selected them raw could not be fetched at all -- + which is why the cast is in the statement rather than the parser. + """ + params = expr.get_params() + target = params.get("schema") or DEFAULT_SCHEMA + joined = self._join( + self._relation("sequences", "sys", alias="q"), + self._relation("schemas", "sys", alias="s"), + join_type="JOIN", + condition=self._eq( + self._col("schema_id", "q"), self._col("schema_id", "s") + ), + ) + projection = [ + self._col("name", "q", alias="sequence_name"), + self._col("name", "s", alias="schema_name"), + self._fn( + "TYPE_NAME", self._col("user_type_id", "q"), alias="data_type" + ), + ] + for column in _SEQUENCE_NUMERIC_COLUMNS: + projection.append( + self._cast( + self._col(column, "q"), "NVARCHAR(64)" + ).as_(column) + ) + projection.extend( + [ + self._col("is_cycling", "q"), + self._col("is_cached", "q"), + self._cast(self._col("cache_size", "q"), "BIGINT").as_( + "cache_size" + ), + ] + ) + return self._select( + projection, + from_=joined, + where=self._eq(self._col("name", "s"), self._lit(target)), + order_by=[self._col("name", "q")], + ) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/json.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/json.py index c8781f4..3f32306 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/json.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/json.py @@ -12,7 +12,7 @@ class SQLServerJSONMixin: def supports_json_type(self) -> bool: """JSON functions are supported since SQL Server 2016.""" - return self.version >= SQL_SERVER_2016 + return self.version_at_least(SQL_SERVER_2016) def get_json_access_operator(self) -> Optional[str]: """SQL Server doesn't have -> operator, uses JSON_VALUE/JSON_QUERY.""" @@ -27,15 +27,15 @@ def format_json_function_expression(self, expr: "BaseExpression") -> Tuple[str, Args: expr: The JSON path node to format -- a - JSONDocumentExpression for ``->``, a - JSONTextExpression for ``->>``. + JSONDocumentValue for ``->``, a + JSONTextValue for ``->>``. Returns: (SQL string, params tuple). """ col_sql, col_params = expr.column.to_sql() - escaped_path = self._escape_sql_string(expr.path) + escaped_path = self.escape_sql_string(expr.path) sql = f"JSON_VALUE({col_sql}, '{escaped_path}')" params = col_params diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/literal.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/literal.py new file mode 100644 index 0000000..cae8e5b --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/literal.py @@ -0,0 +1,79 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/literal.py +"""SQL Server's tier-2 value literals. + +``SQLDialectBase.format_literal`` is the single entry point for writing a value +as SQL text, and it resolves in three tiers: the portable scalars it knows, the +types a backend spells for itself, and the literal-construction protocol. This +module is SQL Server's second tier. T-SQL differs from the portable spellings on +three types and no more: + +* **bool** becomes ``1`` / ``0``. T-SQL's literal grammar has no ``TRUE`` / + ``FALSE`` keywords -- unquoted ``TRUE`` parses as an identifier, not a + value -- and the type that plays the boolean's role is ``BIT``, whose + constants are numeric. (The strings ``'TRUE'`` / ``'FALSE'`` convert to + BIT, but a literal position wants the numeric spelling.) The data + position is untouched: pyodbc binds ``True`` / ``False`` to BIT natively, + and this branch only ever runs for the text a literal position asks for. +* **bytes** becomes the unquoted hexadecimal constant, ``0x0102`` with + uppercase hex -- the ``constant`` grammar's binary form. Quoting the bytes + as text would store a string that reads back as the wrong type and the + wrong length. +* **UUID** becomes the quoted canonical text: ``UNIQUEIDENTIFIER`` accepts a + string literal and converts it, and there is no cast syntax to add. + +**INTERVAL stays absent on purpose.** SQL Server has no interval type and no +interval literal, so the base implementation's explicit refusal is the right +answer: a position that needs an interval literal cannot be written for this +engine. Leaving the branch out is what makes the absence structural -- a +``timedelta`` reaches the base's ladder and fails loudly naming its type. + +Not yet server-verified: the scenario servers this repository tests against +(``127.0.0.1:11433/11434/11435``, the 2019/2022/2025 entries of +``tests/config/sqlserver_scenarios.yaml``) were unreachable while this module +was written -- each dial measured connection refused after ~31 s -- so the +spellings above rest on the documented T-SQL grammar, and the live round-trip +cases in ``test_sqlserver_typed_values.py`` are waiting for a reachable server +to confirm them. +""" + +import uuid +from typing import Any + + +class SQLServerLiteralValueMixin: + """SQL Server's tier-2 branches of :meth:`format_literal`. + + Stands alone rather than inheriting the base, exactly like the SQLite + mixin it mirrors: the dialect composes this class ahead of + ``SQLDialectBase`` -- a mixin placed after the class it overrides never + wins -- and every value this branch does not claim falls through to the + base implementation, which is where the portable forms and the + literal-construction protocol live. + """ + + def format_literal(self, value: Any) -> str: + """Render *value* as inline SQL text, SQL Server's spelling where it has one. + + Args: + value: The host value to write into the statement. + + Returns: + The escaped literal text, without a bind parameter. + + Raises: + TypeError: The base implementation refuses a type SQL Server has + no literal form for -- ``timedelta`` among them -- and this + is the same refusal. + """ + # bool first: it is an int subclass, so the numeric branch would + # otherwise claim it. + if isinstance(value, bool): + return "1" if value else "0" + if isinstance(value, (bytes, bytearray)): + return f"0x{bytes(value).hex().upper()}" + if isinstance(value, uuid.UUID): + return self.format_literal(str(value)) + return super().format_literal(value) + + +__all__ = ["SQLServerLiteralValueMixin"] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/locking.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/locking.py index 83df763..5d9c834 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/locking.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/locking.py @@ -16,7 +16,7 @@ def supports_for_update(self) -> bool: def supports_for_update_skip_locked(self) -> bool: """SQL Server 2019+ supports READPAST with UPDLOCK.""" - return self.version >= SQL_SERVER_2019 + return self.version_at_least(SQL_SERVER_2019) def format_for_update_clause(self, clause: "ForUpdateClause") -> Tuple[str, tuple]: """Format FOR UPDATE for SQL Server using table hints. @@ -28,8 +28,11 @@ def format_for_update_clause(self, clause: "ForUpdateClause") -> Tuple[str, tupl from rhosocial.activerecord.backend.expression import LockStrength if clause.strength != LockStrength.UPDATE: + # The phrase, not the artifact word: a refusal names what SQL would + # have said, and the artifact word is a serialization detail that + # means nothing to whoever reads the error. raise UnsupportedFeatureError( - self.name, f"{clause.strength.value} (unsupported lock strength)" + self.name, f"{clause.strength.phrase} (unsupported lock strength)" ) all_params: list = [] 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 91f7470..4f9d16c 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/memory_optimized.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/memory_optimized.py @@ -27,7 +27,7 @@ class SQLServerMemoryOptimizedMixin: def supports_memory_optimized_tables(self) -> bool: """Memory-optimized tables require SQL Server 2014+.""" - return self.version >= _SQL_SERVER_MEMORY_OPTIMIZED_VERSION # type: ignore[attr-defined] + return self.version_at_least(_SQL_SERVER_MEMORY_OPTIMIZED_VERSION) # type: ignore[attr-defined] def format_memory_optimized_option(self, durability: str = "SCHEMA_ONLY") -> str: """Format the In-Memory OLTP table option clause (2014+). diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/namespace.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/namespace.py index eaa3649..c4e1e92 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/namespace.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/namespace.py @@ -84,6 +84,14 @@ def supports_catalog(self) -> bool: """SQL Server models a catalog above the schema (the database).""" return True + def supports_schema_introspection(self) -> bool: + """SQL Server answers for its schemas from ``sys.schemas``. + + The capability for the ``introspect schemas`` family, declared + beside the namespace rules that decide which of its levels a + name may carry.""" + return True + def supports_catalog_qualification(self) -> bool: """SQL Server renders the catalog when a name carries one.""" return True diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/partition.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/partition.py index 0333259..b0e59cd 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/partition.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/partition.py @@ -17,6 +17,7 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from ..expression.objects import PartitionFunction, PartitionScheme +from ..expression.partition import SQLServerPartitionByRangeClause if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements import PartitionClause @@ -34,7 +35,7 @@ class SQLServerPartitionMixin: def supports_table_partitioning(self) -> bool: """SQL Server supports table partitioning (Enterprise Edition).""" - return self.version >= _SQL_SERVER_PARTITION_VERSION # type: ignore[attr-defined] + return self.version_at_least(_SQL_SERVER_PARTITION_VERSION) # type: ignore[attr-defined] def supports_partitioned_table_creation(self) -> bool: return self.supports_table_partitioning() @@ -125,7 +126,14 @@ def format_partition_clause(self, expr: "PartitionClause") -> Tuple[str, tuple]: parts.append(key_sql) params.extend(key_params) - scheme = getattr(expr, "partition_scheme", None) + # The partition scheme is declared by the SQL Server clause node; the + # generic ``PartitionClause`` has no such field, so the dispatch is on + # the declared class and the generic clause takes the placeholder path. + scheme = ( + expr.partition_scheme + if isinstance(expr, SQLServerPartitionByRangeClause) + else None + ) if scheme: # The scheme is a named object of its own, not part of the table's # name: ``ON ()`` names the scheme, and the table @@ -176,7 +184,7 @@ def format_partition_scheme_object(self, expr: PartitionScheme) -> Tuple[str, tu self.validate_namespace(expr) return self.format_qualified_name(expr) - def format_sqlserver_partition_function( + def format_partition_function_expression( self, expr: "SQLServerPartitionFunctionExpression" ) -> Tuple[str, tuple]: """Format CREATE PARTITION FUNCTION statement. @@ -210,7 +218,7 @@ def format_sqlserver_partition_function( ) return sql, () - def format_sqlserver_partition_scheme( + def format_partition_scheme_expression( self, expr: "SQLServerPartitionSchemeExpression" ) -> Tuple[str, tuple]: """Format CREATE PARTITION SCHEME statement. @@ -221,7 +229,7 @@ def format_sqlserver_partition_scheme( [ALL] TO (filegroup1, filegroup2, ...) ``expr.function_name`` is the same deliberate text field as the one in - :meth:`format_sqlserver_partition_function`: the partition function is + :meth:`format_partition_function_expression`: the partition function is named by text and objectified at the rendering boundary. """ scheme_name = self.qualified_object_name(PartitionScheme, expr.scheme_name) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/pivot.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/pivot.py index 1e3aefb..f301f7d 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/pivot.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/pivot.py @@ -24,11 +24,11 @@ class SQLServerPivotMixin: def supports_pivot(self) -> bool: """PIVOT is supported since SQL Server 2005.""" - return self.version >= _SQL_SERVER_PIVOT_VERSION # type: ignore[attr-defined] + return self.version_at_least(_SQL_SERVER_PIVOT_VERSION) # type: ignore[attr-defined] def supports_unpivot(self) -> bool: """UNPIVOT is supported since SQL Server 2005.""" - return self.version >= _SQL_SERVER_PIVOT_VERSION # type: ignore[attr-defined] + return self.version_at_least(_SQL_SERVER_PIVOT_VERSION) # type: ignore[attr-defined] def format_pivot( self, expr: "SQLServerPivotExpression" 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 2e0d863..21ac622 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/protocol_support.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/protocol_support.py @@ -12,7 +12,7 @@ silently returning a stub. """ -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, List, Optional, Tuple from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.objects import Index, Table, View @@ -52,17 +52,17 @@ def format_output_clause( def supports_offset_fetch(self) -> bool: """OFFSET FETCH pagination requires SQL Server 2012+.""" - return self.version >= (11, 0, 0) + return self.version_at_least((11, 0, 0)) def supports_order_by_in_subquery(self) -> bool: """ORDER BY in subqueries is allowed with TOP/OFFSET FETCH (2012+).""" - return self.version >= (11, 0, 0) + return self.version_at_least((11, 0, 0)) def supports_fulltext_catalog(self) -> bool: """FULLTEXT catalogs require SQL Server 2005+.""" - return self.version >= (9, 0, 0) + return self.version_at_least((9, 0, 0)) - def format_create_fulltext_catalog_statement( + def format_create_full_text_catalog_statement( self, catalog_name: str, as_default: bool = False ) -> Tuple[str, tuple]: """Format CREATE FULLTEXT CATALOG statement (2005+). @@ -88,7 +88,7 @@ def format_create_fulltext_catalog_statement( sql += " AS DEFAULT" return sql, () - def format_drop_fulltext_catalog_statement(self, catalog_name: str) -> Tuple[str, tuple]: + def format_drop_full_text_catalog_statement(self, catalog_name: str) -> Tuple[str, tuple]: """Format DROP FULLTEXT CATALOG statement (2005+). SQL Syntax: @@ -101,7 +101,7 @@ def format_drop_fulltext_catalog_statement(self, catalog_name: str) -> Tuple[str ) return f"DROP FULLTEXT CATALOG {self.format_identifier(catalog_name)}", () - def format_create_fulltext_index_statement( + def format_create_full_text_index_statement( self, table: str, columns: List[str], @@ -134,7 +134,7 @@ def format_create_fulltext_index_statement( ) return sql, () - def format_drop_fulltext_index_statement(self, table: str) -> Tuple[str, tuple]: + def format_drop_full_text_index_statement(self, table: str) -> Tuple[str, tuple]: """Format DROP FULLTEXT INDEX statement (2005+). SQL Syntax: @@ -152,8 +152,8 @@ def format_drop_fulltext_index_statement(self, table: str) -> Tuple[str, tuple]: def format_contains_predicate(self, column: str, search_string: str) -> Tuple[str, tuple]: """Format a CONTAINS predicate delegating to format_fulltext_match.""" - from rhosocial.activerecord.backend.expression.statements.fulltext_match import FulltextMatchExpression - expr = FulltextMatchExpression(self, [column], search_string) + from rhosocial.activerecord.backend.expression.statements.fulltext_match import FulltextMatch + expr = FulltextMatch(self, [column], search_string) return self.format_fulltext_match(expr) def format_freetext_predicate(self, column: str, search_string: str) -> Tuple[str, tuple]: @@ -178,7 +178,7 @@ def format_containstable_function( def supports_readpast(self) -> bool: """READPAST hint requires SQL Server 2019+.""" - return self.version >= (15, 0, 0) + return self.version_at_least((15, 0, 0)) def format_table_hint_locking(self, hint_type: str) -> Tuple[str, tuple]: """Format a locking table hint delegating to SQLServerTableHintClause.""" @@ -193,11 +193,11 @@ def format_table_hint_locking(self, hint_type: str) -> Tuple[str, tuple]: def supports_try_cast(self) -> bool: """TRY_CAST requires SQL Server 2012+.""" - return self.version >= (11, 0, 0) + return self.version_at_least((11, 0, 0)) def supports_try_convert(self) -> bool: """TRY_CONVERT requires SQL Server 2012+.""" - return self.version >= (11, 0, 0) + return self.version_at_least((11, 0, 0)) def format_try_cast_expression(self, expr_sql: str, target_type: str) -> Tuple[str, tuple]: """Format TRY_CAST(expr AS type).""" @@ -232,7 +232,7 @@ def supports_select_into(self) -> bool: def supports_tablesample(self) -> bool: """SQL Server supports TABLESAMPLE since 2005.""" - return self.version >= (9, 0, 0) + return self.version_at_least((9, 0, 0)) def format_tablesample_clause( self, @@ -270,7 +270,7 @@ def format_select_into_statement(self, expr: Any) -> Tuple[str, tuple]: The INTO clause is inserted between the select list and the FROM clause of the fully rendered query. """ - into_table = getattr(expr, "select_into_table", None) + into_table = expr.select_into_table if not into_table: raise ValueError( "select_into_table is required for SELECT INTO" @@ -323,7 +323,7 @@ def format_openjson_expression( def supports_system_versioning(self) -> bool: """Temporal table system-versioning requires SQL Server 2016+.""" - return self.version >= (13, 0, 0) + return self.version_at_least((13, 0, 0)) def format_temporal_period_definition(self, start_column: str, end_column: str) -> Tuple[str, tuple]: """Format PERIOD FOR SYSTEM_TIME (start, end).""" @@ -391,7 +391,7 @@ def format_create_indexed_view_statement(self, expr: Any) -> Tuple[str, tuple]: WITH SCHEMABINDING AS Args: - expr: A ``CreateViewExpression`` whose ``query`` supplies the + expr: A ``CreateViewStatement`` whose ``query`` supplies the select statement. Returns: @@ -408,7 +408,7 @@ def format_create_indexed_view_statement(self, expr: Any) -> Tuple[str, tuple]: ) if not isinstance(expr.view, View): raise TypeError( - f"CreateViewExpression.view must be a View, " + f"CreateViewStatement.view must be a View, " f"got {type(expr.view).__name__}" ) parts = ["CREATE VIEW"] @@ -448,10 +448,10 @@ def supports_json_function(self, function_name: str) -> bool: if name in self._JSON_FUNCTION_UNSUPPORTED: return False if name in self._JSON_FUNCTION_2022: - return self.version >= (16, 0, 0) - return self.version >= self._JSON_FUNCTION_MIN_VERSION + return self.version_at_least((16, 0, 0)) + return self.version_at_least(self._JSON_FUNCTION_MIN_VERSION) - def format_json_extract(self, expr: Any) -> Tuple[str, tuple]: + def format_json_extract_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a JSON extraction as JSON_VALUE (single path only). SQL Server's JSON_VALUE returns the scalar value at the path, already @@ -467,7 +467,7 @@ def format_json_extract(self, expr: Any) -> Tuple[str, tuple]: sql = f"JSON_VALUE({expr.json_doc}, {placeholder})" return self._with_alias(sql, expr.alias), (expr.path,) - def format_json_unquote(self, expr: Any) -> Tuple[str, tuple]: + def format_json_unquote_expression(self, expr: Any) -> Tuple[str, tuple]: """Format JSON_UNQUOTE - not needed in SQL Server. JSON_VALUE already returns scalar values without surrounding quotes, @@ -479,7 +479,7 @@ def format_json_unquote(self, expr: Any) -> Tuple[str, tuple]: "SQL Server's JSON_VALUE returns scalar values already unquoted.", ) - def format_json_object(self, expr: Any) -> Tuple[str, tuple]: + def format_json_object_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a JSON_OBJECT function (SQL Server 2022+). SQL Syntax: @@ -500,7 +500,7 @@ def format_json_object(self, expr: Any) -> Tuple[str, tuple]: sql = f"JSON_OBJECT({', '.join(parts)})" return self._with_alias(sql, expr.alias), tuple(params) - def format_json_array(self, expr: Any) -> Tuple[str, tuple]: + def format_json_array_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a JSON_ARRAY function (SQL Server 2022+).""" values = expr.values if not values: @@ -509,7 +509,7 @@ def format_json_array(self, expr: Any) -> Tuple[str, tuple]: sql = f"JSON_ARRAY({', '.join([placeholder] * len(values))})" return self._with_alias(sql, expr.alias), tuple(values) - def format_json_contains(self, expr: Any) -> Tuple[str, tuple]: + def format_json_contains_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a JSON containment check using OPENJSON (2016+). SQL Server has no JSON_CONTAINS; membership of ``candidate`` in the @@ -523,7 +523,7 @@ def format_json_contains(self, expr: Any) -> Tuple[str, tuple]: ) return self._with_alias(sql, expr.alias), (json_path, expr.candidate) - def format_json_set(self, expr: Any) -> Tuple[str, tuple]: + def format_json_set_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a JSON_MODIFY function (single path-value pair only). SQL Server's JSON_MODIFY accepts exactly one path-value pair. @@ -538,7 +538,7 @@ def format_json_set(self, expr: Any) -> Tuple[str, tuple]: sql = f"JSON_MODIFY({expr.json_doc}, {placeholder}, {placeholder})" return self._with_alias(sql, expr.alias), (expr.path, expr.value) - def format_json_remove(self, expr: Any) -> Tuple[str, tuple]: + def format_json_remove_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a JSON property removal using JSON_MODIFY (single path only). SQL Server removes a property by setting it to NULL with JSON_MODIFY. @@ -553,7 +553,7 @@ def format_json_remove(self, expr: Any) -> Tuple[str, tuple]: sql = f"JSON_MODIFY({expr.json_doc}, {placeholder}, NULL)" return self._with_alias(sql, expr.alias), (expr.path,) - def format_json_type(self, expr: Any) -> Tuple[str, tuple]: + def format_json_type_expression(self, expr: Any) -> Tuple[str, tuple]: """Format JSON_TYPE - not supported by SQL Server.""" raise UnsupportedFeatureError( self.name, @@ -561,7 +561,7 @@ def format_json_type(self, expr: Any) -> Tuple[str, tuple]: "SQL Server has no JSON_TYPE; classify values via OPENJSON.", ) - def format_json_valid(self, expr: Any) -> Tuple[str, tuple]: + def format_json_valid_expression(self, expr: Any) -> Tuple[str, tuple]: """Format an ISJSON function (SQL Server equivalent of JSON_VALID).""" sql = f"ISJSON({expr.json_val})" return self._with_alias(sql, expr.alias), () @@ -582,7 +582,7 @@ def supports_set_type(self) -> bool: """SQL Server has no MySQL-style SET column type.""" return False - def format_set_literal(self, expr: Any) -> Tuple[str, tuple]: + def format_set_literal_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a comma-separated value as a bind parameter. SQL Server stores comma-separated values in VARCHAR columns; the @@ -613,7 +613,7 @@ def _format_find_in_set_parts(self, value: str, set_column: str) -> Tuple[str, t col_sql = self.format_identifier(set_column) return f"(',' + {col_sql} + ',' LIKE '%,' + {placeholder} + ',%')", (value,) - def format_find_in_set(self, expr: Any) -> Tuple[str, tuple]: + def format_find_in_set_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a comma-separated membership check. SQL Server has no FIND_IN_SET; membership is checked with a LIKE @@ -622,7 +622,7 @@ def format_find_in_set(self, expr: Any) -> Tuple[str, tuple]: sql, params = self._format_find_in_set_parts(expr.value, expr.set_column) return self._with_alias(sql, expr.alias), params - def format_set_contains(self, expr: Any) -> Tuple[str, tuple]: + def format_set_contains_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a set-containment predicate as AND-ed membership checks.""" conditions = [] params: List[str] = [] @@ -649,21 +649,21 @@ def format_set_contains(self, expr: Any) -> Tuple[str, tuple]: def supports_spatial_type(self, type_name: str) -> bool: """SQL Server supports geometry/geography since 2008 (10.0).""" - return self.version >= (10, 0, 0) and type_name.upper() in self._SPATIAL_TYPES + return self.version_at_least((10, 0, 0)) and type_name.upper() in self._SPATIAL_TYPES def supports_spatial_index(self) -> bool: """SQL Server supports spatial indexes since 2008 (10.0).""" - return self.version >= (10, 0, 0) + return self.version_at_least((10, 0, 0)) def supports_geojson(self) -> bool: """STAsGeoJSON is not available in the installed SQL Server types assembly.""" return False - def format_spatial_literal(self, expr: Any) -> Tuple[str, tuple]: + def format_spatial_literal_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a WKT literal using geometry::STGeomFromText.""" - return self.format_st_geom_from_text(expr) + return self.format_st_geom_from_text_expression(expr) - def format_st_geom_from_text(self, expr: Any) -> Tuple[str, tuple]: + def format_st_geom_from_text_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a geometry::STGeomFromText call. SQL Server requires the SRID argument; it defaults to 0. @@ -676,32 +676,32 @@ def format_st_geom_from_text(self, expr: Any) -> Tuple[str, tuple]: sql = f"geometry::STGeomFromText({placeholder}, {placeholder})" return self._with_alias(sql, expr.alias), (expr.wkt, srid) - def format_st_as_text(self, expr: Any) -> Tuple[str, tuple]: + def format_st_as_text_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a geometry STAsText method call: ``geom.STAsText()``.""" sql = f"{expr.geom}.STAsText()" return self._with_alias(sql, expr.alias), () - def format_st_as_geojson(self, expr: Any) -> Tuple[str, tuple]: + def format_st_as_geo_json_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a geometry STAsGeoJSON method call: ``geom.STAsGeoJSON()``.""" sql = f"{expr.geom}.STAsGeoJSON()" return self._with_alias(sql, expr.alias), () - def format_st_distance(self, expr: Any) -> Tuple[str, tuple]: + def format_st_distance_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a geometry STDistance method call: ``g1.STDistance(g2)``.""" sql = f"{expr.geom1}.STDistance({expr.geom2})" return self._with_alias(sql, expr.alias), () - def format_st_within(self, expr: Any) -> Tuple[str, tuple]: + def format_st_within_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a geometry STWithin method call: ``g1.STWithin(g2)``.""" sql = f"{expr.geom1}.STWithin({expr.geom2})" return self._with_alias(sql, expr.alias), () - def format_st_contains(self, expr: Any) -> Tuple[str, tuple]: + def format_st_contains_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a geometry STContains method call: ``g1.STContains(g2)``.""" sql = f"{expr.geom1}.STContains({expr.geom2})" return self._with_alias(sql, expr.alias), () - def format_st_distance_sphere(self, expr: Any) -> Tuple[str, tuple]: + def format_st_distance_sphere_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a spherical distance check. SQL Server has no ST_Distance_Sphere; STDistance is used for the @@ -710,12 +710,12 @@ def format_st_distance_sphere(self, expr: Any) -> Tuple[str, tuple]: sql = f"{expr.geom1}.STDistance({expr.geom2})" return self._with_alias(sql, expr.alias), () - def format_st_intersects(self, expr: Any) -> Tuple[str, tuple]: + def format_st_intersects_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a geometry STIntersects method call: ``g1.STIntersects(g2)``.""" sql = f"{expr.geom1}.STIntersects({expr.geom2})" return self._with_alias(sql, expr.alias), () - def format_create_spatial_index(self, expr: Any) -> Tuple[str, tuple]: + def format_create_spatial_index_statement(self, expr: Any) -> Tuple[str, tuple]: """Format a CREATE SPATIAL INDEX statement. SQL Syntax: diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/returning.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/returning.py index 5687b89..25f04ce 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/returning.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/returning.py @@ -56,11 +56,15 @@ def format_returning_clause(self, clause: "ReturningClause", default_table: str Raises: UnsupportedFeatureError: If the clause requests an alias, a - wildcard, or contains a non-column expression. + wildcard (``AllColumnsWildcard``), or contains a non-column + expression. """ from rhosocial.activerecord.backend.expression.column_types import ColumnBase - from rhosocial.activerecord.backend.expression.core import WildcardExpression - from rhosocial.activerecord.backend.expression.operators import RawSQLExpression + from rhosocial.activerecord.backend.expression.core import ( + AllColumnsWildcard, + RowCountWildcard, + ) + from rhosocial.activerecord.backend.expression.operators import RawSQL if clause.alias: raise UnsupportedFeatureError( @@ -73,7 +77,14 @@ def format_returning_clause(self, clause: "ReturningClause", default_table: str expr_parts = [] for expr in clause.expressions: - if isinstance(expr, WildcardExpression): + # The RETURNING position's wildcard is core's ``AllColumnsWildcard`` + # (spec 4.10.1); ``RowCountWildcard`` belongs to COUNT's argument and + # has no business in an OUTPUT list either. ``OUTPUT inserted.*`` + # would be this backend's own spelling, but OUTPUT takes explicit + # column references only, so both classes are refused by name rather + # than left to fall through to the generic refusal below, which would + # name neither. + if isinstance(expr, (AllColumnsWildcard, RowCountWildcard)): raise UnsupportedFeatureError( self.name, "wildcard in OUTPUT", @@ -84,7 +95,7 @@ def format_returning_clause(self, clause: "ReturningClause", default_table: str prefix = expr.table.upper() if expr.table else default_table col_sql = self.format_identifier(expr.name, expr.name_need_quote) expr_parts.append(f"{prefix}.{col_sql}") - elif isinstance(expr, RawSQLExpression): + elif isinstance(expr, RawSQL): expr_sql, expr_params = expr.to_sql() expr_parts.append(f"{default_table}.{expr_sql}") all_params.extend(expr_params) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/routine.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/routine.py index b7f4069..b143b4f 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/routine.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/routine.py @@ -5,8 +5,8 @@ (parameters with leading ``@``, ``RETURNS``, ``AS BEGIN ... END`` bodies) that differs from the SQL/PSM form used by other backends. This mixin provides the capability switches and the formatters used by the -``SQLServerCreateProcedureExpression`` / ``SQLServerCreateFunctionExpression`` -/ ``SQLServerDropRoutineExpression`` expressions. +``SQLServerCreateProcedureStatement`` / ``SQLServerCreateFunctionStatement`` +/ ``SQLServerDropRoutineStatement`` expressions. """ from typing import Any, List, Optional, Tuple, TYPE_CHECKING @@ -15,9 +15,9 @@ if TYPE_CHECKING: from ..expression.ddl.routine import ( - SQLServerCreateFunctionExpression, - SQLServerCreateProcedureExpression, - SQLServerDropRoutineExpression, + SQLServerCreateFunctionStatement, + SQLServerCreateProcedureStatement, + SQLServerDropRoutineStatement, ) @@ -31,21 +31,41 @@ class SQLServerRoutineMixin: """SQL Server stored procedure / function capability and formatting.""" + def supports_procedure_introspection(self) -> bool: + """SQL Server answers for its procedures from ``sys.procedures``. + + The capability for the ``introspect procedures`` family, with bodies + from ``OBJECT_DEFINITION``. Declared beside the routine DDL switches + because both answer the same question about the same catalogue: + whether this engine has procedures at all. + """ + return True + + def supports_function_introspection(self) -> bool: + """SQL Server answers for its functions from ``sys.objects``. + + The capability for the ``introspect functions`` family. The same + catalogue row answers for procedures, so the two switches live + together -- but they name different questions, and a caller asking + about functions should not have to ask about procedures to find out. + """ + return True + def supports_create_procedure(self) -> bool: """CREATE PROCEDURE is supported since SQL Server 2005.""" - return self.version >= _SQL_SERVER_ROUTINE_VERSION # type: ignore[attr-defined] + return self.version_at_least(_SQL_SERVER_ROUTINE_VERSION) # type: ignore[attr-defined] def supports_create_function(self) -> bool: """CREATE FUNCTION is supported since SQL Server 2005.""" - return self.version >= _SQL_SERVER_ROUTINE_VERSION # type: ignore[attr-defined] + return self.version_at_least(_SQL_SERVER_ROUTINE_VERSION) # type: ignore[attr-defined] def supports_drop_procedure(self) -> bool: """DROP PROCEDURE is supported since SQL Server 2005.""" - return self.version >= _SQL_SERVER_ROUTINE_VERSION # type: ignore[attr-defined] + return self.version_at_least(_SQL_SERVER_ROUTINE_VERSION) # type: ignore[attr-defined] def supports_drop_function(self) -> bool: """DROP FUNCTION is supported since SQL Server 2005.""" - return self.version >= _SQL_SERVER_ROUTINE_VERSION # type: ignore[attr-defined] + return self.version_at_least(_SQL_SERVER_ROUTINE_VERSION) # type: ignore[attr-defined] def _routine_object_name( self, @@ -74,7 +94,7 @@ def _format_params(parameters: List[Any]) -> str: Accepts either raw parameter strings (``"@x INT"``) or dictionaries with ``name`` / ``type`` keys (matching the core - ``CreateFunctionExpression`` convention). + ``CreateFunctionStatement`` convention). Raises: TypeError: A parameter is neither a string nor a dict. A parameter is @@ -101,10 +121,13 @@ def _format_params(parameters: List[Any]) -> str: return ", ".join(parts) def _get_routine_name(self, expr: Any) -> str: - return getattr(expr, "name", None) or getattr(expr, "function_name", "") + # Every routine expression names itself through its declared ``name`` + # field; the shadowed core function statement has no such field and + # fails here explicitly rather than reading as an empty name. + return expr.name def format_create_procedure_statement( - self, expr: "SQLServerCreateProcedureExpression" + self, expr: "SQLServerCreateProcedureStatement" ) -> Tuple[str, tuple]: """Format CREATE PROCEDURE for SQL Server (2005+). @@ -127,22 +150,22 @@ def format_create_procedure_statement( raise ValueError("procedure name is required") parts = ["CREATE"] - if getattr(expr, "or_alter", False): + if expr.or_alter: parts.append("OR ALTER") parts.append("PROCEDURE") - parts.append(self._routine_object_name("PROCEDURE", name, getattr(expr, "schema", None))) + parts.append(self._routine_object_name("PROCEDURE", name, expr.schema)) - parameters = getattr(expr, "parameters", None) or [] + parameters = expr.parameters or [] if parameters: parts.append(self._format_params(list(parameters))) - if not getattr(expr, "body", None): + if not expr.body: raise ValueError("routine body is required") parts.append(f"AS BEGIN {expr.body} END;") return " ".join(parts), () def format_create_function_statement( - self, expr: "SQLServerCreateFunctionExpression" + self, expr: "SQLServerCreateFunctionStatement" ) -> Tuple[str, tuple]: """Format CREATE FUNCTION for SQL Server (2005+). @@ -162,25 +185,25 @@ def format_create_function_statement( name = self._get_routine_name(expr) if not name: raise ValueError("function name is required") - if not getattr(expr, "returns", None): + if not expr.returns: raise ValueError("RETURNS type is required for a scalar function") parts = ["CREATE"] - if getattr(expr, "or_alter", False): + if expr.or_alter: parts.append("OR ALTER") parts.append("FUNCTION") - parts.append(self._routine_object_name("FUNCTION", name, getattr(expr, "schema", None))) + parts.append(self._routine_object_name("FUNCTION", name, expr.schema)) - parameters = getattr(expr, "parameters", None) or [] + parameters = expr.parameters or [] parts.append(f"({self._format_params(list(parameters))})") parts.append(f"RETURNS {expr.returns}") - if not getattr(expr, "body", None): + if not expr.body: raise ValueError("function body is required") parts.append(f"AS BEGIN {expr.body} END;") return " ".join(parts), () def format_drop_routine_statement( - self, expr: "SQLServerDropRoutineExpression" + self, expr: "SQLServerDropRoutineStatement" ) -> Tuple[str, tuple]: """Format DROP PROCEDURE / DROP FUNCTION for SQL Server (2005+). @@ -190,7 +213,7 @@ def format_drop_routine_statement( ``IF EXISTS`` is rendered only on SQL Server 2016+. """ - kind = getattr(expr, "kind", "PROCEDURE").upper() + kind = expr.kind.upper() if kind not in _ROUTINE_KINDS: raise ValueError(f"kind must be one of {_ROUTINE_KINDS}, got {kind!r}") @@ -206,7 +229,7 @@ def format_drop_routine_statement( raise ValueError("routine name is required") parts = ["DROP", kind] - if getattr(expr, "if_exists", False) and self.version >= _SQL_SERVER_DROP_IF_EXISTS_VERSION: # type: ignore[attr-defined] + if expr.if_exists and self.version >= _SQL_SERVER_DROP_IF_EXISTS_VERSION: # type: ignore[attr-defined] parts.append("IF EXISTS") - parts.append(self._routine_object_name(kind, name, getattr(expr, "schema", None))) + parts.append(self._routine_object_name(kind, name, expr.schema)) return " ".join(parts), () diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/schema.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/schema.py index b4d0862..960172b 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/schema.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/schema.py @@ -6,8 +6,8 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements import ( - CreateSchemaExpression, - DropSchemaExpression, + CreateSchemaStatement, + DropSchemaStatement, ) @@ -30,7 +30,7 @@ def supports_schema_authorization(self) -> bool: """SQL Server accepts CREATE SCHEMA ... AUTHORIZATION owner.""" return True - def format_create_schema_statement(self, expr: "CreateSchemaExpression") -> Tuple[str, tuple]: + def format_create_schema_statement(self, expr: "CreateSchemaStatement") -> Tuple[str, tuple]: """Format CREATE SCHEMA for SQL Server. SQL Server uses CREATE SCHEMA schema_name [AUTHORIZATION owner_name]. @@ -43,7 +43,7 @@ def format_create_schema_statement(self, expr: "CreateSchemaExpression") -> Tupl """ if not isinstance(expr.schema, Schema): raise TypeError( - f"CreateSchemaExpression.schema must be a Schema, " + f"CreateSchemaStatement.schema must be a Schema, " f"got {type(expr.schema).__name__}" ) parts = ["CREATE SCHEMA"] @@ -52,7 +52,7 @@ def format_create_schema_statement(self, expr: "CreateSchemaExpression") -> Tupl parts.append(f"AUTHORIZATION {self.format_identifier(expr.authorization)}") return " ".join(parts), () - def format_drop_schema_statement(self, expr: "DropSchemaExpression") -> Tuple[str, tuple]: + def format_drop_schema_statement(self, expr: "DropSchemaStatement") -> Tuple[str, tuple]: """Format DROP SCHEMA for SQL Server. SQL Server requires schema to be empty before dropping. @@ -69,7 +69,7 @@ def format_drop_schema_statement(self, expr: "DropSchemaExpression") -> Tuple[st """ if not isinstance(expr.schema, Schema): raise TypeError( - f"DropSchemaExpression.schema must be a Schema, " + f"DropSchemaStatement.schema must be a Schema, " f"got {type(expr.schema).__name__}" ) if expr.cascade and not self.supports_schema_cascade(): diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/sequence.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/sequence.py index a8fb908..a5b383d 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/sequence.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/sequence.py @@ -25,7 +25,15 @@ def supports_sequence_as_data_type(self) -> bool: """SQL Server sequences are not usable as column data types.""" return False - def format_next_value_for( + def supports_sequence_introspection(self) -> bool: + """SQL Server answers for its sequences from ``sys.sequences``. + + The capability for the ``introspect sequences`` family, under + the shared name so the family is answerable the same way + everywhere it exists.""" + return True + + def format_next_value_for_expression( self, expr: "SQLServerNextValueForExpression" ) -> Tuple[str, tuple]: """Format NEXT VALUE FOR expression (SQL Server 2012+). @@ -72,19 +80,19 @@ def format_next_value_for( def supports_sequence(self) -> bool: """SQL Server 2012+ has SEQUENCE objects.""" - return self.version >= SQL_SERVER_2012 # type: ignore[attr-defined] + return self.version_at_least(SQL_SERVER_2012) # type: ignore[attr-defined] def supports_create_sequence(self) -> bool: """SQL Server 2012+ supports CREATE SEQUENCE.""" - return self.version >= SQL_SERVER_2012 # type: ignore[attr-defined] + return self.version_at_least(SQL_SERVER_2012) # type: ignore[attr-defined] def supports_drop_sequence(self) -> bool: """SQL Server 2012+ supports DROP SEQUENCE.""" - return self.version >= SQL_SERVER_2012 # type: ignore[attr-defined] + return self.version_at_least(SQL_SERVER_2012) # type: ignore[attr-defined] def supports_alter_sequence(self) -> bool: """SQL Server 2012+ supports ALTER SEQUENCE.""" - return self.version >= SQL_SERVER_2012 # type: ignore[attr-defined] + return self.version_at_least(SQL_SERVER_2012) # type: ignore[attr-defined] def supports_sequence_if_not_exists(self) -> bool: """SQL Server has no CREATE SEQUENCE IF NOT EXISTS.""" @@ -92,11 +100,11 @@ def supports_sequence_if_not_exists(self) -> bool: def supports_sequence_if_exists(self) -> bool: """DROP SEQUENCE IF EXISTS arrived in SQL Server 2016 (13.x).""" - return self.version >= SQL_SERVER_2016 # type: ignore[attr-defined] + return self.version_at_least(SQL_SERVER_2016) # type: ignore[attr-defined] def supports_sequence_start(self) -> bool: """CREATE SEQUENCE accepts START WITH from SQL Server 2012.""" - return self.version >= SQL_SERVER_2012 # type: ignore[attr-defined] + return self.version_at_least(SQL_SERVER_2012) # type: ignore[attr-defined] def supports_alter_sequence_start(self) -> bool: """Whether ALTER SEQUENCE accepts the START WITH option. @@ -117,23 +125,23 @@ def supports_alter_sequence_start(self) -> bool: def supports_sequence_increment(self) -> bool: """CREATE/ALTER SEQUENCE accept INCREMENT BY from SQL Server 2012.""" - return self.version >= SQL_SERVER_2012 # type: ignore[attr-defined] + return self.version_at_least(SQL_SERVER_2012) # type: ignore[attr-defined] def supports_sequence_minvalue(self) -> bool: """CREATE/ALTER SEQUENCE accept MINVALUE from SQL Server 2012.""" - return self.version >= SQL_SERVER_2012 # type: ignore[attr-defined] + return self.version_at_least(SQL_SERVER_2012) # type: ignore[attr-defined] def supports_sequence_maxvalue(self) -> bool: """CREATE/ALTER SEQUENCE accept MAXVALUE from SQL Server 2012.""" - return self.version >= SQL_SERVER_2012 # type: ignore[attr-defined] + return self.version_at_least(SQL_SERVER_2012) # type: ignore[attr-defined] def supports_sequence_cycle(self) -> bool: """CREATE/ALTER SEQUENCE accept CYCLE / NO CYCLE from SQL Server 2012.""" - return self.version >= SQL_SERVER_2012 # type: ignore[attr-defined] + return self.version_at_least(SQL_SERVER_2012) # type: ignore[attr-defined] def supports_sequence_cache(self) -> bool: """CREATE/ALTER SEQUENCE accept CACHE from SQL Server 2012.""" - return self.version >= SQL_SERVER_2012 # type: ignore[attr-defined] + return self.version_at_least(SQL_SERVER_2012) # type: ignore[attr-defined] def supports_sequence_order(self) -> bool: """SQL Server has no ORDER / NOORDER sequence option.""" diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/set_operation.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/set_operation.py index f09d817..61395d1 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/set_operation.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/set_operation.py @@ -1,14 +1,24 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/mixins/set_operation.py -from typing import Tuple, TYPE_CHECKING +"""SQL Server set-operation capability declarations. -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +Set-operation *formatting* is provided by the core +``SetOperationMixin.format_set_operation``: the node now carries the operation +as a word form (``union`` / ``union_all`` / ``intersect`` / ``except``) and +nothing else, so the trailing ``alias`` / ``ORDER BY`` / ``OFFSET FETCH`` / +``FOR UPDATE`` parameters the old override read are gone, and T-SQL spells all +four operations exactly as the shared renderer writes them. The override that +used to live here has therefore been removed along with the node fields it +read; this file keeps the capability declarations. +""" -if TYPE_CHECKING: +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.expression import bases class SQLServerSetOperationMixin: - """SQL Server set operation capability declarations and formatting.""" + """SQL Server set operation capability declarations.""" def supports_union(self) -> bool: """UNION is supported.""" @@ -33,64 +43,3 @@ def supports_set_operation_order_by(self) -> bool: def supports_set_operation_limit_offset(self) -> bool: """Set operations support OFFSET FETCH.""" return True - - def format_set_operation_expression(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: - """Format set operation for SQL Server. - - SQL Server supports UNION, UNION ALL, INTERSECT, EXCEPT. - ORDER BY and OFFSET FETCH apply to the entire set operation result. - """ - left, right = expr.left, expr.right - operation = expr.operation - all_ = expr.all_ - distinct = expr.distinct - alias = expr.alias - order_by_clause = expr.order_by_clause - limit_offset_clause = expr.limit_offset_clause - for_update_clause = expr.for_update_clause - - if for_update_clause and not self.supports_set_operation_for_update(): - raise UnsupportedFeatureError( - self.name, - "FOR UPDATE in set operations", - "SQL Server does not support FOR UPDATE in a set operation.", - ) - - left_sql, left_params = left.to_sql() - right_sql, right_params = right.to_sql() - if all_: - all_str = " ALL" - elif distinct: - # T-SQL has no DISTINCT keyword in set operations; plain UNION is - # already the distinct form. An explicit DISTINCT request is - # refused rather than dropped -- measured refused on 2019 / 2022 / - # 2025 (``Incorrect syntax near the keyword 'DISTINCT'``). - raise UnsupportedFeatureError( - self.name, - f"{operation} DISTINCT", - f"{self.name} has no DISTINCT keyword in set operations; " - f"the DISTINCT spelling is not part of T-SQL.", - ) - else: - all_str = "" - - base_sql = f"{left_sql} {operation}{all_str} {right_sql}" - - all_params = list(left_params + right_params) - - sql_parts = [base_sql] - - if alias: - sql_parts.append(f"AS {self.format_identifier(alias)}") - - if order_by_clause: - order_by_sql, order_by_params = order_by_clause.to_sql() - sql_parts.append(order_by_sql) - all_params.extend(order_by_params) - - if limit_offset_clause: - limit_offset_sql, limit_offset_params = limit_offset_clause.to_sql() - sql_parts.append(limit_offset_sql) - all_params.extend(limit_offset_params) - - return " ".join(sql_parts), tuple(all_params) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/temporal.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/temporal.py index 21a97c0..7e3e37a 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/temporal.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/temporal.py @@ -7,4 +7,4 @@ class SQLServerTemporalMixin: def supports_temporal_tables(self) -> bool: """SQL Server 2016+ supports temporal tables.""" - return self.version >= SQL_SERVER_2016 + return self.version_at_least(SQL_SERVER_2016) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/transaction.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/transaction.py index 6a21b0c..671bbc2 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/transaction.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/transaction.py @@ -3,8 +3,8 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.transaction import ( - BeginTransactionExpression, - SetTransactionExpression, + BeginTransactionStatement, + SetTransactionStatement, ) @@ -94,8 +94,8 @@ def _refuse_deferrable_transaction(self, expr: Any) -> None: f"{self.name} does not support the {spelling} transaction mode.", ) - def format_begin_transaction( - self, expr: "BeginTransactionExpression" + def format_begin_transaction_statement( + self, expr: "BeginTransactionStatement" ) -> Tuple[str, tuple]: """Format BEGIN TRANSACTION for SQL Server.""" from rhosocial.activerecord.backend.errors import UnsupportedTransactionModeError @@ -117,7 +117,7 @@ def format_begin_transaction( return f"BEGIN TRANSACTION {wait_clause}", () return "BEGIN TRANSACTION", () - def format_set_transaction(self, expr: "SetTransactionExpression") -> Tuple[str, tuple]: + def format_set_transaction_statement(self, expr: "SetTransactionStatement") -> Tuple[str, tuple]: """Format SET TRANSACTION for SQL Server. SQL Server requires SET TRANSACTION ISOLATION LEVEL before BEGIN TRANSACTION. diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/trigger.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/trigger.py index fd11d42..c9520f6 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/trigger.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/trigger.py @@ -4,8 +4,8 @@ T-SQL triggers use a syntax distinct from the SQL:1999 form rendered by the core ``TriggerMixin``: the body is an ``AS BEGIN ... END`` batch rather than ``EXECUTE FUNCTION``. This mixin provides the capability switches and the -formatters used by ``SQLServerCreateTriggerExpression`` / -``SQLServerDropTriggerExpression``. +formatters used by ``SQLServerCreateTriggerStatement`` / +``SQLServerDropTriggerStatement``. """ from typing import Any, Tuple, TYPE_CHECKING @@ -15,8 +15,8 @@ if TYPE_CHECKING: from ..expression.ddl.trigger import ( - SQLServerCreateTriggerExpression, - SQLServerDropTriggerExpression, + SQLServerCreateTriggerStatement, + SQLServerDropTriggerStatement, ) @@ -34,21 +34,24 @@ class SQLServerTriggerDdlMixin: def supports_trigger(self) -> bool: """Triggers are supported since SQL Server 2005.""" - return self.version >= _SQL_SERVER_TRIGGER_VERSION # type: ignore[attr-defined] + return self.version_at_least(_SQL_SERVER_TRIGGER_VERSION) # type: ignore[attr-defined] def supports_create_trigger(self) -> bool: """CREATE TRIGGER is supported since SQL Server 2005.""" - return self.version >= _SQL_SERVER_TRIGGER_VERSION # type: ignore[attr-defined] + return self.version_at_least(_SQL_SERVER_TRIGGER_VERSION) # type: ignore[attr-defined] def supports_drop_trigger(self) -> bool: """DROP TRIGGER is supported since SQL Server 2005.""" - return self.version >= _SQL_SERVER_TRIGGER_VERSION # type: ignore[attr-defined] + return self.version_at_least(_SQL_SERVER_TRIGGER_VERSION) # type: ignore[attr-defined] def _get_trigger_name(self, expr: Any) -> str: - return getattr(expr, "name", None) or getattr(expr, "trigger_name", "") + # Every T-SQL trigger expression names itself through its declared + # ``name`` field; the shadowed SQL:1999 statements have no such field + # and fail here explicitly rather than reading as an empty name. + return expr.name def format_create_trigger_statement( - self, expr: "SQLServerCreateTriggerExpression" + self, expr: "SQLServerCreateTriggerStatement" ) -> Tuple[str, tuple]: """Format CREATE TRIGGER for SQL Server (2005+). @@ -67,57 +70,56 @@ def format_create_trigger_statement( "CREATE TRIGGER", "requires SQL Server 2005+.", ) - if not hasattr(expr, "body"): + # The T-SQL form is rendered from this dialect's own trigger node; the + # SQL:1999 statement (no raw ``body`` -- it names a function instead) + # is refused by class rather than probed for the field. + from ..expression.ddl.trigger import SQLServerCreateTriggerStatement + + if not isinstance(expr, SQLServerCreateTriggerStatement): raise UnsupportedFeatureError( self.name, # type: ignore[attr-defined] "SQL:1999 CREATE TRIGGER", - "Use SQLServerCreateTriggerExpression for T-SQL trigger DDL.", + "Use SQLServerCreateTriggerStatement for T-SQL trigger DDL.", ) name = self._get_trigger_name(expr) if not name: raise ValueError("trigger name is required") - table = getattr(expr, "table_name", None) or getattr(expr, "table", None) + table = expr.table if not table: raise ValueError("trigger table is required") - timing = getattr(expr, "timing", "AFTER").upper() + timing = expr.timing.upper() if timing not in _TRIGGER_TIMINGS: raise ValueError(f"timing must be one of {_TRIGGER_TIMINGS}, got {timing!r}") - events = [event.upper() for event in (getattr(expr, "events", None) or [])] + events = [event.upper() for event in (expr.events or [])] if not events: raise ValueError("at least one trigger event is required") for event in events: if event not in _TRIGGER_EVENTS: raise ValueError(f"event must be one of {_TRIGGER_EVENTS}, got {event!r}") - if not getattr(expr, "body", None): + if not expr.body: raise ValueError("trigger body is required") parts = ["CREATE"] - if getattr(expr, "or_alter", False): + if expr.or_alter: parts.append("OR ALTER") parts.append("TRIGGER") # A trigger and the table it hangs off are both schema objects; a # trigger lives in the same schema as its table, so both names go # through the one qualified-name entry point. parts.append( - self.qualified_object_name_from_dotted( - Trigger, name, schema_name=getattr(expr, "schema", None) - ) + self.qualified_object_name_from_dotted(Trigger, name, schema_name=expr.schema) ) parts.append("ON") - parts.append( - self.qualified_object_name( - Table, table, schema_name=getattr(expr, "schema", None) - ) - ) + parts.append(self.qualified_object_name(Table, table, schema_name=expr.schema)) parts.append(timing) parts.append(", ".join(events)) parts.append(f"AS BEGIN {expr.body} END;") return " ".join(parts), () def format_drop_trigger_statement( - self, expr: "SQLServerDropTriggerExpression" + self, expr: "SQLServerDropTriggerStatement" ) -> Tuple[str, tuple]: """Format DROP TRIGGER for SQL Server (2005+). @@ -137,12 +139,10 @@ def format_drop_trigger_statement( raise ValueError("trigger name is required") parts = ["DROP", "TRIGGER"] - if getattr(expr, "if_exists", False) and self.version >= _SQL_SERVER_TRIGGER_IF_EXISTS_VERSION: # type: ignore[attr-defined] + if expr.if_exists and self.version >= _SQL_SERVER_TRIGGER_IF_EXISTS_VERSION: # type: ignore[attr-defined] parts.append("IF EXISTS") parts.append( - self.qualified_object_name_from_dotted( - Trigger, name, schema_name=getattr(expr, "schema", None) - ) + self.qualified_object_name_from_dotted(Trigger, name, schema_name=expr.schema) ) return " ".join(parts), () diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/types.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/types.py index 424f341..b9f738d 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/types.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/types.py @@ -61,7 +61,7 @@ class SQLServerTypeSupportMixin(DDLTypeMixin, DDLTypeSupport): A concept with several spellings (``INTEGER``/``INT``, ``DECIMAL``/ ``NUMERIC``/``DEC``) declares them as a closed ``SPELLINGS`` list and the formatter states which of them this backend renders through - ``_check_spelling``. T-SQL writes none of the alternatives below, so every + ``check_spelling``. T-SQL writes none of the alternatives below, so every spelling of every concept is accepted and normalised to the T-SQL word — refusing one would refuse the concept's own default spelling, which is the spelling every plainly-constructed type carries. The gate is still called @@ -250,7 +250,7 @@ def format_data_type_sqlserver_tinyint(self, data_type) -> Tuple[str, tuple]: :meth:`_refuse_unsigned_tinyint`, which is where a caller who writes ``TinyIntType(unsigned=True)`` is sent here by name. """ - self._check_spelling(data_type, TinyIntType) + self.check_spelling(data_type, TinyIntType) return "TINYINT", () def format_data_type_sqlserver_bit(self, data_type) -> Tuple[str, tuple]: @@ -335,7 +335,23 @@ def _refuse_unsigned_integer(self, data_type, word: str) -> None: lost by dropping the *concept* here; what would be lost is the caller's statement that this particular column holds no negative value, and that belongs in a ``CHECK`` constraint. + + The shape of ``unsigned`` is checked here, at the rendering end, rather + than in the expression's constructor: an expression collects its + parameters without judging them, because only the formatter knows which + backend will have to honour this flag. A non-boolean therefore arrives + here instead of being turned away at construction, and is named here -- + testing its truth instead would let ``unsigned="yes"`` through the same + branch as ``True`` and report a refusal about a value the caller never + declared, while ``unsigned=0`` would slip past it and render the signed + column silently. """ + if not isinstance(data_type.unsigned, bool): + raise TypeError( + f"unsigned must be a bool, got " + f"{type(data_type.unsigned).__name__}; it " + f"becomes a type modifier in the rendered DDL." + ) if not data_type.unsigned: return raise UnsupportedFeatureError( @@ -407,7 +423,21 @@ class that owns it already exists. There is no such case here: no T-SQL ``UnsupportedFeatureError``, not ``ValueError``: this is a declaration the grammar cannot express at all rather than a wrong value, and the two exceptions do not share a base class. + + The shape of ``unsigned`` is checked here for the reason given on + :meth:`_refuse_unsigned_integer`. The attribute is read **directly** + rather than with ``getattr``: the expression classes are the protocol, + so whether ``unsigned`` exists is statically settled -- ``DecimalType``, + ``FloatType``, ``RealType`` and ``DoubleType`` all declare it in + ``PARAMETERS`` -- and a probe would turn "the caller passed the wrong + class" into a silent signed rendering. """ + if not isinstance(data_type.unsigned, bool): + raise TypeError( + f"unsigned must be a bool, got " + f"{type(data_type.unsigned).__name__}; it " + f"becomes a type modifier in the rendered DDL." + ) if not data_type.unsigned: return raise UnsupportedFeatureError( @@ -457,7 +487,23 @@ def _refuse_unsigned_tinyint(self, data_type) -> None: type, and the width a caller can have is whatever T-SQL offers. A value that must be allowed to go negative needs ``SMALLINT``, the narrowest signed width T-SQL has. + + The shape of ``unsigned`` is checked here too, for the reason given on + :meth:`_refuse_unsigned_integer`. It is checked **here** and not on + :meth:`format_data_type_sqlserver_tinyint`, and that placement is what + keeps :class:`SQLServerTinyIntType` working: this helper is reached only + from the *generic* concept, whose caller may write either value, + whereas ``SQLServerTinyIntType`` pins ``unsigned=True`` in its own + ``__init__`` and must render, not be refused. A guard that asked + "is this unsigned?" and then refused would turn that class's own + declaration into an error. """ + if not isinstance(data_type.unsigned, bool): + raise TypeError( + f"unsigned must be a bool, got " + f"{type(data_type.unsigned).__name__}; it " + f"becomes a type modifier in the rendered DDL." + ) if not data_type.unsigned: return raise UnsupportedFeatureError( @@ -490,7 +536,7 @@ def format_data_type_integer(self, data_type: IntegerType) -> Tuple[str, tuple]: :meth:`_refuse_unsigned_integer`, which is also where the reason for the refusal is written down. """ - self._check_spelling(data_type, IntegerType) + self.check_spelling(data_type, IntegerType) self._refuse_unsigned_integer(data_type, "INT") return "INT", () @@ -500,7 +546,7 @@ def format_data_type_bigint(self, data_type: BigIntType) -> Tuple[str, tuple]: ``unsigned`` is refused rather than ignored; see :meth:`_refuse_unsigned_integer`.""" - self._check_spelling(data_type, BigIntType) + self.check_spelling(data_type, BigIntType) self._refuse_unsigned_integer(data_type, "BIGINT") return "BIGINT", () @@ -510,7 +556,7 @@ def format_data_type_smallint(self, data_type: SmallIntType) -> Tuple[str, tuple ``unsigned`` is refused rather than ignored; see :meth:`_refuse_unsigned_integer`.""" - self._check_spelling(data_type, SmallIntType) + self.check_spelling(data_type, SmallIntType) self._refuse_unsigned_integer(data_type, "SMALLINT") return "SMALLINT", () @@ -530,7 +576,7 @@ def format_data_type_tinyint(self, data_type: TinyIntType) -> Tuple[str, tuple]: declares it; see :meth:`_refuse_unsigned_tinyint`, which is where that reasoning is written down and where the class to use instead is named. """ - self._check_spelling(data_type, TinyIntType) + self.check_spelling(data_type, TinyIntType) self._refuse_unsigned_tinyint(data_type) return "TINYINT", () @@ -596,7 +642,7 @@ def format_data_type_double(self, data_type: DoubleType) -> Tuple[str, tuple]: both sides -- so there is no unsigned double-precision column on this server for the request to have become. """ - self._check_spelling(data_type, DoubleType) + self.check_spelling(data_type, DoubleType) self._refuse_unsigned_numeric(data_type, "DOUBLE PRECISION") return "FLOAT(53)", () @@ -785,7 +831,7 @@ class — so the distinction is kept: ``scale=2`` alone is a declaration still fires for a signed declaration. """ self._refuse_unsigned_numeric(data_type, "DECIMAL") - self._check_spelling(data_type, DecimalType) + self.check_spelling(data_type, DecimalType) self._check_decimal_scale_sign(data_type) if data_type.scale is not None and data_type.precision is None: raise UnsupportedFeatureError( @@ -826,7 +872,7 @@ class — so the distinction is kept: ``scale=2`` alone is a declaration def format_data_type_boolean(self, data_type: BooleanType) -> Tuple[str, tuple]: """``BIT`` for both spellings — T-SQL's own name for the boolean.""" - self._check_spelling(data_type, BooleanType) + self.check_spelling(data_type, BooleanType) return "BIT", () def format_data_type_varchar(self, data_type: VarCharType) -> Tuple[str, tuple]: @@ -848,7 +894,7 @@ def format_data_type_varchar(self, data_type: VarCharType) -> Tuple[str, tuple]: backend checks the intersection of that page's two statements about the lower bound rather than picking one of them. """ - self._check_spelling(data_type, VarCharType) + self.check_spelling(data_type, VarCharType) self._check_length("VARCHAR", data_type.length) length = data_type.length if length is None: @@ -869,7 +915,7 @@ def format_data_type_char(self, data_type: CharType) -> Tuple[str, tuple]: A declared length is range-checked exactly as :meth:`format_data_type_varchar` range-checks a ``VARCHAR`` one; see :meth:`_check_length`. """ - self._check_spelling(data_type, CharType) + self.check_spelling(data_type, CharType) self._check_length("CHAR", data_type.length) length = data_type.length if length is None: @@ -885,7 +931,7 @@ def format_data_type_text(self, data_type: TextType) -> Tuple[str, tuple]: enums and byte strings here, and ``clob`` names the same unbounded concept, so it is accepted rather than refused. """ - self._check_spelling(data_type, TextType) + self.check_spelling(data_type, TextType) return "NVARCHAR(MAX)", () def format_data_type_datetime(self, data_type: DateTimeType) -> Tuple[str, tuple]: @@ -985,7 +1031,7 @@ def format_data_type_blob(self, data_type: BlobType) -> Tuple[str, tuple]: unbounded byte storage — and is what an unbounded blob is on this dialect. """ - self._check_spelling(data_type, BlobType) + self.check_spelling(data_type, BlobType) return "VARBINARY(MAX)", () def format_data_type_custom(self, data_type: CustomType) -> Tuple[str, tuple]: @@ -1082,7 +1128,7 @@ def supports_data_type_timestamptz(self) -> bool: unconditional ``True`` would send the DDL and let the server answer, which is the failure mode a capability question exists to prevent. """ - return self.version >= (10, 0, 0) + return self.version_at_least((10, 0, 0)) def supports_data_type_json(self) -> bool: """Unconditional, because the storage this concept renders to has diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/uuid.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/uuid.py index 7fdc0bb..3d2cd44 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/uuid.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/uuid.py @@ -6,14 +6,6 @@ there is nothing version-dependent to resolve here. """ -from typing import Tuple, TYPE_CHECKING - -if TYPE_CHECKING: # pragma: no cover - from rhosocial.activerecord.backend.expression.uuid import ( - UUIDCastExpression, - UUIDGenerationExpression, - ) - class SQLServerUUIDMixin: """UUID value support for SQL Server. @@ -40,7 +32,7 @@ def supports_uuid_generation(self) -> bool: than returning a constant so the answer has one place to change if a future deprecation lands. """ - return self.version >= (9, 0, 0) + return self.version_at_least((9, 0, 0)) __all__ = ["SQLServerUUIDMixin"] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/window.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/window.py index 501340c..a892349 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/window.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/window.py @@ -11,4 +11,4 @@ def supports_window_functions(self) -> bool: def supports_window_frame_clause(self) -> bool: """Window frame clauses are supported since SQL Server 2012.""" - return self.version >= SQL_SERVER_2012 + return self.version_at_least(SQL_SERVER_2012) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/fulltext.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/fulltext.py index f04d8e7..f4dd4a6 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/fulltext.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/fulltext.py @@ -5,18 +5,18 @@ @runtime_checkable class SQLServerFullTextSearchSupport(Protocol): def supports_fulltext_catalog(self) -> bool: ... - def format_create_fulltext_catalog_statement( + def format_create_full_text_catalog_statement( self, catalog_name: str, as_default: bool = False ) -> Tuple[str, tuple]: ... - def format_drop_fulltext_catalog_statement(self, catalog_name: str) -> Tuple[str, tuple]: ... - def format_create_fulltext_index_statement( + def format_drop_full_text_catalog_statement(self, catalog_name: str) -> Tuple[str, tuple]: ... + def format_create_full_text_index_statement( self, table: str, columns: List[str], key_index: str, catalog_name: str, ) -> Tuple[str, tuple]: ... - def format_drop_fulltext_index_statement(self, table: str) -> Tuple[str, tuple]: ... + def format_drop_full_text_index_statement(self, table: str) -> Tuple[str, tuple]: ... def format_contains_predicate(self, column: str, search_string: str) -> Tuple[str, tuple]: ... def format_freetext_predicate(self, column: str, search_string: str) -> Tuple[str, tuple]: ... def format_containstable_function( diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/graph.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/graph.py index 96013a2..57efb57 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/graph.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/graph.py @@ -45,7 +45,7 @@ def supports_edge_constraints(self) -> bool: # region formatting - def format_sqlserver_graph_table_kind(self, expr: Any) -> Tuple[str, tuple]: + def format_as_graph_table_expression(self, expr: Any) -> Tuple[str, tuple]: """Format the ``AS NODE`` / ``AS EDGE`` table kind fragment.""" ... # pragma: no cover @@ -69,7 +69,7 @@ def format_sqlserver_match_predicate(self, predicate: Any) -> Tuple[str, tuple]: """Format a ``MATCH ()`` predicate.""" ... # pragma: no cover - def format_sqlserver_shortest_path(self, expr: Any) -> Tuple[str, tuple]: + def format_shortest_path_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a ``SHORTEST_PATH ({1,n})`` expression.""" ... # pragma: no cover diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/json.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/json.py index 1d03d00..e0b3d6c 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/json.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/json.py @@ -14,20 +14,20 @@ def format_openjson_expression( alias: Optional[str] = None, ) -> Tuple[str, tuple]: ... - def format_json_extract(self, expr) -> Tuple[str, tuple]: ... + def format_json_extract_expression(self, expr) -> Tuple[str, tuple]: ... - def format_json_unquote(self, expr) -> Tuple[str, tuple]: ... + def format_json_unquote_expression(self, expr) -> Tuple[str, tuple]: ... - def format_json_object(self, expr) -> Tuple[str, tuple]: ... + def format_json_object_expression(self, expr) -> Tuple[str, tuple]: ... - def format_json_array(self, expr) -> Tuple[str, tuple]: ... + def format_json_array_expression(self, expr) -> Tuple[str, tuple]: ... - def format_json_contains(self, expr) -> Tuple[str, tuple]: ... + def format_json_contains_expression(self, expr) -> Tuple[str, tuple]: ... - def format_json_set(self, expr) -> Tuple[str, tuple]: ... + def format_json_set_expression(self, expr) -> Tuple[str, tuple]: ... - def format_json_remove(self, expr) -> Tuple[str, tuple]: ... + def format_json_remove_expression(self, expr) -> Tuple[str, tuple]: ... - def format_json_type(self, expr) -> Tuple[str, tuple]: ... + def format_json_type_expression(self, expr) -> Tuple[str, tuple]: ... - def format_json_valid(self, expr) -> Tuple[str, tuple]: ... + def format_json_valid_expression(self, expr) -> Tuple[str, tuple]: ... diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/partition.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/partition.py index c4ab5f7..34f363d 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/partition.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/partition.py @@ -57,10 +57,10 @@ def supports_merge_partition(self) -> bool: """Whether ALTER PARTITION FUNCTION ... MERGE RANGE is supported.""" ... - def format_sqlserver_partition_function(self, expr: "SQLServerPartitionFunctionExpression") -> Tuple[str, tuple]: + def format_partition_function_expression(self, expr: "SQLServerPartitionFunctionExpression") -> Tuple[str, tuple]: """Format CREATE PARTITION FUNCTION statement.""" ... - def format_sqlserver_partition_scheme(self, expr: "SQLServerPartitionSchemeExpression") -> Tuple[str, tuple]: + def format_partition_scheme_expression(self, expr: "SQLServerPartitionSchemeExpression") -> Tuple[str, tuple]: """Format CREATE PARTITION SCHEME statement.""" ... diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/sequence.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/sequence.py index c775b5e..63c82b0 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/sequence.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/sequence.py @@ -8,6 +8,6 @@ @runtime_checkable class SQLServerSequenceSupport(Protocol): def supports_sequence_as_data_type(self) -> bool: ... - def format_next_value_for( + def format_next_value_for_expression( self, expr: "SQLServerNextValueForExpression" ) -> Tuple[str, tuple]: ... diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/set_type.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/set_type.py index e60143f..bdeebc2 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/set_type.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/set_type.py @@ -17,14 +17,14 @@ def supports_set_type(self) -> bool: """Whether the MySQL-style SET type is supported (always False).""" ... - def format_set_literal(self, expr) -> Tuple[str, tuple]: + def format_set_literal_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`SQLServerSetLiteralExpression` node.""" ... - def format_find_in_set(self, expr) -> Tuple[str, tuple]: + def format_find_in_set_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`SQLServerFindInSetExpression` node.""" ... - def format_set_contains(self, expr) -> Tuple[str, tuple]: + def format_set_contains_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`SQLServerSetContainsExpression` node.""" ... diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/spatial.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/spatial.py index 4072191..1c385f1 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/spatial.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols/spatial.py @@ -26,42 +26,42 @@ def supports_geojson(self) -> bool: """Whether STAsGeoJSON is available in the installed types assembly.""" ... - def format_spatial_literal(self, expr) -> Tuple[str, tuple]: + def format_spatial_literal_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`SQLServerSpatialLiteralExpression` node.""" ... - def format_st_geom_from_text(self, expr) -> Tuple[str, tuple]: + def format_st_geom_from_text_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`SQLServerSTGeomFromTextExpression` node.""" ... - def format_st_as_text(self, expr) -> Tuple[str, tuple]: + def format_st_as_text_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`SQLServerSTAsTextExpression` node.""" ... - def format_st_as_geojson(self, expr) -> Tuple[str, tuple]: + def format_st_as_geo_json_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`SQLServerSTAsGeoJSONExpression` node.""" ... - def format_st_distance(self, expr) -> Tuple[str, tuple]: + def format_st_distance_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`SQLServerSTDistanceExpression` node.""" ... - def format_st_within(self, expr) -> Tuple[str, tuple]: + def format_st_within_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`SQLServerSTWithinExpression` node.""" ... - def format_st_contains(self, expr) -> Tuple[str, tuple]: + def format_st_contains_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`SQLServerSTContainsExpression` node.""" ... - def format_st_distance_sphere(self, expr) -> Tuple[str, tuple]: + def format_st_distance_sphere_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`SQLServerSTDistanceSphereExpression` node.""" ... - def format_st_intersects(self, expr) -> Tuple[str, tuple]: + def format_st_intersects_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`SQLServerSTIntersectsExpression` node.""" ... - def format_create_spatial_index(self, expr) -> Tuple[str, tuple]: - """Format a :class:`SQLServerCreateSpatialIndexExpression` node.""" + def format_create_spatial_index_statement(self, expr) -> Tuple[str, tuple]: + """Format a :class:`SQLServerCreateSpatialIndexStatement` node.""" ... diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/query_nodes.py b/src/rhosocial/activerecord/backend/impl/sqlserver/query_nodes.py new file mode 100644 index 0000000..74d4f8c --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/query_nodes.py @@ -0,0 +1,478 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/query_nodes.py +"""Expression-node builders for the SQL Server backend's own queries. + +An introspection or probe query is a *question* about the catalogue, and this +backend used to spell it as text: f-strings, ``sql +=`` fragments and whole +``SELECT`` statements inline in the introspecter, the backend and its mixins. +A SQL string is a formatting artefact, so it belongs to the dialect formatter, +not to the layer that executes the query (org guide §16.2): the node collects +the parameters, the formatter writes the text, and the caller only constructs +and executes. + +Each helper below states one construction once -- a column reference, a value, +a catalogue relation, a conjunction, a projection into a SELECT -- so the +builders read as the questions they ask instead of as the grammar they happen +to be written in. They take the dialect as their first argument because both +the introspectors and the backend classes ask these questions, and those two +families do not share a base class. +""" + +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + from rhosocial.activerecord.backend.expression.bases import ( + BaseExpression, + SQLPredicate, + ) + from rhosocial.activerecord.backend.expression.sources.relation import ( + NamedRelationRef, + ) + + +def column( + dialect: "SQLDialectBase", + name: str, + table: Optional[str] = None, + alias: Optional[str] = None, + name_need_quote: bool = True, +): + """A column reference, qualified by its relation alias when given. + + ``name_need_quote=False`` is for the names that are grammar rather than + identifiers -- ``SYSTEM_USER`` is the niladic value function the session + query selects, and ``@@IDENTITY`` is a variable, so quoting either would + ask the catalogue for a column of that name instead. + """ + from rhosocial.activerecord.backend.expression import Column + + return Column( + dialect, name, table=table, alias=alias, name_need_quote=name_need_quote + ) + + +def literal(dialect: "SQLDialectBase", value: Any, inline: bool = False): + """A value in a predicate position. + + *inline* keeps the value out of the parameter tuple. Every position that + was written into the old statement text (``'BASE TABLE'``, + ``'PRIMARY KEY'``, ``'IsIdentity'``, a ``SERVERPROPERTY`` name) keeps it: + those are part of what the query asks, not data it carries, and the + callers' parameter tuples are a contract this refactor does not move. + """ + from rhosocial.activerecord.backend.expression import Literal + + return Literal(dialect, value, inline=inline) + + +def relation( + dialect: "SQLDialectBase", + name: str, + schema_name: Optional[str] = None, + alias: Optional[str] = None, +) -> "NamedRelationRef": + """A readable relation, optionally aliased.""" + from rhosocial.activerecord.backend.expression import NamedRelationRef + from rhosocial.activerecord.backend.expression.objects import Table + + return NamedRelationRef( + dialect, Table(dialect, name, schema_name=schema_name), alias=alias + ) + + +def join(dialect: "SQLDialectBase", left, right, condition=None, join_type: str = "JOIN"): + """Join two row sources on *condition*.""" + from rhosocial.activerecord.backend.expression import JoinClause + + return JoinClause(dialect, left, right, join_type=join_type, condition=condition) + + +def conjunction(dialect: "SQLDialectBase", *conditions): + """The conjunction of *conditions*, as one predicate node.""" + from rhosocial.activerecord.backend.expression import LogicalPredicate + + return LogicalPredicate(dialect, "AND", *conditions) + + +def compare(dialect: "SQLDialectBase", op: str, left, right): + """A comparison between two expression nodes.""" + from rhosocial.activerecord.backend.expression import ComparisonPredicate + + return ComparisonPredicate(dialect, op, left, right) + + +def is_not_null(dialect: "SQLDialectBase", operand): + """``operand IS NOT NULL``.""" + from rhosocial.activerecord.backend.expression import IsNullPredicate + + return IsNullPredicate(dialect, operand, is_not=True) + + +def contained_in(dialect: "SQLDialectBase", operand, values: Tuple[Any, ...]): + """``operand IN (...)``, with the values inline as the text had them.""" + from rhosocial.activerecord.backend.expression import InPredicate + + return InPredicate(dialect, operand, literal(dialect, values, inline=True)) + + +def call( + dialect: "SQLDialectBase", + name: str, + *args, + alias: Optional[str] = None, + niladic: bool = False, +): + """A function call node, optionally aliased.""" + from rhosocial.activerecord.backend.expression import FunctionCall + + return FunctionCall(dialect, name, *args, alias=alias, niladic=niladic) + + +def row_count(dialect: "SQLDialectBase"): + """The ``*`` that counts rows, as ``COUNT(*)`` takes it.""" + from rhosocial.activerecord.backend.expression import RowCountWildcard + + return RowCountWildcard(dialect) + + +def cast(dialect: "SQLDialectBase", node, type_name: str): + """``CAST(node AS type_name)``.""" + from rhosocial.activerecord.backend.expression.types._base import DataType + + return node.cast(DataType.parse_data_type_str(dialect, type_name)) + + +def multiply(dialect: "SQLDialectBase", left, right): + """``left * right`` as an arithmetic node.""" + from rhosocial.activerecord.backend.expression.operators import ( + ArithmeticOperation, + ArithmeticOperator, + ) + + return ArithmeticOperation(dialect, ArithmeticOperator.MUL, left, right) + + +def add(dialect: "SQLDialectBase", left, right): + """``left + right`` as an arithmetic node. + + SQL Server spells string concatenation with ``+`` (``||`` only from 2025), + so joining two columns into one name is an arithmetic ``add`` here rather + than the standard concatenation node. + """ + from rhosocial.activerecord.backend.expression.operators import ( + ArithmeticOperation, + ArithmeticOperator, + ) + + return ArithmeticOperation(dialect, ArithmeticOperator.ADD, left, right) + + +def query( + dialect: "SQLDialectBase", + select: List["BaseExpression"], + from_=None, + where: Optional["SQLPredicate"] = None, + group_by: Optional[List["BaseExpression"]] = None, + order_by: Optional[list] = None, + limit: Optional[int] = None, +): + """A ``QueryStatement`` node, ready to render or to nest. + + Args: + dialect: The dialect the node is bound to. + select: The projection, in order. + from_: A relation or join node, or ``None`` for a query that asks the + engine a question and reads no table. + where: A predicate node, or ``None`` for no WHERE clause. + group_by: Grouping expressions, or ``None``. + order_by: ``(expression, direction)`` pairs, or ``None``. + limit: A row cap, or ``None``. This is the pagination node, which SQL + Server renders as ``OFFSET 0 ROWS FETCH NEXT n ROWS ONLY`` and + therefore requires 2012+ and an ORDER BY. + + Returns: + The ``QueryStatement`` node. + """ + from rhosocial.activerecord.backend.expression import ( + GroupByHavingClause, + LimitOffsetClause, + OrderByClause, + QueryStatement, + WhereClause, + ) + + kwargs: Dict[str, Any] = {"select": select} + if from_ is not None: + kwargs["from_"] = from_ + if where is not None: + kwargs["where"] = WhereClause(dialect, where) + if group_by is not None: + kwargs["group_by_having"] = GroupByHavingClause(dialect, group_by=group_by) + if order_by is not None: + kwargs["order_by"] = OrderByClause(dialect, order_by) + if limit is not None: + kwargs["limit_offset"] = LimitOffsetClause(dialect, limit=limit) + return QueryStatement(dialect, **kwargs) + + +def subquery( + dialect: "SQLDialectBase", + select: List["BaseExpression"], + from_=None, + where: Optional["SQLPredicate"] = None, + alias: Optional[str] = None, +): + """A scalar subquery for a select list.""" + from rhosocial.activerecord.backend.expression import Subquery + + return Subquery( + dialect, query(dialect, select, from_=from_, where=where), alias=alias + ) + + +def select_sql( + dialect: "SQLDialectBase", + select: List["BaseExpression"], + from_=None, + where: Optional["SQLPredicate"] = None, + group_by: Optional[List["BaseExpression"]] = None, + order_by: Optional[list] = None, + limit: Optional[int] = None, +) -> Tuple[str, tuple]: + """Render one SELECT from nodes, in the dialect's own spelling.""" + return query( + dialect, + select, + from_=from_, + where=where, + group_by=group_by, + order_by=order_by, + limit=limit, + ).to_sql() + + +def server_property(dialect: "SQLDialectBase", name: str, alias: Optional[str] = None): + """``SERVERPROPERTY('')``, optionally aliased. + + The property name is a literal of the statement, not data, so it stays + inline: the request carries no parameters. + """ + node = call(dialect, "SERVERPROPERTY", literal(dialect, name, inline=True)) + return node.as_(alias) if alias else node + + +def identity_columns_sql(dialect: "SQLDialectBase", table_name: str) -> Tuple[str, tuple]: + """The identity column names of one table in the default schema. + + Returned as ``(sql, params)`` because the table name is a bind position: + it is data the query carries, not part of what it asks. + """ + joined = join( + dialect, + relation(dialect, "identity_columns", "sys", alias="c"), + relation(dialect, "tables", "sys", alias="t"), + condition=compare( + dialect, + "eq", + column(dialect, "object_id", "c"), + column(dialect, "object_id", "t"), + ), + ) + return select_sql( + dialect, + [column(dialect, "name", "c")], + from_=joined, + where=conjunction( + dialect, + compare( + dialect, "eq", column(dialect, "name", "t"), literal(dialect, table_name) + ), + compare( + dialect, + "eq", + call(dialect, "SCHEMA_NAME", column(dialect, "schema_id", "t")), + literal(dialect, "dbo", inline=True), + ), + ), + ) + + +def max_worker_threads_sql(dialect: "SQLDialectBase") -> Tuple[str, tuple]: + """The configured ``max worker threads`` server setting.""" + return select_sql( + dialect, + [column(dialect, "value_in_use")], + from_=relation(dialect, "configurations", "sys"), + where=compare( + dialect, + "eq", + column(dialect, "name"), + literal(dialect, "max worker threads", inline=True), + ), + ) + + +def server_property_value_sql( + dialect: "SQLDialectBase", name: str +) -> Tuple[str, tuple]: + """One ``SERVERPROPERTY`` value, uncast. + + The property name is part of what the query asks, so it stays inline and + the request carries no parameters -- which is what the raw driver call + this replaces did. + """ + return select_sql(dialect, [server_property(dialect, name)]) + + +def session_identity_sql(dialect: "SQLDialectBase") -> Tuple[str, tuple]: + """The ``@@IDENTITY`` variable: the last identity value this session made.""" + return select_sql(dialect, [column(dialect, "@@IDENTITY", name_need_quote=False)]) + + +def scope_identity_value_sql(dialect: "SQLDialectBase") -> Tuple[str, tuple]: + """Alias kept for readability at the call sites that want the variable.""" + return session_identity_sql(dialect) + + +def identity_current_sql(dialect: "SQLDialectBase", table: str) -> Tuple[str, tuple]: + """``IDENT_CURRENT(
)``; the table name is a bind position.""" + return select_sql(dialect, [call(dialect, "IDENT_CURRENT", literal(dialect, table))]) + + +def health_check_sql(dialect: "SQLDialectBase") -> Tuple[str, tuple]: + """The cheapest statement that proves the connection answers.""" + return select_sql(dialect, [literal(dialect, 1, inline=True)]) + + +class SQLServerNodeBuilderMixin: + """The node builders above, bound to the object that owns the spelling. + + Two families ask these questions. The introspectors reach their dialect + through the core introspector mixin; the dialect itself is the spelling. + They share no base class, so the binding is asked for once -- here, under + :attr:`_node_dialect` -- and every builder below reads it from there. + """ + + #: The dialect an introspector reaches through core's introspector mixin. + #: A dialect that uses these builders overrides ``_node_dialect`` instead. + dialect: "SQLDialectBase" + + @property + def _node_dialect(self) -> "SQLDialectBase": + """The dialect every node built here is bound to.""" + return self.dialect + + def _col(self, name: str, table: Optional[str] = None, alias: Optional[str] = None): + """A column reference, qualified by its relation alias when given.""" + return column(self._node_dialect, name, table=table, alias=alias) + + def _lit(self, value: Any, inline: bool = False): + """A value in a predicate position; *inline* keeps it out of params.""" + return literal(self._node_dialect, value, inline=inline) + + def _relation( + self, name: str, schema_name: Optional[str] = None, alias: Optional[str] = None + ) -> "NamedRelationRef": + """A readable relation, optionally aliased.""" + return relation(self._node_dialect, name, schema_name=schema_name, alias=alias) + + def _join(self, left, right, condition=None, join_type: str = "JOIN"): + """Join two row sources on *condition*.""" + return join(self._node_dialect, left, right, condition=condition, join_type=join_type) + + def _and(self, *conditions): + """The conjunction of *conditions*, as one predicate node.""" + return conjunction(self._node_dialect, *conditions) + + def _eq(self, left, right): + """Equality between two expression nodes.""" + return compare(self._node_dialect, "eq", left, right) + + def _gt(self, left, right): + """``left > right`` between two expression nodes.""" + return compare(self._node_dialect, "gt", left, right) + + def _in(self, operand, values: Tuple[Any, ...]): + """``operand IN (...)``, with the values inline as the text had them.""" + return contained_in(self._node_dialect, operand, values) + + def _is_not_null(self, operand): + """``operand IS NOT NULL``.""" + return is_not_null(self._node_dialect, operand) + + def _fn(self, name: str, *args, alias: Optional[str] = None, niladic: bool = False): + """A function call node, optionally aliased.""" + return call(self._node_dialect, name, *args, alias=alias, niladic=niladic) + + def _cast(self, node, type_name: str): + """``CAST(node AS type_name)``.""" + return cast(self._node_dialect, node, type_name) + + def _mul(self, left, right): + """``left * right`` as an arithmetic node.""" + return multiply(self._node_dialect, left, right) + + def _add(self, left, right): + """``left + right`` as an arithmetic node (SQL Server's concatenation).""" + return add(self._node_dialect, left, right) + + def _row_count_wildcard(self): + """The ``*`` that counts rows, as ``COUNT(*)`` takes it.""" + return row_count(self._node_dialect) + + def _subquery(self, select, from_=None, where=None, alias=None): + """A scalar subquery in a select list.""" + return subquery(self._node_dialect, select, from_=from_, where=where, alias=alias) + + def _query(self, select, from_=None, where=None, group_by=None, order_by=None, limit=None): + """A ``QueryStatement`` node, ready to render or to nest.""" + return query( + self._node_dialect, + select, + from_=from_, + where=where, + group_by=group_by, + order_by=order_by, + limit=limit, + ) + + def _select(self, select, from_=None, where=None, group_by=None, order_by=None, limit=None): + """Render one SELECT from nodes, in the dialect's own spelling.""" + return select_sql( + self._node_dialect, + select, + from_=from_, + where=where, + group_by=group_by, + order_by=order_by, + limit=limit, + ) + + +__all__ = [ + "SQLServerNodeBuilderMixin", + "add", + "call", + "cast", + "column", + "compare", + "conjunction", + "contained_in", + "health_check_sql", + "identity_columns_sql", + "identity_current_sql", + "is_not_null", + "join", + "literal", + "max_worker_threads_sql", + "multiply", + "query", + "relation", + "row_count", + "scope_identity_value_sql", + "select_sql", + "server_property", + "server_property_value_sql", + "session_identity_sql", + "subquery", +] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/show/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/show/__init__.py index de1dc7d..0c1dbf0 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/show/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/show/__init__.py @@ -1,10 +1,14 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/show/__init__.py """ -SQL Server SHOW functionality module. +SQL Server SHOW compatibility module. -SQL Server has no ``SHOW ...`` statements. This package provides the -SQL Server equivalents by querying system catalogs and parsing results -into the same typed dataclasses used across backend implementations. +SQL Server has no ``SHOW ...`` statement, so this package carries no SQL at +all: it is the *inbound* half of the MySQL vocabulary -- it reads result rows +whose column names follow MySQL's SHOW spellings and turns them into the typed +result dataclasses the shared ``show()`` API returns. There is nothing here to +route through an expression node, because nothing here builds a statement; +whoever needs the rows asks the catalogue through nodes +(``..query_nodes``) and hands the result to these parsers. Usage: from rhosocial.activerecord.backend.impl.sqlserver.show.functionality import ( @@ -12,6 +16,7 @@ ) show = SQLServerShowFunctionality(backend) + columns = show._parse_columns_result(result) """ from .functionality import SQLServerShowFunctionality diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/show/functionality.py b/src/rhosocial/activerecord/backend/impl/sqlserver/show/functionality.py index 5b1fcaf..ef4b73d 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/show/functionality.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/show/functionality.py @@ -1,13 +1,20 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/show/functionality.py """ -SQL Server SHOW functionality implementation. - -SQL Server has no ``SHOW ...`` statements. This module provides -SQL Server-specific equivalents by querying the system catalogs -(``sys.tables``, ``sys.columns``, ``sys.indexes``, ``sys.triggers``, -``sys.databases``, ``sys.configurations``, ``sys.dm_exec_sessions``) and -parsing the results into the same typed result dataclasses used by the -MySQL ``SHOW`` implementation, keeping the ``show()`` backend API uniform. +SQL Server SHOW compatibility layer. + +SQL Server has no ``SHOW ...`` statements, so this module carries only the +*inbound* half of the MySQL vocabulary: it parses result sets whose column +names follow MySQL's SHOW spellings into the typed result dataclasses the +shared ``show()`` API returns, so a caller written against MySQL's vocabulary +can read SQL Server's catalogue. + +It deliberately builds no SQL. The catalogue questions live in +``..query_nodes`` as expression nodes and are rendered by the dialect (org +guide §16.2), because a query written here would be a second place where +SQL Server's SQL is spelled, and a catalogue read written as text is exactly +what this wave removes. The translation this layer does own is the row shape: +MySQL names a result column ``Field``, SQL Server's catalogue names the same +value ``COLUMN_NAME``, and every parser below accepts either. """ from typing import Optional, Tuple, TYPE_CHECKING @@ -17,10 +24,11 @@ class SQLServerShowFunctionality: - """SQL Server SHOW functionality implementation. + """Parses SHOW-shaped result sets into typed dataclasses. - Parses result sets from SQL Server catalog queries into typed - dataclasses. Supports version-aware feature detection. + MySQL-compatible row names and SQL Server catalogue row names are both + accepted, so the same parser reads either source. Supports version-aware + feature detection. """ def __init__(self, backend: "SQLServerBackend", version: Optional[Tuple[int, ...]] = None): diff --git a/tests/plugins/expression_xdist.py b/tests/plugins/expression_xdist.py new file mode 100644 index 0000000..67a54ee --- /dev/null +++ b/tests/plugins/expression_xdist.py @@ -0,0 +1,145 @@ +# tests/plugins/expression_xdist.py +"""Default the expression test area to parallel when it is the only target. + +The org rules (expression-dialect §12) make pytest-xdist parallel the default +configuration of the expression test directory. The worker count, however, is +read by pytest-xdist before any conftest loads -- a conftest cannot inject +``-n`` -- so the default lives in this repository-local plugin, registered +from ``pytest.ini`` with ``-p plugins.expression_xdist`` and therefore active +for every run in this repository. ``pytest.ini`` also sets +``pythonpath = tests``, and that ini value is what makes the plugin findable by +name at all: pytest applies ``pythonpath`` just before it imports the ``-p`` +plugins, so the registration no longer depends on ``PYTHONPATH=src:tests`` +being set by whoever ran pytest. + +The plugin is deliberately narrow in what it touches: + +* **Availability**: with pytest-xdist not installed it enables nothing -- + installation is not this plugin's business, and the run proceeds serially the + way it always has. +* **Scope**: only when every non-option argument names a path inside the + expression area (``feature/backend/expression``) is ``-n auto`` appended. A + whole-repository run, a mixed run, or a run of any other directory keeps the + serial default; the live scenario tests have their own parallel runner + (``--scenario-parallel``) and are not dragged into a worker pool they did not + ask for. +* **Choice respected**: an explicit ``-n`` / ``--numprocesses`` is never + overridden. An explicit ``--dist`` is never overridden either, except that + ``loadscope`` and ``loadfile`` are refused outright (§12: they bind whole + modules together and hide case-to-case dependencies). ``-n`` without + ``--dist`` gets ``--dist=loadgroup`` appended, because loadgroup is the one + scheduling the rules allow. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +#: The area this plugin defaults to parallel. Relative to the repository root, +#: which is where ``pyproject.toml`` -- and therefore this plugin's +#: registration -- lives. +_EXPRESSION_AREA = os.path.join( + "tests", + "rhosocial", + "activerecord_sqlserver_test", + "feature", + "backend", + "expression", +) + +#: Options that take a separate value, so the value must not be mistaken for a +#: path argument when scoping the invocation. ``-p`` matters most: this plugin +#: is itself registered as ``-p plugins.expression_xdist``, and its own name +#: must not be read as a test target. +_VALUE_TAKING = frozenset( + { + "-p", "-k", "-m", "-o", "-r", "-W", "--tb", "--deselect", "--basetemp", + "--maxfail", "--rootdir", "--import-mode", "--pdbcls", "--lf", + "--lfnf", "--cov", "--cov-config", "--cov-report", + "--capture", "--color", "--junitxml", "--result-log", + "-n", "--numprocesses", "--dist", + } +) + +_FORBIDDEN_DISTS = ("loadscope", "loadfile") + + +def _looks_like_path(arg: str) -> bool: + """Whether *arg* names a test target rather than an option or a value.""" + return not arg.startswith("-") + + +def _expression_area_root() -> Path: + """The absolute expression-area directory for this repository. + + This module is imported from ``tests/plugins``, two levels below the + repository root. + """ + return Path(__file__).resolve().parent.parent.parent / _EXPRESSION_AREA + + +def _inside_expression_area(arg: str) -> bool: + path = Path(os.path.abspath(arg)) + return path == _expression_area_root() or _expression_area_root() in path.parents + + +def pytest_load_initial_conftests(args, early_config, parser): + """Append the parallel default when the run is expression-only.""" + if not _xdist_available(): + print( + "[expression_xdist] pytest-xdist is not installed; the expression " + "area runs serially (install it to get the parallel default the " + "test rules ask for)" + ) + return + + targets = [] + skip_next = False + for arg in args: + if skip_next: + skip_next = False + continue + if arg in _VALUE_TAKING: + skip_next = True + continue + if arg.startswith("--") and "=" in arg: + name = arg.split("=", 1)[0] + if name in _VALUE_TAKING: + continue + continue + if _looks_like_path(arg): + targets.append(arg) + + if not targets or not all(_inside_expression_area(t) for t in targets): + return + + def _option_value(flag: str): + for index, arg in enumerate(args): + if arg == flag and index + 1 < len(args): + return args[index + 1] + if arg.startswith(flag + "="): + return arg.split("=", 1)[1] + return None + + if _option_value("--dist") in _FORBIDDEN_DISTS: + raise SystemExit( + "[expression_xdist] --dist=loadscope/loadfile are not allowed for " + "the expression area (expression-dialect §12: they bind whole " + "modules together; use --dist=loadgroup)" + ) + + if _option_value("-n") is None and _option_value("--numprocesses") is None: + args.extend(["-n", "auto"]) + if _option_value("--dist") is None: + args.extend(["--dist", "loadgroup"]) + + +def _xdist_available() -> bool: + """The availability condition: the plugin enables nothing without xdist, + and installing it is nobody's business but the environment's.""" + try: + import xdist # noqa: F401 + except ImportError: + return False + return True diff --git a/tests/providers/basic.py b/tests/providers/basic.py index f2040fd..c343df9 100644 --- a/tests/providers/basic.py +++ b/tests/providers/basic.py @@ -384,7 +384,7 @@ def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, tabl def _reset_table_sync(self, model_class: Type[ActiveRecord], table_name: str) -> None: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from rhosocial.activerecord.backend.expression.objects import Table from providers.fixtures.basic import TABLE_EXPRESSIONS from providers.fixtures._common import to_sqlserver_ddl_sql @@ -579,7 +579,7 @@ async def _setup_async_model( async def _reset_table_async(self, model_class: Type[ActiveRecord], table_name: str) -> None: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from rhosocial.activerecord.backend.expression.objects import Table from providers.fixtures.basic import TABLE_EXPRESSIONS from providers.fixtures._common import to_sqlserver_ddl_sql diff --git a/tests/providers/events.py b/tests/providers/events.py index 4a349a8..26cdaa4 100644 --- a/tests/providers/events.py +++ b/tests/providers/events.py @@ -110,7 +110,7 @@ def __init__(self): def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str) -> Type[ActiveRecord]: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from rhosocial.activerecord.backend.expression.objects import Table from providers.fixtures.events import TABLE_EXPRESSIONS from providers.fixtures._common import to_sqlserver_ddl_sql @@ -162,7 +162,7 @@ async def _setup_async_model( from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from rhosocial.activerecord.backend.expression.objects import Table from providers.fixtures.events import TABLE_EXPRESSIONS from providers.fixtures._common import to_sqlserver_ddl_sql diff --git a/tests/providers/fixtures/__init__.py b/tests/providers/fixtures/__init__.py index dabe722..0f70dba 100644 --- a/tests/providers/fixtures/__init__.py +++ b/tests/providers/fixtures/__init__.py @@ -2,9 +2,9 @@ """SQL Server-specific DDL expression fixtures for the testsuite providers. Each module in this package exposes a ``TABLE_EXPRESSIONS`` mapping of -table name -> factory callable ``Callable[[DialectLike, str], CreateTableExpression]``. +table name -> factory callable ``Callable[[DialectLike, str], CreateTableStatement]``. -The factory functions build :class:`CreateTableExpression` instances that emit +The factory functions build :class:`CreateTableStatement` instances that emit SQL Server-compatible DDL (INT IDENTITY / NVARCHAR / DATETIME2 / TINYINT / JSON). The pre-existing ``.sql`` schema files under ``tests/rhosocial/activerecord_sqlserver_test/feature//schema/`` diff --git a/tests/providers/fixtures/_common.py b/tests/providers/fixtures/_common.py index a814fe0..1bc1595 100644 --- a/tests/providers/fixtures/_common.py +++ b/tests/providers/fixtures/_common.py @@ -6,16 +6,16 @@ from typing import Tuple from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, + CreateTableStatement, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType -def to_sqlserver_ddl_sql(expr: CreateTableExpression) -> Tuple[str, tuple]: - """Generate canonical SQL Server DDL for a :class:`CreateTableExpression`. +def to_sqlserver_ddl_sql(expr: CreateTableStatement) -> Tuple[str, tuple]: + """Generate canonical SQL Server DDL for a :class:`CreateTableStatement`. No post-processing is required (unlike MySQL where storage option quotes must be stripped). This pass-through wrapper exists so the call site is @@ -24,14 +24,14 @@ def to_sqlserver_ddl_sql(expr: CreateTableExpression) -> Tuple[str, tuple]: return expr.to_sql() -def create_table_sql(expr: CreateTableExpression) -> Tuple[str, tuple]: +def create_table_sql(expr: CreateTableStatement) -> Tuple[str, tuple]: """Public alias for :func:`to_sqlserver_ddl_sql`.""" return to_sqlserver_ddl_sql(expr) -def drop_table(dialect, table_name: str) -> DropTableExpression: +def drop_table(dialect, table_name: str) -> DropTableStatement: """Build a ``DROP TABLE IF EXISTS table_name`` expression.""" - return DropTableExpression( + return DropTableStatement( dialect=dialect, table=Table(dialect, table_name), if_exists=True, @@ -105,7 +105,7 @@ def safe_drop_table(backend, dialect, table_name: str) -> None: _drop_fk_constraints_for_table(backend, table_name) options = ExecutionOptions(stmt_type=StatementType.DDL) try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=dialect, table=Table(dialect, table_name), if_exists=True, @@ -120,7 +120,7 @@ async def safe_drop_table_async(backend, dialect, table_name: str) -> None: await _drop_fk_constraints_for_table_async(backend, table_name) options = ExecutionOptions(stmt_type=StatementType.DDL) try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=dialect, table=Table(dialect, table_name), if_exists=True, diff --git a/tests/providers/fixtures/basic.py b/tests/providers/fixtures/basic.py index 9ebde4c..3dca45f 100644 --- a/tests/providers/fixtures/basic.py +++ b/tests/providers/fixtures/basic.py @@ -1,7 +1,7 @@ # tests/providers/fixtures/basic.py """DDL expressions for the ``feature/basic`` table group (SQL Server). -Each factory builds a :class:`CreateTableExpression` whose generated T-SQL +Each factory builds a :class:`CreateTableStatement` whose generated T-SQL DDL is semantically equivalent to the reference ``.sql`` schema files under ``tests/rhosocial/activerecord_sqlserver_test/feature/basic/schema/``. """ @@ -9,17 +9,20 @@ from typing import Callable, Dict from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, + CreateTableStatement, ) from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, ForeignKeyConstraint, IndexDefinition, TableConstraint, - TableConstraintType, + TablePrimaryKeyConstraint, ReferentialAction, ) from rhosocial.activerecord.backend.expression.types import ( @@ -45,51 +48,56 @@ _CASCADE = ReferentialAction.CASCADE -def to_sql(expr: CreateTableExpression): - """Route a CreateTableExpression through the canonical SQL Server DDL pass-through.""" +def to_sql(expr: CreateTableStatement): + """Route a CreateTableStatement through the canonical SQL Server DDL pass-through.""" return _common.to_sqlserver_ddl_sql(expr) -def create_users_table(dialect, table_name: str = "users") -> CreateTableExpression: - return CreateTableExpression( +def create_users_table(dialect, table_name: str = "users") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ]), ColumnDefinition(dialect, "username", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.UNIQUE)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect))]), ColumnDefinition(dialect, "email", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.UNIQUE)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect))]), ColumnDefinition(dialect, "age", IntegerType(dialect)), ColumnDefinition(dialect, "balance", FloatType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0))]), ColumnDefinition(dialect, "is_active", BooleanType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "created_at", TextType(dialect)), ColumnDefinition(dialect, "updated_at", TextType(dialect)), ], ) -def create_type_cases_table(dialect, table_name: str = "type_cases") -> CreateTableExpression: - return CreateTableExpression( +def create_type_cases_table(dialect, table_name: str = "type_cases") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", CharType(dialect, length=36), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "username", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "email", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "tiny_int", SmallIntType(dialect)), ColumnDefinition(dialect, "small_int", SmallIntType(dialect)), ColumnDefinition(dialect, "big_int", BigIntType(dialect)), @@ -110,76 +118,86 @@ def create_type_cases_table(dialect, table_name: str = "type_cases") -> CreateTa ) -def create_type_tests_table(dialect, table_name: str = "type_tests") -> CreateTableExpression: - return CreateTableExpression( +def create_type_tests_table(dialect, table_name: str = "type_tests") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", CharType(dialect, length=36), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "string_field", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="test string")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "test string"))]), ColumnDefinition(dialect, "int_field", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=42)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 42))]), ColumnDefinition(dialect, "float_field", FloatType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=3.14)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 3.14))]), ColumnDefinition(dialect, "decimal_field", DoubleType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=10.99)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 10.99))]), ColumnDefinition(dialect, "bool_field", BooleanType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "datetime_field", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "json_field", JsonType(dialect)), ColumnDefinition(dialect, "nullable_field", VarCharType(dialect, length=255)), ], ) -def create_validated_field_users_table(dialect, table_name: str = "validated_field_users") -> CreateTableExpression: - return CreateTableExpression( +def create_validated_field_users_table(dialect, table_name: str = "validated_field_users") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ]), ColumnDefinition(dialect, "username", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "email", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "age", IntegerType(dialect)), ColumnDefinition(dialect, "balance", DecimalType(dialect, precision=10, scale=2)), ColumnDefinition(dialect, "credit_score", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "status", VarCharType(dialect, length=50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="active")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "active"))]), ColumnDefinition(dialect, "is_active", BooleanType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ], ) -def create_validated_users_table(dialect, table_name: str = "validated_users") -> CreateTableExpression: - return CreateTableExpression( +def create_validated_users_table(dialect, table_name: str = "validated_users") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ]), ColumnDefinition(dialect, "username", VarCharType(dialect, length=50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "email", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "age", IntegerType(dialect)), ], ) @@ -187,14 +205,19 @@ def create_validated_users_table(dialect, table_name: str = "validated_users") - def create_pydantic_validated_models_table( dialect, table_name: str = "pydantic_validated_models" -) -> CreateTableExpression: - return CreateTableExpression( +) -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ]), ColumnDefinition(dialect, "code", VarCharType(dialect, length=32)), ColumnDefinition(dialect, "quantity", IntegerType(dialect)), ColumnDefinition(dialect, "step_count", IntegerType(dialect)), @@ -208,40 +231,50 @@ def create_pydantic_validated_models_table( ) -def create_bulk_users_table(dialect, table_name: str = "bulk_users") -> CreateTableExpression: - return CreateTableExpression( +def create_bulk_users_table(dialect, table_name: str = "bulk_users") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ]), ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "age", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ColumnDefinition(dialect, "email", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="")]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, ""))]), ], ) -def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpression: - return CreateTableExpression( +def create_posts_table(dialect, table_name: str = "posts") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ]), ColumnDefinition(dialect, "author", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "title", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "content", TextType(dialect)), ColumnDefinition(dialect, "published_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "published", BooleanType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], @@ -258,25 +291,30 @@ def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpress ) -def create_comments_table(dialect, table_name: str = "comments") -> CreateTableExpression: - return CreateTableExpression( +def create_comments_table(dialect, table_name: str = "comments") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ]), ColumnDefinition(dialect, "post_ref", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "author", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "text", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "approved", BooleanType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ], indexes=[ IndexDefinition(dialect, name="idx_post_ref", columns=["post_ref"]), @@ -303,18 +341,23 @@ def create_comments_table(dialect, table_name: str = "comments") -> CreateTableE def create_column_mapping_items_table( dialect, table_name: str = "column_mapping_items" -) -> CreateTableExpression: - return CreateTableExpression( +) -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ]), ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "item_total", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "remarks", IntegerType(dialect)), ], ) @@ -322,16 +365,21 @@ def create_column_mapping_items_table( def create_mixed_annotation_items_table( dialect, table_name: str = "mixed_annotation_items" -) -> CreateTableExpression: - return CreateTableExpression( +) -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ]), ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "tags", TextType(dialect)), ColumnDefinition(dialect, "meta", TextType(dialect)), ColumnDefinition(dialect, "description", TextType(dialect)), @@ -342,16 +390,21 @@ def create_mixed_annotation_items_table( def create_type_adapter_tests_table( dialect, table_name: str = "type_adapter_tests" -) -> CreateTableExpression: - return CreateTableExpression( +) -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=True, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ]), ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "optional_name", VarCharType(dialect, length=255)), ColumnDefinition(dialect, "optional_age", IntegerType(dialect)), ColumnDefinition(dialect, "last_login", TextType(dialect)), @@ -365,82 +418,90 @@ def create_type_adapter_tests_table( def create_composite_pk_order_items_table( dialect, table_name: str = "order_items" -) -> CreateTableExpression: - return CreateTableExpression( +) -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "order_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "product_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "quantity", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "unit_price", DecimalType(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ], table_constraints=[ - TableConstraint(dialect, constraint_type=TableConstraintType.PRIMARY_KEY, - columns=["order_id", "product_id"]), + TableConstraint(dialect, branch=TablePrimaryKeyConstraint(dialect, ["order_id", "product_id"])), ], ) -def create_store_inventory_table(dialect, table_name: str = "store_inventory") -> CreateTableExpression: - return CreateTableExpression( +def create_store_inventory_table(dialect, table_name: str = "store_inventory") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "store_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "product_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "batch_id", VarCharType(dialect, length=64), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "stock", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ], table_constraints=[ - TableConstraint(dialect, constraint_type=TableConstraintType.PRIMARY_KEY, - columns=["store_id", "product_id", "batch_id"]), + TableConstraint(dialect, branch=TablePrimaryKeyConstraint(dialect, ["store_id", "product_id", "batch_id"])), ], ) -def create_orders_table(dialect, table_name: str = "orders") -> CreateTableExpression: - return CreateTableExpression( +def create_orders_table(dialect, table_name: str = "orders") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ]), ColumnDefinition(dialect, "total", DecimalType(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "created_at", TextType(dialect)), ColumnDefinition(dialect, "updated_at", TextType(dialect)), ], ) -def create_product_table(dialect, table_name: str = "product") -> CreateTableExpression: - return CreateTableExpression( +def create_product_table(dialect, table_name: str = "product") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=True, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ]), ColumnDefinition(dialect, "name", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "price", FloatType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "quantity", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ], ) diff --git a/tests/providers/fixtures/events.py b/tests/providers/fixtures/events.py index e6da57f..16ff4d5 100644 --- a/tests/providers/fixtures/events.py +++ b/tests/providers/fixtures/events.py @@ -7,13 +7,16 @@ from typing import Callable, Dict from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, + CreateTableStatement, ) from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnNotNullConstraint, + ColumnNullableConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import ( DateTimeType, @@ -25,26 +28,31 @@ from . import _common -def to_sql(expr: CreateTableExpression): +def to_sql(expr: CreateTableStatement): return _common.to_sqlserver_ddl_sql(expr) -def create_event_tests_table(dialect, table_name: str = "event_tests") -> CreateTableExpression: - return CreateTableExpression( +def create_event_tests_table(dialect, table_name: str = "event_tests") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ]), ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "status", VarCharType(dialect, length=50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="draft")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "draft"))]), ColumnDefinition(dialect, "revision", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "content", TextType(dialect)), ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), @@ -54,23 +62,28 @@ def create_event_tests_table(dialect, table_name: str = "event_tests") -> Create def create_event_tracking_models_table( dialect, table_name: str = "event_tracking_models" -) -> CreateTableExpression: - return CreateTableExpression( +) -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ]), ColumnDefinition(dialect, "title", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "content", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "view_count", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ColumnDefinition(dialect, "last_viewed_at", DateTimeType(dialect, precision=6), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNullableConstraint(dialect))]), ], ) diff --git a/tests/providers/fixtures/mixins.py b/tests/providers/fixtures/mixins.py index 2778621..e6ccd99 100644 --- a/tests/providers/fixtures/mixins.py +++ b/tests/providers/fixtures/mixins.py @@ -7,13 +7,16 @@ from typing import Callable, Dict from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, + CreateTableStatement, ) from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnNotNullConstraint, + ColumnNullableConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import ( BooleanType, @@ -27,89 +30,109 @@ from . import _common -def to_sql(expr: CreateTableExpression): +def to_sql(expr: CreateTableStatement): return _common.to_sqlserver_ddl_sql(expr) -def create_combined_articles_table(dialect, table_name: str = "combined_articles") -> CreateTableExpression: - return CreateTableExpression( +def create_combined_articles_table(dialect, table_name: str = "combined_articles") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ]), ColumnDefinition(dialect, "title", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "content", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "status", VarCharType(dialect, length=50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="draft")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "draft"))]), ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "version", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "deleted_at", DateTimeType(dialect, precision=6), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNullableConstraint(dialect))]), ], ) -def create_tasks_table(dialect, table_name: str = "tasks") -> CreateTableExpression: - return CreateTableExpression( +def create_tasks_table(dialect, table_name: str = "tasks") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ]), ColumnDefinition(dialect, "title", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "is_completed", BooleanType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ColumnDefinition(dialect, "deleted_at", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNullableConstraint(dialect))]), ], ) -def create_timestamped_posts_table(dialect, table_name: str = "timestamped_posts") -> CreateTableExpression: - return CreateTableExpression( +def create_timestamped_posts_table(dialect, table_name: str = "timestamped_posts") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ]), ColumnDefinition(dialect, "title", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "content", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], ) -def create_versioned_products_table(dialect, table_name: str = "versioned_products") -> CreateTableExpression: - return CreateTableExpression( +def create_versioned_products_table(dialect, table_name: str = "versioned_products") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ]), ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "price", DecimalType(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0))]), ColumnDefinition(dialect, "version", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ], ) diff --git a/tests/providers/fixtures/query.py b/tests/providers/fixtures/query.py index e2b26da..73f2484 100644 --- a/tests/providers/fixtures/query.py +++ b/tests/providers/fixtures/query.py @@ -1,7 +1,7 @@ # tests/providers/fixtures/query.py """DDL expressions for the ``feature/query`` table group (SQL Server). -Each factory builds a :class:`CreateTableExpression` whose generated T-SQL +Each factory builds a :class:`CreateTableStatement` whose generated T-SQL DDL is semantically equivalent to the reference ``.sql`` schema files under ``tests/rhosocial/activerecord_sqlserver_test/feature/query/schema/``. """ @@ -9,13 +9,17 @@ from typing import Callable, Dict from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, + CreateTableStatement, ) from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnNotNullConstraint, + ColumnNullableConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, ForeignKeyConstraint, IndexDefinition, ReferentialAction, @@ -36,53 +40,63 @@ _CASCADE = ReferentialAction.CASCADE -def to_sql(expr: CreateTableExpression): +def to_sql(expr: CreateTableStatement): return _common.to_sqlserver_ddl_sql(expr) -def create_users_table(dialect, table_name: str = "users") -> CreateTableExpression: - return CreateTableExpression( +def create_users_table(dialect, table_name: str = "users") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ]), ColumnDefinition(dialect, "username", VarCharType(dialect, length=191), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.UNIQUE)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect))]), ColumnDefinition(dialect, "email", VarCharType(dialect, length=191), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.UNIQUE)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect))]), ColumnDefinition(dialect, "age", IntegerType(dialect)), ColumnDefinition(dialect, "balance", DoubleType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0))]), ColumnDefinition(dialect, "is_active", BooleanType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], ) -def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpression: - return CreateTableExpression( +def create_posts_table(dialect, table_name: str = "posts") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ]), ColumnDefinition(dialect, "user_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "title", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "content", TextType(dialect)), ColumnDefinition(dialect, "status", VarCharType(dialect, length=50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="published")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "published"))]), ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], @@ -102,21 +116,26 @@ def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpress ) -def create_comments_table(dialect, table_name: str = "comments") -> CreateTableExpression: - return CreateTableExpression( +def create_comments_table(dialect, table_name: str = "comments") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ]), ColumnDefinition(dialect, "user_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "post_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "content", TextType(dialect)), ColumnDefinition(dialect, "is_hidden", BooleanType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], @@ -143,24 +162,29 @@ def create_comments_table(dialect, table_name: str = "comments") -> CreateTableE ) -def create_orders_table(dialect, table_name: str = "orders") -> CreateTableExpression: - return CreateTableExpression( +def create_orders_table(dialect, table_name: str = "orders") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ]), ColumnDefinition(dialect, "user_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "order_number", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "total_amount", DecimalType(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0))]), ColumnDefinition(dialect, "status", VarCharType(dialect, length=50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="pending")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "pending"))]), ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], @@ -176,26 +200,31 @@ def create_orders_table(dialect, table_name: str = "orders") -> CreateTableExpre ) -def create_order_items_table(dialect, table_name: str = "order_items") -> CreateTableExpression: - return CreateTableExpression( +def create_order_items_table(dialect, table_name: str = "order_items") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ]), ColumnDefinition(dialect, "order_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "product_name", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "quantity", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "unit_price", DecimalType(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "subtotal", DecimalType(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0))]), ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], @@ -212,16 +241,21 @@ def create_order_items_table(dialect, table_name: str = "order_items") -> Create ) -def create_profiles_table(dialect, table_name: str = "profiles") -> CreateTableExpression: - return CreateTableExpression( +def create_profiles_table(dialect, table_name: str = "profiles") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ]), ColumnDefinition(dialect, "user_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "bio", TextType(dialect)), ColumnDefinition(dialect, "avatar_url", VarCharType(dialect, length=512)), ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), @@ -238,18 +272,23 @@ def create_profiles_table(dialect, table_name: str = "profiles") -> CreateTableE ) -def create_json_users_table(dialect, table_name: str = "json_users") -> CreateTableExpression: - return CreateTableExpression( +def create_json_users_table(dialect, table_name: str = "json_users") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ]), ColumnDefinition(dialect, "username", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "email", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "age", IntegerType(dialect)), ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), @@ -264,21 +303,26 @@ def create_json_users_table(dialect, table_name: str = "json_users") -> CreateTa ) -def create_nodes_table(dialect, table_name: str = "nodes") -> CreateTableExpression: - return CreateTableExpression( +def create_nodes_table(dialect, table_name: str = "nodes") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ]), ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "parent_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNullableConstraint(dialect))]), ColumnDefinition(dialect, "value", DecimalType(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0))]), ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", DateTimeType(dialect, precision=6)), ], @@ -295,14 +339,19 @@ def create_nodes_table(dialect, table_name: str = "nodes") -> CreateTableExpress ) -def create_searchable_items_table(dialect, table_name: str = "searchable_items") -> CreateTableExpression: - return CreateTableExpression( +def create_searchable_items_table(dialect, table_name: str = "searchable_items") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ]), ColumnDefinition(dialect, "name", VarCharType(dialect, length=255)), ColumnDefinition(dialect, "tags", TextType(dialect)), ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), @@ -311,30 +360,35 @@ def create_searchable_items_table(dialect, table_name: str = "searchable_items") ) -def create_extended_orders_table(dialect, table_name: str = "extended_orders") -> CreateTableExpression: - return CreateTableExpression( +def create_extended_orders_table(dialect, table_name: str = "extended_orders") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ]), ColumnDefinition(dialect, "user_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "order_number", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "total_amount", DecimalType(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0))]), ColumnDefinition(dialect, "status", VarCharType(dialect, length=50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="pending")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "pending"))]), ColumnDefinition(dialect, "priority", VarCharType(dialect, length=50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="medium")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "medium"))]), ColumnDefinition(dialect, "region", VarCharType(dialect, length=50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="default")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "default"))]), ColumnDefinition(dialect, "category", VarCharType(dialect, length=255)), ColumnDefinition(dialect, "product", VarCharType(dialect, length=255)), ColumnDefinition(dialect, "department", VarCharType(dialect, length=255)), @@ -363,23 +417,28 @@ def create_extended_orders_table(dialect, table_name: str = "extended_orders") - def create_extended_order_items_table( dialect, table_name: str = "extended_order_items" -) -> CreateTableExpression: - return CreateTableExpression( +) -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ) + ]), ColumnDefinition(dialect, "order_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "product_name", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "quantity", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "price", DecimalType(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "category", VarCharType(dialect, length=255)), ColumnDefinition(dialect, "region", VarCharType(dialect, length=50)), ColumnDefinition(dialect, "created_at", DateTimeType(dialect, precision=6)), diff --git a/tests/providers/mixins.py b/tests/providers/mixins.py index b744f85..67cbb49 100644 --- a/tests/providers/mixins.py +++ b/tests/providers/mixins.py @@ -129,7 +129,7 @@ def __init__(self): def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str) -> Type[ActiveRecord]: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from rhosocial.activerecord.backend.expression.objects import Table from providers.fixtures.mixins import TABLE_EXPRESSIONS from providers.fixtures._common import to_sqlserver_ddl_sql @@ -187,7 +187,7 @@ async def _setup_async_model( from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from rhosocial.activerecord.backend.expression.objects import Table from providers.fixtures.mixins import TABLE_EXPRESSIONS from providers.fixtures._common import to_sqlserver_ddl_sql diff --git a/tests/providers/query.py b/tests/providers/query.py index 21f0063..3d04393 100644 --- a/tests/providers/query.py +++ b/tests/providers/query.py @@ -243,7 +243,7 @@ def __init__(self): def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str) -> Type[ActiveRecord]: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from rhosocial.activerecord.backend.expression.objects import Table from providers.fixtures.query import TABLE_EXPRESSIONS from providers.fixtures._common import to_sqlserver_ddl_sql @@ -286,7 +286,7 @@ def _setup_multiple_models( def _reset_schema(self, backend_instance, table_name: str) -> None: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from rhosocial.activerecord.backend.expression.objects import Table from providers.fixtures.query import TABLE_EXPRESSIONS from providers.fixtures._common import to_sqlserver_ddl_sql @@ -370,7 +370,7 @@ def setup_profile_fixtures( def setup_order_item_model(self, scenario_name: str) -> Type[ActiveRecord]: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from rhosocial.activerecord.backend.expression.objects import Table from providers.fixtures.basic import TABLE_EXPRESSIONS as BASIC_EXPRS from providers.fixtures._common import to_sqlserver_ddl_sql @@ -533,7 +533,7 @@ async def _setup_model_async( from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from rhosocial.activerecord.backend.expression.objects import Table from providers.fixtures.query import TABLE_EXPRESSIONS from providers.fixtures._common import to_sqlserver_ddl_sql @@ -688,7 +688,7 @@ async def setup_profile_fixtures( async def setup_order_item_model(self, scenario_name: str) -> Type[AsyncActiveRecord]: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend from providers.fixtures.basic import TABLE_EXPRESSIONS as BASIC_EXPRS diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_introspect.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_introspect.py index c57df5b..ae166f0 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_introspect.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_introspect.py @@ -1,9 +1,14 @@ # tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_introspect.py """Offline black-box tests for the ``introspect`` subcommand. -All ``cli/introspect.py`` dispatch branches run against a fake backend whose -``introspector`` returns fixed data, so every type (including the SQL Server -specific ``procedures``/``functions``) is covered without a live server. +The fakes below implement the *real* introspector API -- ``list_tables``, +``list_views``, ``list_columns``, ... plus this backend's ``list_schemas``/ +``list_procedures``/``list_functions``/``list_sequences``. That matters: the +command used to call ``get_tables``/``get_views``/``get_columns``, which exist +nowhere in the backend, so every type failed against a real backend while the +old fakes happily answered methods of those names. ``test_dispatch_contract`` +closes that gap by checking every type against the real introspector classes, +with no fake in the way. """ import argparse import json @@ -11,17 +16,28 @@ import pytest from rhosocial.activerecord.backend.errors import ConnectionError, QueryError -from rhosocial.activerecord.backend.impl.sqlserver import backend as backend_pkg from rhosocial.activerecord.backend.impl.sqlserver.backend import async_backend as async_backend_mod from rhosocial.activerecord.backend.impl.sqlserver.cli import introspect as introspect_mod +from rhosocial.activerecord.backend.impl.sqlserver.introspection import ( + AsyncSQLServerIntrospector, + SyncSQLServerIntrospector, +) + + +class FakeTable: + """The shape ``get_table_info`` returns, as far as the CLI reads it.""" + + def __init__(self, name): + self.name = name + self.columns = [{"method": "columns"}] + self.indexes = [{"method": "indexes"}] + self.foreign_keys = [{"method": "foreign_keys"}] class FakeIntrospector: """Fixed-data stand-in recording calls with their kwargs.""" instances = [] - # When set, ``get_tables`` returns this instead of the default payload. - tables_override = None def __init__(self): self.calls = [] @@ -31,51 +47,124 @@ def _record(self, method, **kwargs): self.calls.append((method, kwargs)) return [{"method": method, **kwargs}] - def get_tables(self, schema=None): - if self.tables_override is not None: - return self.tables_override - return self._record("get_tables", schema=schema) + def list_tables(self, schema=None, include_system=False): + return self._record("list_tables", schema=schema, include_system=include_system) + + def list_views(self, schema=None, include_system=False): + return self._record("list_views", schema=schema, include_system=include_system) + + def list_columns(self, table_name, schema=None): + return self._record("list_columns", table_name=table_name, schema=schema) + + def list_indexes(self, table_name, schema=None): + return self._record("list_indexes", table_name=table_name, schema=schema) + + def list_foreign_keys(self, table_name, schema=None): + return self._record("list_foreign_keys", table_name=table_name, schema=schema) + + def list_triggers(self, table_name=None, schema=None): + return self._record("list_triggers", table_name=table_name, schema=schema) + + def list_procedures(self, schema=None): + return self._record("list_procedures", schema=schema) + + def list_functions(self, schema=None): + return self._record("list_functions", schema=schema) - def get_views(self, schema=None): - return self._record("get_views", schema=schema) + def list_sequences(self, schema=None): + return self._record("list_sequences", schema=schema) - def get_columns(self, table, schema=None): - return self._record("get_columns", table=table, schema=schema) + def list_schemas(self): + return self._record("list_schemas") - def get_indexes(self, table, schema=None): - return self._record("get_indexes", table=table, schema=schema) + def list_extended_properties( + self, + major_id=None, + *, + table_name=None, + minor_id=None, + class_name="OBJECT_OR_COLUMN", + schema=None, + ): + return self._record("list_extended_properties", table_name=table_name, schema=schema) - def get_foreign_keys(self, table, schema=None): - return self._record("get_foreign_keys", table=table, schema=schema) + def get_view_info(self, view_name, schema=None): + self.calls.append(("get_view_info", {"view_name": view_name, "schema": schema})) + return {"name": view_name, "definition": "CREATE VIEW v AS SELECT 1"} - def get_triggers(self, table=None, schema=None): - if table == "explode": - raise RuntimeError("trigger probe failed") - return self._record("get_triggers", table=table, schema=schema) + def table_exists(self, table_name, schema=None): + self.calls.append(("table_exists", {"table_name": table_name, "schema": schema})) + return True - def get_constraints(self, table=None, schema=None): - return self._record("get_constraints", table=table, schema=schema) + def get_database_info(self): + return {"db_name": "db", "version": "16.0.4250.1"} + + def get_table_info(self, table_name, schema=None): + self.calls.append(("get_table_info", {"table_name": table_name, "schema": schema})) + return FakeTable(table_name) + + +class FakeMissingTableIntrospector(FakeIntrospector): + def get_table_info(self, table_name, schema=None): + self.calls.append(("get_table_info", {"table_name": table_name, "schema": schema})) + return None + + +class FakeAsyncIntrospector(FakeIntrospector): + """The same records, awaited.""" + + async def list_tables(self, schema=None, include_system=False): + return FakeIntrospector.list_tables(self, schema, include_system) + + async def list_schemas(self): + return FakeIntrospector.list_schemas(self) + + async def list_triggers(self, table_name=None, schema=None): + return FakeIntrospector.list_triggers(self, table_name, schema) + + async def list_extended_properties( + self, + major_id=None, + *, + table_name=None, + minor_id=None, + class_name="OBJECT_OR_COLUMN", + schema=None, + ): + return FakeIntrospector.list_extended_properties( + self, + major_id, + table_name=table_name, + minor_id=minor_id, + class_name=class_name, + schema=schema, + ) - def get_procedures(self, schema=None): - return self._record("get_procedures", schema=schema) + async def get_view_info(self, view_name, schema=None): + return FakeIntrospector.get_view_info(self, view_name, schema) - def get_functions(self, schema=None): - return self._record("get_functions", schema=schema) + async def table_exists(self, table_name, schema=None): + return FakeIntrospector.table_exists(self, table_name, schema) - def get_sequences(self, schema=None): - return self._record("get_sequences", schema=schema) + async def get_database_info(self): + return FakeIntrospector.get_database_info(self) + + async def get_table_info(self, table_name, schema=None): + return FakeIntrospector.get_table_info(self, table_name, schema) class FakeBackendBase: created = [] instances = [] connect_error = None + introspector_class = FakeIntrospector def __init__(self, connection_config=None): self.connection_config = connection_config self._connection = object() self.disconnects = 0 - self.introspector = FakeIntrospector() + self.adaptations = 0 + self.introspector = self.introspector_class() type(self).created.append(connection_config) type(self).instances.append(self) @@ -83,26 +172,51 @@ def connect(self): if self.connect_error is not None: raise self.connect_error + def introspect_and_adapt(self): + # The command must ask the server what it is talking to before the + # first question; the capability gates and version rendering need it. + self.adaptations += 1 + def disconnect(self): self.disconnects += 1 - def introspect_and_adapt(self): - pass - class FakeAsyncBackendBase(FakeBackendBase): + introspector_class = FakeAsyncIntrospector + async def connect(self): FakeBackendBase.connect(self) + async def introspect_and_adapt(self): + FakeBackendBase.introspect_and_adapt(self) + async def disconnect(self): self.disconnects += 1 - async def introspect_and_adapt(self): - pass + +def _tracking_class(base, name, introspector_class=None): + attrs = {"created": [], "instances": []} + if introspector_class is not None: + attrs["introspector_class"] = introspector_class + return type(name, (base,), attrs) -def _tracking_class(base, name): - return type(name, (base,), {"created": [], "instances": []}) +def _json_documents(text): + """Every standalone JSON document in *text*, in order. + + A table detail is rendered as three sections, each its own document, so + the output is not one JSON array. + """ + decoder = json.JSONDecoder() + index, documents = 0, [] + while index < len(text): + while index < len(text) and text[index].isspace(): + index += 1 + if index >= len(text): + break + document, index = decoder.raw_decode(text, index) + documents.append(document) + return documents @pytest.fixture(autouse=True) @@ -118,10 +232,11 @@ def _clean_env(monkeypatch): def _patched(monkeypatch): """Patch sync backend construction and reset per-test recording state.""" monkeypatch.setattr( - introspect_mod, "SQLServerBackend", _tracking_class(FakeBackendBase, "FakeIntrospectBackend") + introspect_mod, + "SQLServerBackend", + _tracking_class(FakeBackendBase, "FakeIntrospectBackend"), ) FakeIntrospector.instances = [] - FakeIntrospector.tables_override = None def make_args(argv): @@ -136,10 +251,12 @@ def test_sqlserver_specific_types_accepted(self): assert make_args(["introspect", "procedures"]).type == "procedures" assert make_args(["introspect", "functions"]).type == "functions" - def test_schema_defaults_to_dbo(self): - args = make_args(["introspect", "tables"]) - assert args.schema == "dbo" - assert make_args(["introspect", "tables", "--schema", "sales"]).schema == "sales" + def test_schemas_type_accepted(self): + assert make_args(["introspect", "schemas"]).type == "schemas" + + def test_schema_flag_is_not_offered(self): + with pytest.raises(SystemExit): + make_args(["introspect", "tables", "--schema", "sales"]) def test_name_optional_and_include_system_flag(self): args = make_args(["introspect", "columns", "users"]) @@ -158,41 +275,82 @@ def test_missing_database_exits_before_backend_creation(self, capsys): assert introspect_mod.SQLServerBackend.created == [] +class TestDispatchContract: + """Every advertised type resolves to a method the real backend has. + + No fake and no server: this is the regression guard for the command + calling methods (``get_tables`` and friends) that never existed. + """ + + @pytest.mark.parametrize("kind", introspect_mod.INTROSPECT_TYPES) + def test_type_maps_to_a_real_method_on_both_managers(self, kind): + argv = ["introspect", kind] + if kind in ("columns", "indexes", "foreign-keys", "extended-properties"): + argv.append("users") + for method, _kwargs in introspect_mod._steps(make_args(argv)): + assert hasattr(SyncSQLServerIntrospector, method), method + assert hasattr(AsyncSQLServerIntrospector, method), method + + def test_database_takes_no_object_name(self): + """``database`` answers the database's own facts, with or without a name. + + It used to accept a table name and render that table's columns, + indexes and foreign keys -- the same three questions the aggregate + ``table`` type answered. Both are gone; a name is now simply unused. + """ + assert introspect_mod._steps(make_args(["introspect", "database"])) == [ + ("get_database_info", {}) + ] + + class TestHandleDispatch: @pytest.mark.parametrize( - "argv", + ("argv", "method"), [ - ["tables"], - ["views"], - ["columns", "users"], - ["indexes", "users"], - ["foreign-keys", "users"], - ["procedures"], - ["functions"], - ["sequences"], + (["tables"], "list_tables"), + (["views"], "list_views"), + (["columns", "users"], "list_columns"), + (["indexes", "users"], "list_indexes"), + (["foreign-keys", "users"], "list_foreign_keys"), + (["extended-properties", "users"], "list_extended_properties"), + (["triggers"], "list_triggers"), + (["procedures"], "list_procedures"), + (["functions"], "list_functions"), + (["sequences"], "list_sequences"), + (["schemas"], "list_schemas"), ], ) - def test_types_dispatch_and_render_json(self, argv, capsys): + def test_types_dispatch_and_render_json(self, argv, method, capsys): introspect_mod.handle(make_args(["introspect"] + argv + ["--database", "db"])) payload = json.loads(capsys.readouterr().out) - assert isinstance(payload, list) and payload[0]["method"] + assert isinstance(payload, list) + assert payload[0]["method"] == method + + def test_tables_receives_include_system_and_no_schema(self, capsys): + introspect_mod.handle( + make_args(["introspect", "tables", "--database", "db", "--include-system"]) + ) + method, kwargs = FakeIntrospector.instances[-1].calls[-1] + assert method == "list_tables" + assert kwargs == {"schema": None, "include_system": True} - def test_tables_receives_schema(self, capsys): + def test_the_backend_is_adapted_before_the_first_question(self): introspect_mod.handle( - make_args(["introspect", "tables", "--database", "db", "--schema", "sales"]) + make_args(["introspect", "tables", "--database", "db"]) ) + # The capability gates and version rendering need the handshake's + # version; the command must ask for it rather than hope something did. + assert introspect_mod.SQLServerBackend.instances[-1].adaptations == 1 + + def test_schemas_take_no_scope_argument(self): + introspect_mod.handle(make_args(["introspect", "schemas", "--database", "db"])) method, kwargs = FakeIntrospector.instances[-1].calls[-1] - assert method == "get_tables" - assert kwargs["schema"] == "sales" + assert (method, kwargs) == ("list_schemas", {}) - def test_table_detail_shows_columns_indexes_fks(self, capsys): - introspect_mod.handle(make_args(["introspect", "table", "users", "--database", "db"])) - inst = FakeIntrospector.instances[-1] - methods = [m for m, _ in inst.calls] - assert methods == ["get_columns", "get_indexes", "get_foreign_keys"] - assert all(kwargs["table"] == "users" for _, kwargs in inst.calls) - @pytest.mark.parametrize("kind", ["table", "columns", "indexes", "foreign-keys"]) + @pytest.mark.parametrize( + "kind", ["columns", "indexes", "foreign-keys", "extended-properties"] + ) def test_missing_name_exits_for_table_scoped_types(self, kind, capsys): with pytest.raises(SystemExit) as exc: introspect_mod.handle(make_args(["introspect", kind, "--database", "db"])) @@ -204,44 +362,241 @@ def test_triggers_with_table_name(self, capsys): make_args(["introspect", "triggers", "users", "--database", "db"]) ) method, kwargs = FakeIntrospector.instances[-1].calls[-1] - assert method == "get_triggers" - assert kwargs["table"] == "users" + assert method == "list_triggers" + assert kwargs["table_name"] == "users" - def test_triggers_without_name_iterates_all_tables(self): - FakeIntrospector.tables_override = [ - type("T", (), {"name": "users"}), - type("T", (), {"name": "orders"}), - ] - introspect_mod.handle(make_args(["introspect", "triggers", "--database", "db"])) - trigger_calls = [ - k["table"] for m, k in FakeIntrospector.instances[-1].calls if m == "get_triggers" - ] - assert trigger_calls == ["users", "orders"] + def test_views_without_a_name_lists(self): + introspect_mod.handle(make_args(["introspect", "views", "--database", "db"])) + method, kwargs = FakeIntrospector.instances[-1].calls[-1] + assert (method, kwargs) == ("list_views", {"schema": None, "include_system": False}) - def test_triggers_iteration_swallows_per_table_errors(self): - FakeIntrospector.tables_override = [ - type("T", (), {"name": "explode"}), - type("T", (), {"name": "safe"}), - ] + def test_views_with_a_name_asks_the_single_view(self, capsys): + """A name is the single-view question, not a filter over the list.""" + introspect_mod.handle( + make_args(["introspect", "views", "active_users", "--database", "db"]) + ) + method, kwargs = FakeIntrospector.instances[-1].calls[-1] + assert (method, kwargs) == ("get_view_info", {"view_name": "active_users", "schema": None}) + payload = json.loads(capsys.readouterr().out) + assert payload[0]["definition"] + + def test_a_view_that_is_not_there_exits_one(self, monkeypatch, capsys): + class MissingViewIntrospector(FakeIntrospector): + def get_view_info(self, view_name, schema=None): + return None + + monkeypatch.setattr( + introspect_mod, + "SQLServerBackend", + _tracking_class(FakeBackendBase, "MissingViewBackend", MissingViewIntrospector), + ) + with pytest.raises(SystemExit) as exc: + introspect_mod.handle(make_args(["introspect", "views", "nope", "--database", "db"])) + assert exc.value.code == 1 + assert "View 'nope' not found" in capsys.readouterr().err + + def test_extended_properties_ask_by_table_name(self): + """A name is the whole question: the statement resolves the object id.""" + introspect_mod.handle( + make_args(["introspect", "extended-properties", "users", "--database", "db"]) + ) + method, kwargs = FakeIntrospector.instances[-1].calls[-1] + assert (method, kwargs) == ( + "list_extended_properties", + {"table_name": "users", "schema": None}, + ) + + def test_a_table_without_properties_is_not_a_missing_table( + self, monkeypatch, capsys + ): + """No rows and no table are two answers, and only the second is an error.""" + + class NoPropertiesIntrospector(FakeIntrospector): + def list_extended_properties(self, *args, **kwargs): + return [] + + monkeypatch.setattr( + introspect_mod, + "SQLServerBackend", + _tracking_class(FakeBackendBase, "NoPropertiesBackend", NoPropertiesIntrospector), + ) + introspect_mod.handle( + make_args(["introspect", "extended-properties", "users", "--database", "db"]) + ) + assert json.loads(capsys.readouterr().out) == [] + + def test_an_unknown_table_exits_one(self, monkeypatch, capsys): + class UnknownTableIntrospector(FakeIntrospector): + def list_extended_properties(self, *args, **kwargs): + return [] + + def table_exists(self, table_name, schema=None): + return False + + monkeypatch.setattr( + introspect_mod, + "SQLServerBackend", + _tracking_class(FakeBackendBase, "UnknownTableBackend", UnknownTableIntrospector), + ) + with pytest.raises(SystemExit) as exc: + introspect_mod.handle( + make_args( + ["introspect", "extended-properties", "ghost", "--database", "db"] + ) + ) + assert exc.value.code == 1 + assert "Table 'ghost' not found" in capsys.readouterr().err + + def test_triggers_without_name_asks_once_for_all(self): introspect_mod.handle(make_args(["introspect", "triggers", "--database", "db"])) - trigger_calls = [ - k["table"] for m, k in FakeIntrospector.instances[-1].calls if m == "get_triggers" + calls = FakeIntrospector.instances[-1].calls + assert calls == [ + ("list_triggers", {"table_name": None, "schema": None}), ] - assert trigger_calls == ["safe"] # failure probing 'explode' is not fatal - def test_database_without_name_lists_tables_and_schema(self, capsys): + def test_database_without_name_reports_database_info(self, capsys): introspect_mod.handle(make_args(["introspect", "database", "--database", "db"])) payload = json.loads(capsys.readouterr().out) - assert payload[0]["schema"] == "dbo" - assert payload[0]["tables"][0]["method"] == "get_tables" + assert payload == [{"db_name": "db", "version": "16.0.4250.1"}] + + def test_database_with_a_name_is_refused(self): + """A name must not smuggle the aggregate back in -- nor be ignored. + + ``database users`` used to answer the same three-part table detail as + ``table users``. Both are gone. A name is now refused instead of + ignored: silently answering the unnamed question reads as a filter that + matched everything, which is the one shape a wrong answer must not take. + """ + with pytest.raises(SystemExit) as exc: + introspect_mod.handle( + make_args(["introspect", "database", "users", "--database", "db"]) + ) + assert exc.value.code == 2 + + +class TestDeclaredVocabulary: + """The advertised types, the switches and the NAME rules are one statement.""" + + def test_the_advertised_list_is_the_capability_table(self): + assert list(introspect_mod._TYPE_CAPABILITIES) == introspect_mod.INTROSPECT_TYPES - def test_database_with_name_shows_constraints(self, capsys): + def test_every_declared_type_has_a_name_rule(self): + assert set(introspect_mod._NAME_RULES) == set(introspect_mod.INTROSPECT_TYPES) + + def test_the_removed_aggregate_is_not_readvertised(self): + assert "table" not in introspect_mod.INTROSPECT_TYPES + assert "table" not in introspect_mod._TYPE_CAPABILITIES + + @pytest.mark.parametrize( + ("kind", "switch"), sorted(introspect_mod._TYPE_CAPABILITIES.items()) + ) + def test_every_switch_is_declared_by_the_dialect(self, kind, switch): + from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect + + dialect = SQLServerDialect(version=(16, 0, 0)) + answer = getattr(dialect, switch, None) + assert callable(answer), f"{kind} names {switch}, which the dialect lacks" + assert answer() is True + + def test_help_lists_every_declared_type(self, capsys): + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="command") + introspect_mod.create_parser(sub) + with pytest.raises(SystemExit): + parser.parse_args(["introspect", "--help"]) + help_text = capsys.readouterr().out + for kind in introspect_mod.INTROSPECT_TYPES: + assert kind in help_text + + +class TestSupportedScopes: + """``get_supported_introspection_scopes`` is not core's empty default.""" + + @staticmethod + def _scopes(): + from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect + + return SQLServerDialect(version=(16, 0, 0)).get_supported_introspection_scopes() + + def test_the_scopes_are_the_families_that_answer(self): + from rhosocial.activerecord.backend.introspection.types import IntrospectionScope + + assert set(self._scopes()) == { + IntrospectionScope.DATABASE, + IntrospectionScope.TABLE, + IntrospectionScope.COLUMN, + IntrospectionScope.INDEX, + IntrospectionScope.FOREIGN_KEY, + IntrospectionScope.VIEW, + IntrospectionScope.TRIGGER, + IntrospectionScope.SEQUENCE, + } + + def test_a_caller_is_not_told_nothing(self): + assert self._scopes() + + def test_every_declared_switch_answers_true(self): + from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect + + dialect = SQLServerDialect(version=(16, 0, 0)) + for switch in introspect_mod._TYPE_CAPABILITIES.values(): + assert getattr(dialect, switch)() is True, switch + + +class TestCapabilityGate: + def test_a_switch_that_says_no_refuses_the_type(self, monkeypatch, capsys): + cls = _tracking_class(FakeBackendBase, "RefusingBackend") + cls.dialect = type( + "RefusingDialect", + (), + {"supports_table_introspection": lambda self: False}, + )() + monkeypatch.setattr(introspect_mod, "SQLServerBackend", cls) + with pytest.raises(SystemExit) as exc: + introspect_mod.handle(make_args(["introspect", "tables", "--database", "db"])) + assert exc.value.code == 2 + assert ( + "Error: tables introspection is not supported by this server" + in capsys.readouterr().err + ) + # Asked after the adaptation: the switch answers for this server. + assert cls.instances[-1].adaptations == 1 + assert cls.instances[-1].introspector.calls == [], "refused before the question" + + def test_a_stub_without_a_dialect_is_not_refused(self): + """The offline fakes have no dialect; the gate must not invent one.""" + introspect_mod.handle(make_args(["introspect", "tables", "--database", "db"])) + assert FakeIntrospector.instances[-1].calls != [] + + +class TestNameGate: + @pytest.mark.parametrize( + "kind", + ["tables", "database", "procedures", "functions", "sequences", "schemas"], + ) + def test_a_name_the_type_cannot_use_exits_two(self, kind, capsys): + with pytest.raises(SystemExit) as exc: + introspect_mod.handle( + make_args(["introspect", kind, "users", "--database", "db"]) + ) + assert exc.value.code == 2 + assert f"Error: {kind} introspection takes no name" in capsys.readouterr().err + + def test_a_forbidden_name_is_refused_before_connecting(self, capsys): + with pytest.raises(SystemExit): + introspect_mod.handle( + make_args(["introspect", "schemas", "dbo", "--database", "db"]) + ) + assert introspect_mod.SQLServerBackend.created == [] + assert "takes no name" in capsys.readouterr().err + + def test_the_optional_names_are_not_refused(self): + introspect_mod.handle(make_args(["introspect", "views", "users", "--database", "db"])) + assert FakeIntrospector.instances[-1].calls[-1][0] == "get_view_info" introspect_mod.handle( - make_args(["introspect", "database", "users", "--database", "db"]) + make_args(["introspect", "triggers", "users", "--database", "db"]) ) - method, kwargs = FakeIntrospector.instances[-1].calls[-1] - assert method == "get_constraints" - assert kwargs["table"] == "users" + assert FakeIntrospector.instances[-1].calls[-1][0] == "list_triggers" class TestHandleLifecycleAndConfig: @@ -264,23 +619,21 @@ def test_disconnect_called_in_finally(self): class TestHandleErrors: @pytest.mark.parametrize( ("backend_error", "introspector_exc"), - [(ConnectionError("refused"), None), (None, QueryError("syntax")), (None, RuntimeError("boom"))], + [ + (ConnectionError("refused"), None), + (None, QueryError("syntax")), + (None, RuntimeError("boom")), + ], ) def test_error_paths_exit_nonzero(self, monkeypatch, backend_error, introspector_exc): cls = _tracking_class(FakeBackendBase, "ErrBackend") if introspector_exc is not None: class Exploding(FakeIntrospector): - def get_tables(self, schema=None): + def list_tables(self, schema=None, include_system=False): raise introspector_exc - orig_init = cls.__init__ - - def init_with_exploding(self, connection_config=None): - orig_init(self, connection_config=connection_config) - self.introspector = Exploding() - - cls.__init__ = init_with_exploding + cls.introspector_class = Exploding else: cls.connect_error = backend_error monkeypatch.setattr(introspect_mod, "SQLServerBackend", cls) @@ -291,17 +644,32 @@ def init_with_exploding(self, connection_config=None): class TestHandleAsync: def test_async_tables_dispatch(self, monkeypatch, capsys): + cls = _tracking_class(FakeAsyncBackendBase, "AsyncFake") monkeypatch.setattr( async_backend_mod, "AsyncSQLServerBackend", - _tracking_class(FakeAsyncBackendBase, "AsyncFake"), + cls, raising=False, ) introspect_mod.handle( make_args(["introspect", "tables", "--database", "db", "-o", "json", "--async"]) ) payload = json.loads(capsys.readouterr().out) - assert payload[0]["method"] == "get_tables" + assert payload[0]["method"] == "list_tables" + assert cls.instances[-1].adaptations == 1 + + def test_async_schemas_dispatch(self, monkeypatch, capsys): + monkeypatch.setattr( + async_backend_mod, + "AsyncSQLServerBackend", + _tracking_class(FakeAsyncBackendBase, "AsyncSchemasFake"), + raising=False, + ) + introspect_mod.handle( + make_args(["introspect", "schemas", "--database", "db", "-o", "json", "--async"]) + ) + payload = json.loads(capsys.readouterr().out) + assert payload[0]["method"] == "list_schemas" def test_async_connection_error_exits_nonzero(self, monkeypatch): cls = _tracking_class(FakeAsyncBackendBase, "AsyncErr") @@ -312,3 +680,59 @@ def test_async_connection_error_exits_nonzero(self, monkeypatch): make_args(["introspect", "tables", "--database", "db", "--async"]) ) assert exc.value.code != 0 + + def test_async_extended_properties_dispatch(self, monkeypatch, capsys): + monkeypatch.setattr( + async_backend_mod, + "AsyncSQLServerBackend", + _tracking_class(FakeAsyncBackendBase, "AsyncExtFake"), + raising=False, + ) + introspect_mod.handle( + make_args( + [ + "introspect", + "extended-properties", + "users", + "--database", + "db", + "-o", + "json", + "--async", + ] + ) + ) + payload = json.loads(capsys.readouterr().out) + assert payload[0]["method"] == "list_extended_properties" + + def test_async_unknown_table_exits_one(self, monkeypatch, capsys): + """The existence read is awaited too, not left as an unfired coroutine.""" + + class UnknownTable(FakeAsyncIntrospector): + async def list_extended_properties(self, *args, **kwargs): + return [] + + async def table_exists(self, table_name, schema=None): + return False + + monkeypatch.setattr( + async_backend_mod, + "AsyncSQLServerBackend", + _tracking_class(FakeAsyncBackendBase, "AsyncUnknownTable", UnknownTable), + raising=False, + ) + with pytest.raises(SystemExit) as exc: + introspect_mod.handle( + make_args( + [ + "introspect", + "extended-properties", + "ghost", + "--database", + "db", + "--async", + ] + ) + ) + assert exc.value.code == 1 + assert "Table 'ghost' not found" in capsys.readouterr().err diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_introspect_live.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_introspect_live.py new file mode 100644 index 0000000..2b6b8ab --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_introspect_live.py @@ -0,0 +1,289 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_introspect_live.py +"""The ``introspect`` subcommand against a live SQL Server. + +The offline suite in ``test_cli_introspect.py`` proves the dispatch -- which +introspector method answers which type -- with fakes. This proves the same +command works end to end against a real server, which is what the old +``get_tables``/``get_views``/``get_columns`` spelling could never do: those +methods exist nowhere in the backend, so against a live server every type +failed while the offline fakes happily answered them. + +Runs on the first scenario only, like the other single-scenario live suites, +and points its probe objects through ``introspection_probe`` so the two live +suites see the same catalogue. +""" + +import json + +import pytest + + +from rhosocial.activerecord.backend.impl.sqlserver.__main__ import main + +from tests.rhosocial.activerecord_sqlserver_test.feature.backend.introspection_probe import ( + PROBE_DESCRIPTION, + PROBE_FUNCTION, + PROBE_PROCEDURE, + PROBE_SEQUENCE, + PROBE_TABLE, + PROBE_TRIGGER, + PROBE_TRIGGER_DISABLED, + PROBE_VIEW, + create_probe_objects, + drop_probe_objects, +) + + +def _connection_argv(config): + """Connection flags for the scenario the fixture connected with.""" + return [ + "--host", + config.host, + "--port", + str(config.port), + "--database", + config.database, + "--user", + config.username, + "--password", + config.password or "", + "--driver", + config.driver, + ] + + +@pytest.fixture +def probed_backend(sqlserver_backend_single): + create_probe_objects(sqlserver_backend_single) + yield sqlserver_backend_single + drop_probe_objects(sqlserver_backend_single) + + +class TestIntrospectLiveCli: + """``main(...)`` returns non-empty output for the types it advertises.""" + + def test_tables(self, probed_backend, capsys): + main( + [ + "introspect", + "tables", + "-o", + "json", + *_connection_argv(probed_backend.config), + ] + ) + payload = json.loads(capsys.readouterr().out) + assert isinstance(payload, list) and payload + names = [row["name"] for row in payload] + assert PROBE_TABLE in names + + def test_views(self, probed_backend, capsys): + main( + [ + "introspect", + "views", + "-o", + "json", + *_connection_argv(probed_backend.config), + ] + ) + payload = json.loads(capsys.readouterr().out) + assert isinstance(payload, list) and payload + assert all(row["name"] for row in payload) + + def test_one_view_answers_with_its_definition(self, probed_backend, capsys): + """``views NAME`` is the single-view question, and it carries the text.""" + main( + [ + "introspect", + "views", + PROBE_VIEW, + "-o", + "json", + *_connection_argv(probed_backend.config), + ] + ) + payload = json.loads(capsys.readouterr().out) + assert payload and payload[0]["name"] == PROBE_VIEW + assert "CREATE VIEW" in payload[0]["definition"] + + def test_the_view_list_carries_the_definitions_too(self, probed_backend, capsys): + """The list projects the same definition the single-view question does.""" + main( + [ + "introspect", + "views", + "-o", + "json", + *_connection_argv(probed_backend.config), + ] + ) + payload = json.loads(capsys.readouterr().out) + row = [r for r in payload if r["name"] == PROBE_VIEW][0] + assert "CREATE VIEW" in row["definition"] + + def test_columns(self, probed_backend, capsys): + main( + [ + "introspect", + "columns", + PROBE_TABLE, + "-o", + "json", + *_connection_argv(probed_backend.config), + ] + ) + payload = json.loads(capsys.readouterr().out) + assert isinstance(payload, list) and payload + assert "id" in [c["name"] for c in payload] + + def test_triggers(self, probed_backend, capsys): + main( + [ + "introspect", + "triggers", + "-o", + "json", + *_connection_argv(probed_backend.config), + ] + ) + payload = json.loads(capsys.readouterr().out) + assert isinstance(payload, list) and payload + # The probe trigger, by name and by the table it fires on. + names = [row.get("name") for row in payload] + assert PROBE_TRIGGER in names + row = [r for r in payload if r.get("name") == PROBE_TRIGGER][0] + assert row.get("table_name") == PROBE_TABLE + + def test_triggers_carry_their_events_and_their_status(self, probed_backend, capsys): + """The firing event and ``is_disabled`` are on the row, not just selected. + + ``events`` used to be ``[]`` for every trigger and ``status`` the + dataclass default ``ENABLED`` for every trigger, because the parser + wrote both rather than reading what the statement selected. + """ + main( + [ + "introspect", + "triggers", + "-o", + "json", + *_connection_argv(probed_backend.config), + ] + ) + payload = json.loads(capsys.readouterr().out) + enabled = [r for r in payload if r.get("name") == PROBE_TRIGGER][0] + assert enabled["events"] == ["INSERT"] + assert enabled["status"] == "ENABLED" + disabled = [r for r in payload if r.get("name") == PROBE_TRIGGER_DISABLED][0] + assert disabled["events"] == ["UPDATE"] + assert disabled["status"] == "DISABLED" + + def test_schemas(self, probed_backend, capsys): + main( + [ + "introspect", + "schemas", + "-o", + "json", + *_connection_argv(probed_backend.config), + ] + ) + payload = json.loads(capsys.readouterr().out) + assert isinstance(payload, list) and payload + assert any(row["name"] == "dbo" for row in payload) + + def test_extended_properties_by_table_name(self, probed_backend, capsys): + """``extended-properties NAME`` is the only way the CLI reads a comment.""" + main( + [ + "introspect", + "extended-properties", + PROBE_TABLE, + "-o", + "json", + *_connection_argv(probed_backend.config), + ] + ) + payload = json.loads(capsys.readouterr().out) + assert isinstance(payload, list) and payload + property_row = [r for r in payload if r["name"] == "MS_Description"][0] + assert property_row["value"] == PROBE_DESCRIPTION + + def test_extended_properties_of_an_unknown_table_exits_one(self, probed_backend): + """The empty answer is checked against the catalogue, so a typo is an error.""" + with pytest.raises(SystemExit) as exc: + main( + [ + "introspect", + "extended-properties", + "zz_no_such_table", + "-o", + "json", + *_connection_argv(probed_backend.config), + ] + ) + assert exc.value.code == 1 + + def test_procedures(self, probed_backend, capsys): + main( + [ + "introspect", + "procedures", + "-o", + "json", + *_connection_argv(probed_backend.config), + ] + ) + payload = json.loads(capsys.readouterr().out) + assert isinstance(payload, list) and payload + assert any(row["name"] == PROBE_PROCEDURE for row in payload) + + def test_functions(self, probed_backend, capsys): + main( + [ + "introspect", + "functions", + "-o", + "json", + *_connection_argv(probed_backend.config), + ] + ) + payload = json.loads(capsys.readouterr().out) + assert isinstance(payload, list) and payload + assert any(row["name"] == PROBE_FUNCTION for row in payload) + + def test_sequences(self, probed_backend, capsys): + main( + [ + "introspect", + "sequences", + "-o", + "json", + *_connection_argv(probed_backend.config), + ] + ) + payload = json.loads(capsys.readouterr().out) + assert isinstance(payload, list) and payload + row = [r for r in payload if r["name"] == PROBE_SEQUENCE][0] + # Decimal renders as text through the JSON provider; 5 and 2 are the + # declared start and increment, so a wrong answer changes the text. + assert row["start_value"] == "5" + assert row["increment"] == "2" + + def test_database(self, probed_backend, capsys): + main( + [ + "introspect", + "database", + "-o", + "json", + *_connection_argv(probed_backend.config), + ] + ) + payload = json.loads(capsys.readouterr().out) + assert payload + # ``get_database_info`` answers with the database's own name and the + # version string the server reports (``15.0.4465`` and up). + assert payload[0]["name"] + assert payload[0]["version"].count(".") >= 2 diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_returning_output.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_returning_output.py index e3fd9fb..09a2073 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_returning_output.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_returning_output.py @@ -5,9 +5,10 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.core import ( + AllColumnsWildcard, Column, FunctionCall, - WildcardExpression, + RowCountWildcard, ) from rhosocial.activerecord.backend.expression.statements import ReturningClause from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect @@ -74,7 +75,18 @@ def test_rejects_non_column_expression(self, dialect): dialect.format_returning_clause(clause) def test_rejects_wildcard(self, dialect): - clause = ReturningClause(dialect, expressions=[WildcardExpression(dialect)]) + clause = ReturningClause(dialect, expressions=[AllColumnsWildcard(dialect)]) + with pytest.raises(UnsupportedFeatureError, match="wildcard in OUTPUT"): + dialect.format_returning_clause(clause) + + def test_rejects_row_count_wildcard(self, dialect): + """The COUNT-side asterisk is refused by name too, not generically. + + ``RowCountWildcard`` belongs to COUNT's argument; in an OUTPUT list it is + no more legal than the select-list expansion, and the refusal names the + wildcard rather than falling through to the generic expression message. + """ + clause = ReturningClause(dialect, expressions=[RowCountWildcard(dialect)]) with pytest.raises(UnsupportedFeatureError, match="wildcard in OUTPUT"): dialect.format_returning_clause(clause) 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 fc738b3..35d5d13 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 @@ -15,12 +15,12 @@ from rhosocial.activerecord.backend.expression.objects import NodeTable, Table from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, - CreateTableExpression, + CreateTableStatement, ) 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, + SQLServerCreateTableStatement, SQLServerForPathTable, SQLServerGraphDirection, SQLServerGraphEdgeRef, @@ -292,7 +292,7 @@ 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 = SQLServerCreateTableExpression( + expr = SQLServerCreateTableStatement( dialect, Table(dialect, "graph_table"), columns=[ColumnDefinition(dialect, "ID", IntegerType(dialect))], graph_table_kind=kind, @@ -329,7 +329,7 @@ def test_edge_constraint(self, major, name, expected): ) def test_create_graph_table(self, major, kind, expected): dialect = SQLServerDialect(version=(major, 0, 0)) - expr = SQLServerCreateTableExpression( + expr = SQLServerCreateTableStatement( dialect, Table(dialect, "graph_table"), columns=[ColumnDefinition(dialect, "ID", IntegerType(dialect))], graph_table_kind=kind, @@ -339,7 +339,7 @@ def test_create_graph_table(self, major, kind, 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 = SQLServerCreateTableExpression( + expr = SQLServerCreateTableStatement( dialect, Table(dialect, "friend"), columns=[ColumnDefinition(dialect, "ID", IntegerType(dialect))], graph_table_kind=SQLServerGraphTableKind.EDGE, diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/expression/expression_parse_contract.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/expression/expression_parse_contract.py new file mode 100644 index 0000000..aef7041 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/expression/expression_parse_contract.py @@ -0,0 +1,185 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/expression/expression_parse_contract.py +"""The parse-vs-format round-trip sweep for typed-column contracts. + +Vendored from the shared testsuite's ``utils/parse_contract.py`` when that +suite's ``fix/column-constraint-gates`` branch dropped ``utils/expression`` +in ``e05edb0`` with the direction "round-trip coverage moves to the owning +repository": this module imported ``make_instance`` from the deleted harness, +and a dead import is a dead test. The sweep itself is unchanged -- the same two +invariants over the same registry -- and the one dependency is now the harness +this repository owns beside it. Fix drift here deliberately; do not re-import +it from the suite. + +Every dialect's ``parse_type`` is the introspection inverse of its +``format_data_type_`` renderings. Two invariants make a dialect coherent, +and this module checks both over a backend's full type registry: + +**String stability** -- parsing a rendering and re-rendering the answer +must produce the identical string. Whatever ``parse`` answers, its answer +must name the same storage the server holds, or the schema differ would +report a change no one made. + +**Class honesty** -- the answer is either the declared instance itself +(``parsed == declared``, the round-trip-equal case) or the *documented* +answer class for that declaration: a backend that widens a declaration on +the way in (four integer widths stored as one integer type, a JSONB +concept stored as the JSON type) answers with the concept that was *not* +widened, and that answer is recorded here once per backend rather than +being re-derived by every test. + +What the sweep deliberately does **not** do: + +* It does not assert anything about a class the registry cannot construct + or cannot render -- those are the round-trip matrix's own lists + (``UNCONSTRUCTIBLE`` / pinned non-renders) and have their own tests. +* It does not guess what ``CustomType`` should parse to -- a + ``CustomType``'s raw is caller-supplied text that may happen to be a + word the dialect knows, so the class is skipped by default. +* It does not accept a ``CustomType`` answer for a rendered word. A + dialect that renders a word its own parse does not model has a gap on + one side or the other, and the sweep says so instead of blessing it. +""" + +from typing import Any, Dict, Optional + +from rhosocial.activerecord.backend.expression.types import CustomType, DataType + +# The harness is this module's sibling, and this module is itself loaded by path +# (from ``test_sqlserver_type_protocol.py``) as well as by name (from the matrix +# beside it) -- a by-name import would only work in the second case, so the +# sibling is loaded by path and both callers get the same module. +import importlib.util as _importlib_util +from pathlib import Path as _Path + +_harness_path = _Path(__file__).parent / "expression_roundtrip_harness.py" +_spec = _importlib_util.spec_from_file_location( + "sqlserver_roundtrip_harness", _harness_path +) +_harness = _importlib_util.module_from_spec(_spec) +_spec.loader.exec_module(_harness) + +make_instance = _harness.make_instance + + +def parse_roundtrip_failures( + dialect: Any, + registry: Dict[str, Any], + *, + widening: Optional[Dict[str, Any]] = None, + skip: Any = (), +) -> Any: + """Sweep *registry* and return the parse round-trip failures as text. + + Args: + dialect: the dialect whose ``parse_type`` is under test. + registry: a mapping of fully-qualified name to class, as the + backend's round-trip module keeps it (``REGISTERED`` or + ``ALL_CLASSES``). Only concrete ``DataType`` subclasses are + swept. + widening: the backend's documented answer table, + ``{declared class name: answer}``. An entry here asserts the + parse answers with exactly that class and that the answer + re-renders the identical string, **unless** the value is a + ``(class name, expected string)`` tuple: that form is for the + documented answers whose re-render deliberately *differs* -- a + backend that stores a word as its documented alias (Snowflake + writes TINYINT and stores INTEGER) or completes a bare word + with the server's default (its bare VARCHAR *is* + VARCHAR(16777216)). The tuple pins both the answer class and + the string it re-renders, so the alias is recorded as a + reviewable fact rather than leaking out as string instability. + The reason for each entry belongs in the backend's test file + next to the table, where a reader can check it. + skip: class names to leave out of the sweep entirely, in addition + to the built-in ``CustomType`` skip. Use it only for classes + that are not column types at all (a CAST target, say) and say + why in the test file. + + Returns: + A list of human-readable failures. An empty list is the contract + met; each element names the class, the rendering and the fault, + so a red test points straight at the offending entry instead of + leaving the reader to bisect. + """ + widening = dict(widening or {}) + skipped = set(skip) | {"CustomType"} + failures = [] + for fqn in sorted(registry): + cls = registry[fqn] + if not (isinstance(cls, type) and issubclass(cls, DataType)): + continue + if cls is DataType or cls is CustomType: + continue + if cls.__name__ in skipped: + continue + instance, source = make_instance(cls, dialect) + if instance is None: + continue # the matrix's UNCONSTRUCTIBLE list owns this class + try: + rendered, _params = instance.to_sql() + except Exception: + continue # the matrix's pinned non-renders own this class + try: + parsed = dialect.parse_type(rendered) + except Exception as exc: + failures.append( + "%s renders %r but parse_type raises %s: %s" + % (cls.__name__, rendered, type(exc).__name__, exc) + ) + continue + if isinstance(parsed, CustomType): + failures.append( + "%s renders %r but parse_type answers CustomType -- a " + "dialect must recognise its own renderings" + % (cls.__name__, rendered) + ) + continue + expected_entry = widening.get(cls.__name__) + if expected_entry is None: + if parsed != instance: + failures.append( + "%s renders %r but parse_type answers %r -- neither " + "the declaration nor a documented widening" + % (cls.__name__, rendered, parsed) + ) + continue + if isinstance(expected_entry, tuple): + expected, expected_restring = expected_entry + else: + expected, expected_restring = expected_entry, None + if type(parsed).__name__ != expected: + failures.append( + "%s renders %r; the documented answer is %s but " + "parse_type answers %s" + % (cls.__name__, rendered, expected, type(parsed).__name__) + ) + continue + try: + re_rendered, _ = parsed.to_sql() + except Exception as exc: + failures.append( + "%s renders %r; the documented answer %s re-renders with " + "%s: %s" + % (cls.__name__, rendered, expected, type(exc).__name__, exc) + ) + continue + if expected_restring is not None: + if re_rendered != expected_restring: + failures.append( + "%s renders %r; the documented answer %s re-renders " + "%r but the table says %r" + % (cls.__name__, rendered, expected, re_rendered, + expected_restring) + ) + elif re_rendered != rendered: + failures.append( + "%s renders %r but the documented answer %s re-renders " + "%r -- the strings must name the same storage, or the " + "table entry must record the documented re-render as a " + "(class, string) tuple" + % (cls.__name__, rendered, expected, re_rendered) + ) + return failures + + +__all__ = ["parse_roundtrip_failures"] diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/expression/expression_roundtrip_harness.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/expression/expression_roundtrip_harness.py new file mode 100644 index 0000000..11aef69 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/expression/expression_roundtrip_harness.py @@ -0,0 +1,1130 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/expression/expression_roundtrip_harness.py +""" +Expression serialization round-trip / contract test helpers (SQL Server-local copy). + +Vendored from the shared testsuite at +``python-activerecord-testsuite@4ef2551`` (formerly +``src/rhosocial/activerecord/testsuite/utils/expression.py``) when that suite's +``fix/column-constraint-gates`` branch dropped the module in ``e05edb0`` with +the direction "round-trip coverage moves to the owning repository": the suite +keeps the ActiveRecord-layer contracts and must not grow an opinion about +which expressions exist, so each backend now owns the harness its own matrix +runs on. Fix drift here deliberately; do not re-import it from the suite. + +This module is the test-only home (NOT shipped as part of the core library's +runtime API) for constructing expression instances and asserting they survive +all three serializations. + +After registering a class into the ExpressionRegistry, expression instances +must satisfy: + +- ``get_params()`` reflects every ``__init__`` parameter +- A round-trip through the three encoding channels returns a structurally + equivalent instance (``get_params()`` equal) +- When the chosen backend's dialect supports ``to_sql()``, the SQL keyword + rendering is identical +""" + +import collections.abc +import dataclasses +import inspect +import re +import typing +import warnings +from enum import Enum +from typing import Any, Callable, Dict, Type + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +# Annotation fragments that mean "this parameter names a relation object". +# Word boundaries matter: ``TableConstraint`` and ``AddTableConstraint`` are +# constraint actions, not relations, and a substring test would hand them a +# Table. ``SchemaObject`` is the base every catalogue object shares, so an +# annotation naming it wants an object too. +_MENTIONS_RELATION = re.compile( + r"\b(Table|View|MaterializedView|Index|Sequence|Type|Domain|Function|Trigger" + r"|Schema|Database|RelationObject|SchemaObject|NodeTable|EdgeTable)\b" +) + +# An INSERT's data source. It is an expression now, so it needs a real one -- +# the generic placeholder cannot invent the rows a VALUES list is made of. +_MENTIONS_INSERT_SOURCE = re.compile(r"\bInsertDataSource\b") + + +def _empty_container_for(annotation: Any, dialect: Any): + """Return an empty container for a parameterised alias, else None. + + A parameterised alias like ``List[str]`` or ``Dict[str, BaseExpression]`` + says what the *elements* are, never the container itself, so there is no + catalogue object to hand back -- only an empty container. Deciding this + before the relation-name test is what keeps ``typing.List[str]`` from being + read as a ``Table``: ``typing.get_origin`` on it gives ``list``, but the + relation test only ever saw the alias' own name, and ``List[str]`` matches + ``Sequence``/``Table``-family fragments well enough to be mistaken for one. + + Returns ``None`` when ``annotation`` is not a parameterised container, which + is the caller's signal to keep reading. + """ + origin = typing.get_origin(annotation) + if origin is None: + return None + if origin in (list, tuple, set, frozenset): + return [] + if origin is dict: + return {} + # ``Sequence[str]`` and friends resolve to the collections.abc ABC rather + # than to ``list``, so match on the abstract base instead of the concrete + # one the annotation happens to name. + if isinstance(origin, type) and issubclass(origin, (collections.abc.Sequence, collections.abc.Set)): + return [] + if isinstance(origin, type) and issubclass(origin, collections.abc.Mapping): + return {} + return None + + +def _mentions_expression_class(annotation: Any, raw: Any) -> bool: + """Whether the annotation names an expression node, whatever it is called. + + This used to be a substring test for ``"Expression"``, which is a coupling + to the class *names*: renaming ``QueryExpression`` to ``QueryStatement`` + (规范 4.9: a statement is a statement) silently stopped matching, the + parameter fell through to the ``"x"`` fallback, and the failure surfaced + later as ``'str' object has no attribute 'to_sql'`` from inside a + formatter. The structural question -- does this annotation name a + ``BaseExpression`` subclass? -- does not care what the class is called. + """ + from rhosocial.activerecord.backend.expression.bases import BaseExpression + + candidates = [raw] + if typing.get_origin(annotation) is not None: + candidates.extend(typing.get_args(annotation)) + for candidate in candidates: + if isinstance(candidate, type) and issubclass(candidate, BaseExpression): + return True + return False + + +def _is_predicate_class(annotation: Any, raw: Any) -> bool: + """Whether the annotation names a predicate node, whatever it is called. + + Same structural question as :func:`_mentions_expression_class` answers for + expressions, asked of the predicate family: a rename inside the family must + not change what this heuristic builds either. + """ + from rhosocial.activerecord.backend.expression.bases import SQLPredicate + + for candidate in (raw, annotation): + if isinstance(candidate, type) and issubclass(candidate, SQLPredicate): + return True + return False + + +def _placeholder_for(param: inspect.Parameter, dialect: Any = None): + """Return a heuristic construction value for a required parameter.""" + annotation = param.annotation + if annotation is not inspect.Parameter.empty: + origin = typing.get_origin(annotation) + raw = annotation if origin is None else origin + if isinstance(annotation, str): + text = annotation + else: + text = getattr(raw, "__name__", "") + container = _empty_container_for(annotation, dialect) + if container is not None: + return container + if raw in (list, tuple, set): + return [] + if raw is dict: + return {} + if _MENTIONS_RELATION.search(text): + return _relation(dialect, text) + if _MENTIONS_INSERT_SOURCE.search(text): + return _values_source(dialect) + if raw is str or "str" in text: + return "x" + # A typed value expression wraps the call it renders, so the parameter + # wants a FunctionCall and not a bare name. This must be tested before + # the str branch above: "FunctionCall" contains no "str", but the + # fallback below returns "x" for it, which the wrapper then calls + # to_sql() on. + if "FunctionCall" in text: + return _function_call(dialect) + if "DataType" in text or "Type" in text.split(".")[-1:]: + from rhosocial.activerecord.backend.expression.types import IntegerType + + return IntegerType(dialect) + if raw is int: + return 1 + if raw is float: + return 1.0 + if raw is bool: + return True + # A predicate slot wants a predicate, not a value. SQLPredicate is a + # BaseExpression subclass like the values are, so the structural + # expression branch below hands it a Literal -- and the constructors + # that type a parameter as a predicate refuse a Literal on their + # isinstance check (core's DomainCheckConstraint.condition is the one + # in the matrix today). Asked structurally when the annotation + # resolves; the name test beside it covers forward references. + if (not isinstance(annotation, str) and _is_predicate_class(annotation, raw)) or ( + "Predicate" in text + ): + return _comparison(dialect) + # Asked structurally when the annotation resolves: what a class is + # called says nothing about what it is, and a rename must not change + # what this heuristic builds. The substring tests beside it remain for + # forward references, which introspection cannot resolve -- and they + # must list every name the library might grow, which is exactly why + # the structural test is the one that leads. + if ( + "Expression" in text + or "Subquery" in text + or "Statement" in text + or (not isinstance(annotation, str) and _mentions_expression_class(annotation, raw)) + ): + return _literal(dialect) + + name = param.name + if name in ("value",): + return 1 + if name in ("values", "columns", "args", "params", "predicates", "expressions"): + return [] + if name in ("left", "right") or "predicate" in name or "condition" in name: + return _comparison(dialect) + if "expression" in name or name in ("expr", "operand", "subquery", "query"): + return _literal(dialect) + return "x" + + +def _literal(dialect): + from rhosocial.activerecord.backend.expression.core import Literal + + return Literal(dialect, 1) + + +def _function_call(dialect): + """A niladic function call, for a parameter that wraps one. + + A value-wrapping node holds the ``call`` it renders through ``to_sql()``. + Handing such a parameter a string builds an instance that cannot render, + and the failure surfaces as ``'str' object has no attribute 'to_sql'`` + from inside the wrapper rather than as a construction problem. + """ + from rhosocial.activerecord.backend.expression.core import FunctionCall + + return FunctionCall(dialect, "FUNC", niladic=True) + + +def _comparison(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate + + return ComparisonPredicate(dialect, "=", Column(dialect, "a"), _literal(dialect)) + + +def _try_construct(cls, dialect): + """Construct an instance of cls with heuristic arguments; None on failure.""" + sig = inspect.signature(cls.__init__) + # DataType-family declares dialect as an optional trailing keyword. + has_dialect = "dialect" in sig.parameters + first_param = next((p for p in sig.parameters if p != "self"), None) + dialect_as_kw = first_param != "dialect" + args = [] + kwargs = {} + skipped_defaulted_positional = False + for pname, param in sig.parameters.items(): + if pname in ("self", "dialect"): + continue + if param.kind == inspect.Parameter.VAR_KEYWORD: + continue + if param.kind == inspect.Parameter.VAR_POSITIONAL: + if not skipped_defaulted_positional: + args.append(_literal(dialect)) + continue + if param.default is not inspect.Parameter.empty: + if param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: + skipped_defaulted_positional = True + continue + placeholder = _placeholder_for(param, dialect) + # placeholder None → default case; real value provided otherwise + if param.kind == inspect.Parameter.KEYWORD_ONLY: + kwargs[pname] = placeholder + else: + args.append(placeholder) + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + if not has_dialect: + return cls(*args, **kwargs) + if dialect_as_kw: + return cls(*args, dialect=dialect, **kwargs) + return cls(dialect, *args, **kwargs) + except Exception: + return None + + +def special_constructors(): + """Explicit constructors for classes whose ``__init__`` rejects heuristic + placeholder values. Keyed by a suffix that matches the class's full + module.ClassName. Registered by name only — this is a static registry and + backends may register their own entries for backend-specific expressions. + + **A key that matches no class is dead code, and nothing says so.** + :func:`make_instance` dispatches on ``full_name.endswith(key)``, so + renaming a class turns its entry into a literal that matches nothing: the + class then falls through to :func:`_try_construct`, the source is reported + as ``"heuristic"`` instead of ``"special"``, and if the heuristic also + fails the class is silently reported as unconstructible rather than as + mis-registered. This is how ``"advanced_functions.JSONExpression"`` came to + match nothing at all once core split that one class into + ``JSONDocumentValue`` and ``JSONTextValue``. + + The failure is one-directional, which is what makes it dangerous: renaming + the key once breaks the match silently, and a *single* key covering both + new names is not expressible, because ``endswith`` compares a suffix. A + class that was split needs one entry per resulting class. + + :func:`register_special_constructor` writes to the module-level + ``_SPECIAL_REGISTRY``, which is what :func:`make_instance` reads. Note that + this function rebuilds its dict on every call, so a registration made + elsewhere is **not** visible to a caller that invokes + ``special_constructors()`` directly — core's expression-contract test does + exactly that, and therefore sees only the literals below. + """ + return { + "statements.ddl_table.ColumnDefinition": _column_definition, + "statements.ddl_table.ColumnConstraint": _column_constraint, + "statements.ddl_table.ForeignKeyConstraint": _foreign_key_constraint, + "statements.ddl_table.TableConstraint": _table_constraint, + "statements.ddl_table.ColumnForeignKeyConstraint": _column_fk_constraint, + "statements.ddl_table.TableForeignKeyConstraint": _table_fk_constraint, + "statements.ddl_table.TablePrimaryKeyConstraint": _table_primary_key, + "statements.ddl_table.TableUniqueConstraint": _table_unique, + "statements.dml.ValuesSource": _values_source, + "statements.ddl_sequence.CreateSequenceStatement": _create_sequence_expr, + "statements.ddl_sequence.DropSequenceStatement": _drop_sequence_expr, + "statements.ddl_sequence.AlterSequenceStatement": _alter_sequence_expr, + "statements.ddl_type.CreateTypeStatement": _create_type_expr, + "statements.ddl_type.AlterTypeStatement": _alter_type_expr, + "statements.ddl_type.DropTypeStatement": _drop_type_expr, + "statements.ddl_domain.CreateDomainStatement": _create_domain_expr, + "statements.ddl_domain.AlterDomainStatement": _alter_domain_expr, + "statements.ddl_domain.DropDomainStatement": _drop_domain_expr, + "statements.ddl_function.CreateFunctionStatement": _create_function_expr, + "statements.ddl_function.DropFunctionStatement": _drop_function_expr, + "statements.ddl_schema.CreateSchemaStatement": _create_schema_expr, + "statements.ddl_schema.DropSchemaStatement": _drop_schema_expr, + "statements.ddl_database.CreateDatabaseStatement": _create_database_expr, + "statements.ddl_database.DropDatabaseStatement": _drop_database_expr, + # core splits what was one JSONExpression into a document and a text + # class, so one key cannot cover both: endswith matches a suffix. + "advanced_functions.JSONDocumentValue": _json_document_expr, + "advanced_functions.JSONTextValue": _json_text_expr, + "query_parts.JoinClause": _join_expr, + "statements.ddl_partition.PartitionClause": _partition_clause, + "statements.dml.OnConflictClause": _on_conflict, + "statements.dml.UpdateStatement": _update_expr, + "statements.dml.InsertStatement": _insert_expr, + "statements.dml.DeleteStatement": _delete_expr, + "statements.ddl_alter.AlterTableStatement": _alter_table_expr, + "types.array.ArrayType": _array_type, + "statements.ddl_view.CreateViewStatement": _create_view_expr, + "graph.GraphVertex": _graph_vertex, + "graph.GraphEdge": _graph_edge, + "graph.VertexTable": _node_table, + "graph.EdgeTableDefinition": _edge_table, + "graph.GraphTableExpression": _graph_table_expr, + "graph.CreatePropertyGraphStatement": _create_property_graph_expr, + "graph.DropPropertyGraphStatement": _drop_property_graph_expr, + "graph.AlterPropertyGraphStatement": _alter_property_graph_expr, + "datetime.ExtractExpression": _extract_expr, + "datetime.DatePartExpression": _datepart_expr, + "datetime.DateTruncExpression": _datetrunc_expr, + "datetime.IntervalExpression": _interval_expr, + "datetime.DatetimeDiffExpression": _datetime_diff_expr, + "datetime.DatetimeSubtractExpression": _datetime_sub_expr, + "datetime.DatetimeAddExpression": _datetime_add_expr, + } + + +def _column_definition(dialect): + from rhosocial.activerecord.backend.expression.statements import ColumnDefinition + from rhosocial.activerecord.backend.expression.types import IntegerType + + return ColumnDefinition(dialect, "col", IntegerType(dialect)) + + +# ---------------------------------------------------------------- constraint nodes +# A constraint is a clause plus the branch that says which kind it is, so the +# two levels need a branch each: the clause cannot be built without saying what +# it constrains, and the branches whose payload is a relation or a column list +# need one the heuristic guess has no way to supply. + + +def _column_constraint(dialect): + from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, + ColumnNotNullConstraint, + ) + + return ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)) + + +def _foreign_key_constraint(dialect): + """The construction sugar over a table-level FOREIGN KEY branch. + + Same reason as the branch itself: what it points at has to be a real + ``Table``, which a guessed placeholder cannot be. + """ + from rhosocial.activerecord.backend.expression.objects import Table + from rhosocial.activerecord.backend.expression.statements import ( + ForeignKeyConstraint, + ) + + return ForeignKeyConstraint( + dialect, + columns=["col"], + foreign_key_table=Table(dialect, "parent"), + foreign_key_columns=["id"], + ) + + +def _table_constraint(dialect): + from rhosocial.activerecord.backend.expression.statements import ( + TableConstraint, + TableUniqueConstraint, + ) + + return TableConstraint(dialect, branch=TableUniqueConstraint(dialect, ["col"])) + + +def _column_fk_constraint(dialect): + from rhosocial.activerecord.backend.expression.objects import Table + from rhosocial.activerecord.backend.expression.statements import ( + ColumnForeignKeyConstraint, + ) + + return ColumnForeignKeyConstraint(dialect, (Table(dialect, "parent"), ["id"])) + + +def _table_fk_constraint(dialect): + from rhosocial.activerecord.backend.expression.objects import Table + from rhosocial.activerecord.backend.expression.statements import ( + TableForeignKeyConstraint, + ) + + return TableForeignKeyConstraint(dialect, ["col"], (Table(dialect, "parent"), ["id"])) + + +def _table_primary_key(dialect): + from rhosocial.activerecord.backend.expression.statements import ( + TablePrimaryKeyConstraint, + ) + + return TablePrimaryKeyConstraint(dialect, ["col"]) + + +def _table_unique(dialect): + from rhosocial.activerecord.backend.expression.statements import TableUniqueConstraint + + return TableUniqueConstraint(dialect, ["col"]) + + +def _json_document_expr(dialect): + """``->``: a JSON *document*, reached by path. + + ``path`` and ``operation`` keep their defaults, so this renders exactly + what the pre-split single ``JSONExpression`` factory rendered. + """ + from rhosocial.activerecord.backend.expression.advanced_functions import ( + JSONDocumentValue, + ) + from rhosocial.activerecord.backend.expression.core import Literal + + return JSONDocumentValue(dialect, Literal(dialect, '{"a": 1}'), path="$.a") + + +def _json_text_expr(dialect): + """``->>``: the *text* of a JSON path. + + ``JSONTextValue`` declares no ``__init__`` of its own, so it inherits + the document class's -- including ``operation="->"``. The default is + therefore inherited too, and this factory has to say ``->>`` explicitly. + Leaving it off would hand the contract test a ``JSONTextValue`` whose + ``operation`` says ``->``, which is the one thing the class exists to + distinguish. + + The rendered SQL is unaffected either way: the formatters branch on + ``expr.operation``, not on the class, and dispatch is on ``format_method``, + which both classes share. + """ + from rhosocial.activerecord.backend.expression.advanced_functions import ( + JSONTextValue, + ) + from rhosocial.activerecord.backend.expression.core import Literal + + return JSONTextValue( + dialect, Literal(dialect, '{"a": 1}'), path="$.a", operation="->>" + ) + + +def _values_source(dialect): + """A ``VALUES`` clause with one row of one column. + + ``ValuesSource`` rejects an empty ``values_list``, and the heuristic filler + returns ``[]`` for any ``list``-origin annotation -- which is why this class + needs an explicit constructor at all. ``[]`` would also be the wrong thing + to satisfy it with: a ``VALUES`` clause with no rows is not an INSERT + source, and the zero-row spelling already has its own class, + ``DefaultValuesSource``, sitting next to this one. + """ + from rhosocial.activerecord.backend.expression.core import Literal + from rhosocial.activerecord.backend.expression.statements.dml import ValuesSource + + return ValuesSource(dialect, [[Literal(dialect, 1)]]) + + +def _array_type(dialect): + """An array of integers -- one dimension. + + ``ArrayType`` requires an ``element_type`` and raises ``TypeError`` without + one ("an array with no element type is not a type"). Two things stop the + heuristic filler from reaching that requirement, and neither is fixable by + giving the parameter a default: + + * the filler skips defaulted parameters, so it calls ``ArrayType(dialect)`` + with nothing else; and + * ``dialect`` must come first and ``element_type`` must follow it, so a + *required* ``element_type`` after a defaulted ``dialect`` is not + expressible in Python at all. + + Making the parameter required therefore cannot help, and a default of + ``None`` is already what core declares. So the element type has to be + supplied here, as a concrete ``DataType``. + """ + from rhosocial.activerecord.backend.expression.types import ( + ArrayType, + IntegerType, + ) + + return ArrayType(dialect, element_type=IntegerType(dialect)) + + +def _sequence(dialect): + """A sequence, by kind.""" + from rhosocial.activerecord.backend.expression.objects import Sequence + + return Sequence(dialect, "s") + + +def _create_sequence_expr(dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_sequence import ( + CreateSequenceStatement, + ) + + return CreateSequenceStatement(dialect, _sequence(dialect)) + + +def _drop_sequence_expr(dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_sequence import ( + DropSequenceStatement, + ) + + return DropSequenceStatement(dialect, _sequence(dialect)) + + +def _alter_sequence_expr(dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_sequence import ( + AlterSequenceStatement, + ) + + return AlterSequenceStatement(dialect, _sequence(dialect)) + + +def _create_type_expr(dialect): + from rhosocial.activerecord.backend.impl.dummy.expression import _DummyTypeDefinition + from rhosocial.activerecord.backend.expression.objects import Type + from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + CreateTypeStatement, + ) + from rhosocial.activerecord.backend.expression.types import IntegerType + + return CreateTypeStatement( + dialect, Type(dialect, "t"), _DummyTypeDefinition(dialect, IntegerType(dialect)) + ) + + +def _alter_type_expr(dialect): + from rhosocial.activerecord.backend.impl.dummy.expression import _DummyTypeAlterAction + from rhosocial.activerecord.backend.expression.objects import Type + from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + AlterTypeStatement, + ) + + return AlterTypeStatement( + dialect, Type(dialect, "t"), [_DummyTypeAlterAction(dialect, "t2")] + ) + + +def _drop_type_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Type + from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + DropTypeStatement, + ) + + return DropTypeStatement(dialect, Type(dialect, "t")) + + +def _create_domain_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Domain + from rhosocial.activerecord.backend.expression.statements.ddl_domain import ( + CreateDomainStatement, + ) + from rhosocial.activerecord.backend.expression.types import IntegerType + + return CreateDomainStatement(dialect, Domain(dialect, "d"), IntegerType(dialect)) + + +def _alter_domain_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Literal + from rhosocial.activerecord.backend.expression.objects import Domain + from rhosocial.activerecord.backend.expression.statements.ddl_domain import ( + AlterDomainStatement, + SetDomainDefaultAction, + ) + + return AlterDomainStatement( + dialect, Domain(dialect, "d"), [SetDomainDefaultAction(dialect, Literal(dialect, 1))] + ) + + +def _drop_domain_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Domain + from rhosocial.activerecord.backend.expression.statements.ddl_domain import ( + DropDomainStatement, + ) + + return DropDomainStatement(dialect, Domain(dialect, "d")) + + +def _create_function_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Function + from rhosocial.activerecord.backend.expression.statements.ddl_function import ( + CreateFunctionStatement, + ) + + return CreateFunctionStatement(dialect, Function(dialect, "f")) + + +def _drop_function_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Function + from rhosocial.activerecord.backend.expression.statements.ddl_function import ( + DropFunctionStatement, + ) + + return DropFunctionStatement(dialect, Function(dialect, "f")) + + +def _create_schema_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Schema + from rhosocial.activerecord.backend.expression.statements.ddl_schema import ( + CreateSchemaStatement, + ) + + return CreateSchemaStatement(dialect, Schema(dialect, "s")) + + +def _drop_schema_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Schema + from rhosocial.activerecord.backend.expression.statements.ddl_schema import ( + DropSchemaStatement, + ) + + return DropSchemaStatement(dialect, Schema(dialect, "s")) + + +def _create_database_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Database + from rhosocial.activerecord.backend.expression.statements.ddl_database import ( + CreateDatabaseStatement, + ) + + return CreateDatabaseStatement(dialect, Database(dialect, "d")) + + +def _drop_database_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Database + from rhosocial.activerecord.backend.expression.statements.ddl_database import ( + DropDatabaseStatement, + ) + + return DropDatabaseStatement(dialect, Database(dialect, "d")) + + +def _node_table(dialect): + """A graph vertex table.""" + from rhosocial.activerecord.backend.expression.graph import VertexTable + from rhosocial.activerecord.backend.expression.objects import NodeTable + + return VertexTable(dialect, NodeTable(dialect, "n")) + + +def _edge_table(dialect): + """A graph edge table declaration. + + Core renamed the declaration class to ``EdgeTableDefinition`` and moved the + catalogue object -- still called ``EdgeTable`` -- into + ``expression.objects``; the two used to share one name in the graph + package. The registry key follows the class, so it carries the new + suffix; an entry that matches no class builds nothing and says so only as + a missing constructor, which is why the rename is done here too. + """ + from rhosocial.activerecord.backend.expression.graph import EdgeTableDefinition + from rhosocial.activerecord.backend.expression.objects import ( + EdgeTable as EdgeTableObject, + ) + + return EdgeTableDefinition(dialect, EdgeTableObject(dialect, "e"), ["s"], ["d"]) + + +def _graph_vertex(dialect): + from rhosocial.activerecord.backend.expression.graph import GraphVertex + from rhosocial.activerecord.backend.expression.objects import NodeTable + + return GraphVertex(dialect, "v", NodeTable(dialect, "n")) + + +def _graph_edge(dialect): + from rhosocial.activerecord.backend.expression.graph import ( + GraphEdge, + GraphEdgeDirection, + ) + from rhosocial.activerecord.backend.expression.objects import ( + EdgeTable as EdgeTableObject, + ) + + return GraphEdge(dialect, "e", EdgeTableObject(dialect, "e"), + GraphEdgeDirection.RIGHT) + + +def _graph_table_expr(dialect): + from rhosocial.activerecord.backend.expression.graph import ( + ColumnsClause, + GraphColumn, + GraphTableExpression, + MatchClause, + ) + from rhosocial.activerecord.backend.expression.objects import PropertyGraph + + return GraphTableExpression( + dialect, + PropertyGraph(dialect, "g"), + MatchClause( + dialect, + _graph_vertex(dialect), + _graph_edge(dialect), + _graph_vertex(dialect), + ), + ColumnsClause(dialect, GraphColumn("v", "name")), + ) + + +def _create_property_graph_expr(dialect): + from rhosocial.activerecord.backend.expression.graph import ( + CreatePropertyGraphStatement, + ) + from rhosocial.activerecord.backend.expression.objects import PropertyGraph + + return CreatePropertyGraphStatement( + dialect, PropertyGraph(dialect, "g"), [_node_table(dialect)] + ) + + +def _drop_property_graph_expr(dialect): + from rhosocial.activerecord.backend.expression.graph import ( + DropPropertyGraphStatement, + ) + from rhosocial.activerecord.backend.expression.objects import PropertyGraph + + return DropPropertyGraphStatement(dialect, PropertyGraph(dialect, "g")) + + +def _alter_property_graph_expr(dialect): + from rhosocial.activerecord.backend.expression.graph import ( + AlterPropertyGraphStatement, + ) + from rhosocial.activerecord.backend.expression.objects import PropertyGraph + + return AlterPropertyGraphStatement( + dialect, + PropertyGraph(dialect, "g"), + "ADD", + "VERTEX TABLES", + vertex_tables=[_node_table(dialect)], + ) + + +def _table(dialect, name="t"): + """The catalogue object a statement acts on.""" + from rhosocial.activerecord.backend.expression.objects import Table + + return Table(dialect, name) + + +def _relation(dialect, annotation): + """The object an annotation asks for, chosen by the kind it names. + + The kind is not decoration: CREATE VIEW will not accept a Table, and DROP + MATERIALIZED VIEW will not accept a View. Longest name first, because + ``MaterializedView`` contains ``View`` and the more specific kind is the one + an annotation naming it means. + """ + import rhosocial.activerecord.backend.expression.objects as objects + + for kind in ("MaterializedView", "ForeignTable", "NodeTable", "EdgeTable", + "Sequence", "Index", "View", "Table", "Type", "Domain", + "Function", "Trigger", "Schema", "Database"): + if re.search(rf"\b{kind}\b", annotation): + return getattr(objects, kind)(dialect, "t") + return _table(dialect) + + +def _source(dialect, name="t"): + """A row source over the object a statement acts on. + + A statement that *names* a relation takes the object; a statement that + *reads rows from* one takes a source over it. The two are not + interchangeable, so they get their own helper rather than one that guesses + which position it is filling. + """ + from rhosocial.activerecord.backend.expression.objects import Table + from rhosocial.activerecord.backend.expression.sources import NamedRelationRef + + return NamedRelationRef(dialect, Table(dialect, name)) + + +def _join_expr(dialect): + from rhosocial.activerecord.backend.expression.query_parts import JoinClause + from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate + from rhosocial.activerecord.backend.expression.core import Column + + return JoinClause( + dialect, + left_table=_source(dialect, "a"), + right_table=_source(dialect, "b"), + condition=ComparisonPredicate(dialect, "=", Column(dialect, "a"), Column(dialect, "b")), + ) + + +def _partition_clause(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.statements.ddl_partition import ( + PartitionClause, + PartitionStrategy, + ) + + return PartitionClause(dialect, method=PartitionStrategy.RANGE, keys=[Column(dialect, "created_at")]) + + +def _on_conflict(dialect): + from rhosocial.activerecord.backend.expression.statements.dml import OnConflictClause + + return OnConflictClause(dialect, do_nothing=True, conflict_target=["id"]) + + +def _insert_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Literal + from rhosocial.activerecord.backend.expression.statements.dml import ( + InsertStatement, + ValuesSource, + ) + + return InsertStatement(dialect, into=_table(dialect), source=ValuesSource(dialect, [[Literal(dialect, 1)]])) + + +def _delete_expr(dialect): + from rhosocial.activerecord.backend.expression.statements.dml import DeleteStatement + + return DeleteStatement(dialect, tables=_table(dialect)) + + +def _update_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Literal + from rhosocial.activerecord.backend.expression.statements.dml import UpdateStatement + + return UpdateStatement(dialect, table=_table(dialect), assignments={"a": Literal(dialect, 1)}) + + +def _alter_table_expr(dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + AddColumnAction, + AlterTableStatement, + DropColumnAction, + ) + + column = _column_definition(dialect) + return AlterTableStatement( + dialect, + table=_table(dialect, "t"), + actions=[DropColumnAction(dialect, "a"), AddColumnAction(dialect, column=column)], + ) + + +def _create_view_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.objects import View + from rhosocial.activerecord.backend.expression.statements.ddl_view import CreateViewStatement + from rhosocial.activerecord.backend.expression.statements.dql import QueryStatement + + query = QueryStatement(dialect, select=[Column(dialect, "id")], from_=_source(dialect, "t")) + return CreateViewStatement(dialect, view=View(dialect, "v"), query=query) + + +def _extract_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.datetime import ExtractExpression + + return ExtractExpression(dialect, "YEAR", Column(dialect, "created_at")) + + +def _datepart_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.datetime import DatePartExpression + + return DatePartExpression(dialect, "YEAR", Column(dialect, "created_at")) + + +def _datetrunc_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.datetime import DateTruncExpression + + return DateTruncExpression(dialect, "year", Column(dialect, "created_at")) + + +def _interval_expr(dialect): + from rhosocial.activerecord.backend.expression.datetime import IntervalExpression + + return IntervalExpression(dialect, 7, "day") + + +def _datetime_diff_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.datetime import DatetimeDiffExpression + + return DatetimeDiffExpression(dialect, "day", Column(dialect, "a"), Column(dialect, "b")) + + +def _datetime_sub_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.datetime import ( + DatetimeSubtractExpression, + IntervalExpression, + ) + + return DatetimeSubtractExpression(dialect, Column(dialect, "a"), IntervalExpression(dialect, 1, "day")) + + +def _datetime_add_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.datetime import ( + DatetimeAddExpression, + IntervalExpression, + ) + + return DatetimeAddExpression(dialect, Column(dialect, "a"), IntervalExpression(dialect, 1, "day")) + + +_SPECIAL_REGISTRY: Dict[str, Callable] = special_constructors() + + +def register_special_constructor(fqn_suffix: str, factory: Callable) -> None: + """Register an explicit constructor for an expression class. + + ``fqn_suffix`` must match the tail of the class's fully qualified name + (e.g. ``"json.MySQLJSONObjectExpression"``). ``factory(dialect)`` must + return an expression instance. + """ + _SPECIAL_REGISTRY[fqn_suffix] = factory + + +def make_instance(cls: Type[BaseExpression], dialect: Any): + """Best-effort construction for an expression class. + + Returns (instance, source) where source is "special", "heuristic", or + (None, "failed"). + """ + full_name = f"{cls.__module__}.{cls.__name__}" + special = next((fn for s, fn in _SPECIAL_REGISTRY.items() if full_name.endswith(s)), None) + if special is not None: + try: + return special(dialect), "special" + except Exception: + return None, "special-failed" + instance = _try_construct(cls, dialect) + if instance is None: + return None, "failed" + return instance, "heuristic" + + +def collect_expression_classes(package_path: str) -> Dict[str, Type[BaseExpression]]: + """Walk a package subtree and collect every defined BaseExpression subclass.""" + import importlib + import pkgutil + + pkg = importlib.import_module(package_path) + classes: Dict[str, Type[BaseExpression]] = {} + for _, modname, _ in pkgutil.walk_packages(pkg.__path__, pkg.__name__ + "."): + try: + mod = importlib.import_module(modname) + except Exception: + continue + for name in dir(mod): + obj = getattr(mod, name, None) + if ( + isinstance(obj, type) + and obj.__module__ == modname + and issubclass(obj, BaseExpression) + and obj is not BaseExpression + and not inspect.isabstract(obj) + ): + classes[f"{modname}.{name}"] = obj + return classes + + +def register_all(classes: Dict[str, Type[BaseExpression]]) -> None: + """Hand the collected classes to the deserialization kind whitelist. + + Core split the class index out of the serialization module into a + registry: deserialization resolves a node to its class through the + framework's default registry, not through the ``(dialect, kind)`` render + registry -- the two are separate mechanisms and only the former names + expression classes. Core and the built-in backends populate that registry + on first use; registering one of those classes again is a no-op, and the + backend classes collected by the matrix are what this call adds. + """ + from rhosocial.activerecord.backend.expression.serialization import default_registry + + registry = default_registry() + for cls in classes.values(): + registry.register_node(cls) + + +def assert_params_equal( + a: Any, b: Any, path: str = "params", allow_enum_names: bool = False +) -> None: + """Deep-compare two get_params() outputs, treating nested BaseExpression + instances as structurally equal, nested dataclasses as recursively compared, + and an enum member as the value it carries. + + The artifact is plain JSON, so an enum parameter travels as its value and + comes back as whatever the constructor normalised it into: a member whose + class stores the normalised member, a bare string where the constructor + keeps the word. Both spell the same fact, so the comparison reads a member + through its value rather than insisting the restored object be the same + class -- and says which side is the member, because the two directions are + not symmetric once a subclass of ``str`` is involved. + + ``allow_enum_names`` is for the one other spelling in the tree: the XML + codec writes a member as ``Class.MEMBER``. That is a *recorded* spelling -- + the caller passes the flag only where a pin says the channel carries it -- + and the flag is deliberately not the default, because a comparison that + accepts any string naming an enum would stop being able to see a type + change. + """ + _compare(a, b, path, allow_enum_names=allow_enum_names) + + +def _compare(a: Any, b: Any, path: str, allow_enum_names: bool) -> None: + if a is b: + return + if isinstance(a, Enum) and not isinstance(b, Enum): + accepted = [a.value] + if allow_enum_names: + accepted.append(f"{type(a).__name__}.{a.name}") + assert b in accepted, f"{path}: {a!r} != {b!r}" + return + if isinstance(b, Enum) and not isinstance(a, Enum): + accepted = [b.value] + if allow_enum_names: + accepted.append(f"{type(b).__name__}.{b.name}") + assert a in accepted, f"{path}: {a!r} != {b!r}" + return + if isinstance(a, BaseExpression) and isinstance(b, BaseExpression): + _compare(a.get_params(), b.get_params(), path + ".", allow_enum_names) + return + if dataclasses.is_dataclass(a) and not isinstance(a, type): + assert dataclasses.is_dataclass(b) and not isinstance(b, type), ( + f"{path}: expected dataclass instance, got {type(b).__name__}" + ) + for f in dataclasses.fields(a): + _compare( + getattr(a, f.name), + getattr(b, f.name), + f"{path}.{f.name}", + allow_enum_names, + ) + return + assert type(a) is type(b) or ( + isinstance(a, (list, tuple, dict)) and isinstance(b, (list, tuple, dict)) + ), f"{path}: type mismatch {type(a).__name__} vs {type(b).__name__}" + if isinstance(a, dict): + assert set(a) == set(b), f"{path}: keys differ {set(a) ^ set(b)}" + for k in a: + _compare(a[k], b[k], f"{path}.{k}", allow_enum_names) + return + if isinstance(a, (list, tuple)): + assert len(a) == len(b), f"{path}: length differ" + for i, (x, y) in enumerate(zip(a, b)): + _compare(x, y, f"{path}[{i}]", allow_enum_names) + return + assert a == b, f"{path}: {a!r} != {b!r}" + + +def roundtrip_expression(fqn: str, instance: BaseExpression, dialect: Any) -> None: + """Assert an instance round-trips losslessly through dict / JSON / XML.""" + original = instance.get_params() + for restored in ( + _rt_dict(instance, dialect), + _rt_json(instance, dialect), + _rt_xml(instance, dialect), + ): + assert_params_equal(restored.get_params(), original, fqn) + + +def _rt_dict(instance, dialect): + from rhosocial.activerecord.backend.expression.serialization import deserialize, serialize + + return deserialize(serialize(instance), dialect) + + +def _rt_json(instance, dialect): + from rhosocial.activerecord.backend.expression.serialization import deserialize_json, serialize_json + + return deserialize_json(serialize_json(instance), dialect) + + +def _rt_xml(instance, dialect): + from rhosocial.activerecord.backend.expression.serialization import deserialize_xml, serialize_xml + + return deserialize_xml(serialize_xml(instance), dialect) + + +def sql_consistent(fqn: str, instance: BaseExpression, dialect: Any) -> None: + """Assert to_sql is identical after round-trip, when the dialect supports it.""" + try: + expected = instance.to_sql() + except Exception: + return + for restored in (_rt_dict(instance, dialect), _rt_json(instance, dialect), _rt_xml(instance, dialect)): + assert restored.to_sql() == expected, fqn + + +__all__ = [ + "assert_params_equal", + "collect_expression_classes", + "make_instance", + "register_all", + "register_special_constructor", + "roundtrip_expression", + "sql_consistent", +] diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/expression/test_expression_roundtrip_all.py similarity index 56% rename from tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_expression_roundtrip_all.py rename to tests/rhosocial/activerecord_sqlserver_test/feature/backend/expression/test_expression_roundtrip_all.py index d6804df..45578dc 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/expression/test_expression_roundtrip_all.py @@ -1,4 +1,4 @@ -# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_expression_roundtrip_all.py +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/expression/test_expression_roundtrip_all.py """ Serialization and SQL coverage for every expression class SQL Server renders. @@ -53,6 +53,21 @@ reports a *missing formatter* through that same type; see :func:`assert_sql_roundtrip_classified`. +And when the artifact itself cannot carry a node +================================================ + +Rendering is one axis; the artifact is the other. A node whose parameter is a +plain ``Enum`` or a dataclass cannot be written as plain JSON at all -- the +serializer names the type and stops -- and a node whose parameter is an enum may +still fail on one *channel* even though the other two carry it: the XML codec +spells a member as ``Class.MEMBER`` and hands that word back to a constructor +that refuses it (the enum sub-registry that used to resolve it is gone). Neither +is a rendering answer, so neither is allowed to pass as one: every such class is +named in :data:`ARTIFACT_GAPS`, pinned by channel and by the exact failure, and +:meth:`TestMatrixIntegrity.test_artifact_gap_list_is_exact` pins that table in +both directions -- a class that starts travelling fails until its row goes, and +a class that stops travelling with no row fails the per-class assertion. + Why the dispatch's refusal is no longer allowed on its type alone ================================================================= @@ -79,15 +94,18 @@ tuple against what the constructor really skips. Both directions fail: a class that gains a constructor, and a class that starts failing to build. -Discovery does not use ``ExpressionRegistry._registry``. That registry is -process-global and grows as sibling test modules import their own backends, so -reading it made this matrix's contents depend on which files pytest happened to -import first. :func:`collect_expression_classes` walks a package instead, which -is deterministic. +Discovery does not read a process-global class index. That index grows as +sibling test modules import their own backends, so reading it made this +matrix's contents depend on which files pytest happened to import first. +:func:`collect_expression_classes` walks a package instead, which is +deterministic. """ +import datetime import inspect -from typing import Dict, Tuple, Type +import uuid as uuid_module +from decimal import Decimal +from typing import Any, Dict import pytest @@ -95,7 +113,6 @@ from rhosocial.activerecord.backend.expression.core import Column, Literal from rhosocial.activerecord.backend.expression.objects import ( Database, - Function, Index, Schema, Sequence, @@ -104,9 +121,14 @@ Type, View, ) -from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate -from rhosocial.activerecord.backend.expression.serialization import ExpressionRegistry +from rhosocial.activerecord.backend.expression.predicates import ( + LikeOperator, + ComparisonOperator, + ComparisonPredicate, +) from rhosocial.activerecord.backend.expression.sources import NamedRelationRef +from rhosocial.activerecord.backend.expression.operators import ArithmeticOperation +from rhosocial.activerecord.backend.expression.query_sources import SetOperation from rhosocial.activerecord.backend.expression.statements import ( ddl_alter, ddl_table, @@ -115,27 +137,44 @@ dml, ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + ColumnCheckConstraint, ColumnConstraint, - ColumnConstraintType, ColumnDefinition, + ColumnGeneratedConstraint, + ColumnNotNullConstraint, + ForeignKeyConstraint, + TableCheckConstraint, TableConstraint, - TableConstraintType, + TablePrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.statements.dml import ( MergeAction, MergeActionType, ) -from rhosocial.activerecord.backend.expression.statements.dql import QueryExpression +from rhosocial.activerecord.backend.expression.statements.dql import QueryStatement from rhosocial.activerecord.backend.expression.types import IntegerType from rhosocial.activerecord.backend.expression.types.custom import CustomType from rhosocial.activerecord.backend.expression.uuid import UUIDConstantExpression -from rhosocial.activerecord.testsuite.utils.expression import ( - collect_expression_classes, - make_instance, - register_all, - register_special_constructor, - roundtrip_expression, +# The harness is this module's sibling, and this module is itself loaded by path +# (from ``test_sqlserver_type_protocol.py``'s sweep) as well as by name (when +# pytest collects it here) -- a by-name import would only work in the second +# case, so the sibling is loaded by path and both callers get one module object. +import importlib.util as _importlib_util +from pathlib import Path as _Path + +_harness_path = _Path(__file__).parent / "expression_roundtrip_harness.py" +_spec = _importlib_util.spec_from_file_location( + "sqlserver_roundtrip_harness", _harness_path ) +_harness = _importlib_util.module_from_spec(_spec) +_spec.loader.exec_module(_harness) + +assert_params_equal = _harness.assert_params_equal +collect_expression_classes = _harness.collect_expression_classes +make_instance = _harness.make_instance +register_all = _harness.register_all +register_special_constructor = _harness.register_special_constructor +roundtrip_expression = _harness.roundtrip_expression SS_EXPR_PKG = "rhosocial.activerecord.backend.impl.sqlserver.expression" CORE_EXPR_PKG = "rhosocial.activerecord.backend.expression" @@ -149,7 +188,6 @@ def _collect(pkg: str) -> Dict[str, type]: different places, so the first name in sorted order wins and the matrix does not assert one class twice. """ - ExpressionRegistry._auto_register_builtins() collected = collect_expression_classes(pkg) by_identity: Dict[int, str] = {} for fqn, cls in sorted(collected.items()): @@ -166,6 +204,13 @@ def _collect(pkg: str) -> Dict[str, type]: SS_REGISTERED = _collect(SS_EXPR_PKG) REGISTERED: Dict[str, type] = {**CORE_REGISTERED, **SS_REGISTERED} register_all(REGISTERED) +# Nothing else needs registering. The enum/dataclass sub-registries the old +# matrix filled existed only because an artifact carried ``__enum__`` / +# ``__dataclass__`` markers and had to resolve those class names on the way +# back in; the artifact is plain JSON now, an enum rides as its value, a +# dataclass as an object, and the kind whitelist has no name to resolve for +# either. Registering an enum here would therefore be a call to a method the +# core no longer ships, which is how this line got noticed. # --------------------------------------------------------------------------- @@ -247,7 +292,7 @@ def _one_column_query(dialect): Used by the CREATE VIEW and materialized-view registrations, where the guess would otherwise supply a bare string for the query slot. """ - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id")], from_=_table_obj(dialect) ) @@ -270,6 +315,20 @@ def _sqlserver_collation(dialect): return SQLServerCollation.LATIN1_GENERAL_CI_AS +def _string_fn(dialect, class_name, *operands): + """One scalar string function node from ``expression.functions.string``.""" + import rhosocial.activerecord.backend.expression.functions.string as string_module + + return getattr(string_module, class_name)(dialect, *operands) + + +def _datetime_fn(dialect, class_name, field, source): + """One EXTRACT-family node from ``expression.datetime``.""" + import rhosocial.activerecord.backend.expression.datetime as datetime_module + + return getattr(datetime_module, class_name)(dialect, field, source) + + def register_specials(): """Replace every introspective guess that would cost a real assertion.""" # -- the FROM side ------------------------------------------------------ @@ -279,8 +338,8 @@ def register_specials(): ) # CREATE VIEW needs a query, and the guess supplies the string "x" for it. register_special_constructor( - "statements.ddl_view.CreateViewExpression", - lambda d: ddl_view.CreateViewExpression( + "statements.ddl_view.CreateViewStatement", + lambda d: ddl_view.CreateViewStatement( d, view=_view_obj(d), query=_one_column_query(d) ), ) @@ -299,6 +358,24 @@ def register_specials(): WindowSpecification, ) + # -- closed word sets: the guess supplies a bare string ------------------- + # ``op`` on both nodes is validated against a closed set at construction + # (add/sub/mul/div/mod and union/union_all/intersect/except), so the + # introspective guess raises ValueError before a formatter is reached. + register_special_constructor( + "operators.ArithmeticOperation", + lambda d: ArithmeticOperation(d, "add", Column(d, "a"), Column(d, "b")), + ) + register_special_constructor( + "query_sources.SetOperation", + lambda d: SetOperation( + d, + op="union", + left=QueryStatement(d, select=[Column(d, "id")]), + right=QueryStatement(d, select=[Column(d, "id")]), + ), + ) + register_special_constructor( "advanced_functions.CaseExpression", lambda d: CaseExpression( @@ -329,35 +406,36 @@ def register_specials(): # The guess supplies the string "x", which SQL Server's collation validator # rightly refuses. register_special_constructor( - "collation.CollateExpression", + "collation.CollateFunction", lambda d: __import__( "rhosocial.activerecord.backend.expression.collation", - fromlist=["CollateExpression"], - ).CollateExpression(d, Column(d, "a"), _sqlserver_collation(d)), + fromlist=["CollateFunction"], + ).CollateFunction(d, Column(d, "a"), _sqlserver_collation(d)), ) # -- ALTER TABLE actions ------------------------------------------------ register_special_constructor( - "ddl_alter.AddColumn", lambda d: ddl_alter.AddColumn(d, _integer_column(d)) + "ddl_alter.AddColumnAction", lambda d: ddl_alter.AddColumnAction(d, _integer_column(d)) ) register_special_constructor( - "ddl_alter.AddTableConstraint", - lambda d: ddl_alter.AddTableConstraint( - d, TableConstraint(d, TableConstraintType.PRIMARY_KEY, name="c", columns=["a"]) + "ddl_alter.AddTableConstraintAction", + lambda d: ddl_alter.AddTableConstraintAction( + d, TableConstraint(d, branch=TablePrimaryKeyConstraint(d, ["a"]), name="c") ), ) register_special_constructor( - "ddl_alter.AddIndex", lambda d: ddl_alter.AddIndex(d, ddl_table.IndexDefinition(d, "i", ["a"])) + "ddl_alter.AddIndexAction", + lambda d: ddl_alter.AddIndexAction(d, ddl_table.IndexDefinition(d, "i", ["a"])), ) # -- constraints that must render without bind parameters ---------------- register_special_constructor( "ddl_table.ColumnConstraint", - lambda d: ColumnConstraint(d, ColumnConstraintType.NOT_NULL, name="c"), + lambda d: ColumnConstraint(d, branch=ColumnNotNullConstraint(d), name="c"), ) register_special_constructor( "ddl_table.TableConstraint", - lambda d: TableConstraint(d, TableConstraintType.PRIMARY_KEY, name="c", columns=["a"]), + lambda d: TableConstraint(d, branch=TablePrimaryKeyConstraint(d, ["a"]), name="c"), ) # REFERENCES needs at least one referenced column; the guess supplies none. register_special_constructor( @@ -365,6 +443,27 @@ def register_specials(): lambda d: ddl_table.ReferencesClause(d, Table(d, "other"), ["b"]), ) + # -- predicates whose operand slots are value nodes ---------------------- + # Core's wildcard-split commit calls these out: the operand slots are + # annotated as forward references (or ``Any``), so the generic guess fills + # them with plain strings and the instance cannot render. Both factories are + # the ones core's own dummy2 matrix registers, so the matrices agree. + # ``LikePredicate`` carries the operator as a parameter, so the guess's + # ``"x"`` would become the operator rather than the operand. + from rhosocial.activerecord.backend.expression.predicates import ( + ILikePredicate, + LikePredicate, + ) + + register_special_constructor( + "predicates.LikePredicate", + lambda d: LikePredicate(d, LikeOperator.LIKE, Column(d, "a"), Literal(d, "%x%")), + ) + register_special_constructor( + "predicates.ILikePredicate", + lambda d: ILikePredicate(d, Column(d, "a"), "%x%"), + ) + # -- statements holding objects the guess cannot name -------------------- # Each of these was found by running the matrix, not by reading it: the # introspective guess hands the statement a bare ``"x"``, and the formatter @@ -374,8 +473,8 @@ def register_specials(): # overlap, because ``make_instance`` returns the **first** registry key whose # suffix matches -- and the testsuite populates its registry at import time, # so a new, shorter key is never consulted for a class an earlier, longer key - # already claims. Registering ``ddl_type.CreateTypeExpression`` next to the - # testsuite's ``statements.ddl_type.CreateTypeExpression`` would be silent + # already claims. Registering ``ddl_type.CreateTypeStatement`` next to the + # testsuite's ``statements.ddl_type.CreateTypeStatement`` would be silent # dead code; re-using the key replaces the entry. That is why the two TYPE # registrations below keep the ``statements.`` prefix. from rhosocial.activerecord.backend.expression.statements import ( @@ -385,45 +484,45 @@ def register_specials(): ) register_special_constructor( - "ddl_truncate.TruncateExpression", - lambda d: ddl_truncate.TruncateExpression(d, _table_obj(d)), + "ddl_truncate.TruncateStatement", + lambda d: ddl_truncate.TruncateStatement(d, _table_obj(d)), ) register_special_constructor( - "ddl_table.CreateTableExpression", - lambda d: ddl_table.CreateTableExpression(d, _table_obj(d), [_integer_column(d)]), + "ddl_table.CreateTableStatement", + lambda d: ddl_table.CreateTableStatement(d, _table_obj(d), [_integer_column(d)]), ) register_special_constructor( - "ddl_table.DropTableExpression", - lambda d: ddl_table.DropTableExpression(d, _table_obj(d)), + "ddl_table.DropTableStatement", + lambda d: ddl_table.DropTableStatement(d, _table_obj(d)), ) register_special_constructor( - "ddl_view.DropViewExpression", - lambda d: ddl_view.DropViewExpression(d, _view_obj(d)), + "ddl_view.DropViewStatement", + lambda d: ddl_view.DropViewStatement(d, _view_obj(d)), ) register_special_constructor( - "ddl_index.DropFulltextIndexExpression", - lambda d: ddl_index.DropFulltextIndexExpression( + "ddl_index.DropFulltextIndexStatement", + lambda d: ddl_index.DropFulltextIndexStatement( d, _index_obj(d), _table_obj(d) ), ) register_special_constructor( - "ddl_index.CreateFulltextIndexExpression", - lambda d: ddl_index.CreateFulltextIndexExpression( + "ddl_index.CreateFulltextIndexStatement", + lambda d: ddl_index.CreateFulltextIndexStatement( d, index=_index_obj(d), table=_table_obj(d), columns=["a"] ), ) # A COMMENT ON is DDL: its comment must render as a literal rather than a # bind parameter, so the guess's bare string is replaced by one with a name. register_special_constructor( - "ddl_comment.CommentOnExpression", - lambda d: ddl_comment.CommentOnExpression(d, "table", _table_obj(d), comment="c"), + "ddl_comment.CommentOnStatement", + lambda d: ddl_comment.CommentOnStatement(d, "table", _table_obj(d), comment="c"), ) register_special_constructor( - "ddl_trigger.DropTriggerExpression", + "ddl_trigger.DropTriggerStatement", lambda d: __import__( "rhosocial.activerecord.backend.expression.statements.ddl_trigger", - fromlist=["DropTriggerExpression"], - ).DropTriggerExpression(d, Trigger(d, "trg")), + fromlist=["DropTriggerStatement"], + ).DropTriggerStatement(d, Trigger(d, "trg")), ) # A TYPE definition must be a definition object, and the one the testsuite # registers is the Dummy's -- which SQL Server cannot spell, so the statement @@ -431,8 +530,8 @@ def register_specials(): # alias definition is used instead, so it renders and the round-trip is # asserted. Same key as the testsuite's, deliberately. register_special_constructor( - "statements.ddl_type.CreateTypeExpression", - lambda d: ddl_type.CreateTypeExpression( + "statements.ddl_type.CreateTypeStatement", + lambda d: ddl_type.CreateTypeStatement( d, _type_obj(d), _alias_type_definition(d) ), ) @@ -449,8 +548,8 @@ def register_specials(): ), ) register_special_constructor( - "dml.MergeExpression", - lambda d: dml.MergeExpression( + "dml.MergeStatement", + lambda d: dml.MergeStatement( d, target_table=_table_obj(d), source=NamedRelationRef(d, Table(d, "src")), @@ -470,24 +569,21 @@ def register_specials(): # -- SQL Server's own expressions --------------------------------------- def pivot(d): from rhosocial.activerecord.backend.impl.sqlserver.expression.pivot import ( - PivotExpression, + Pivot, ) - return PivotExpression(d, aggregate_function="SUM", value_column="amount", pivot_column="q") + return Pivot(d, aggregate_function="SUM", value_column="amount", pivot_column="q") def columnstore(d): from rhosocial.activerecord.backend.impl.sqlserver.expression.columnstore import ( - SQLServerColumnstoreIndexExpression, + SQLServerColumnstoreIndexStatement, ) - return SQLServerColumnstoreIndexExpression(d, "cci", "t", columns=["a"]) + return SQLServerColumnstoreIndexStatement(d, "cci", "t", columns=["a"]) def partition_range(d): from rhosocial.activerecord.backend.impl.sqlserver.expression.partition import ( SQLServerPartitionByRangeClause, - SQLServerPartitionFunctionExpression, - SQLServerPartitionRangeDirection, - SQLServerPartitionSchemeExpression, ) return SQLServerPartitionByRangeClause( @@ -530,13 +626,11 @@ def table_type(d): d, "id", IntegerType(d), - constraints=[ColumnConstraint(d, ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(d, branch=ColumnNotNullConstraint(d))], ) ], constraints=[ - TableConstraint( - d, TableConstraintType.PRIMARY_KEY, columns=["id"] - ) + TableConstraint(d, branch=TablePrimaryKeyConstraint(d, ["id"])) ], indexes=[SQLServerIndexDefinition(d, "ix_id", ["id"])], ) @@ -557,10 +651,10 @@ def drop_type(d): def rename_type(d): from rhosocial.activerecord.backend.impl.sqlserver.expression.ddl.type import ( - SQLServerRenameTypeExpression, + SQLServerRenameTypeStatement, ) - return SQLServerRenameTypeExpression(d, "old_name", "new_name") + return SQLServerRenameTypeStatement(d, "old_name", "new_name") def column_definition(d): from rhosocial.activerecord.backend.impl.sqlserver.expression.column import ( @@ -576,12 +670,72 @@ def alter_column(d): return SQLServerAlterColumn(d, "c", data_type="INT") + def comparison(d): + """A predicate whose operator is a member, not a word. + + The operator normalises at construction, so a word is *accepted* -- but + the restored node is handed the artifact's word and answers with the + member, and a class whose own params disagree with that is exactly what + this matrix exists to see. Building from the member makes the two sides + the same object and the comparison honest; a word would hide the + difference instead of measuring it. + """ + return ComparisonPredicate( + d, ComparisonOperator.EQ, Column(d, "a"), Column(d, "b") + ) + + def check_constraint(d): + """A CHECK whose condition compares two columns. + + A DDL condition may not carry a bind parameter -- the checker says so by + name -- so the guess's ``Literal(d, 1)`` is refused there. Two columns + render with no parameters at all, which is the position this class is + actually used in. + """ + return TableCheckConstraint(d, condition=_column_predicate(d)) + + def column_check_constraint(d): + return ColumnCheckConstraint(d, condition=_column_predicate(d)) + + def generated_constraint(d): + """A generated column whose expression inlines its literal. + + Same DDL rule as the CHECK constraint, and the storage word travels as + the plain string it is: an enum parameter in a *core* node is the core's + spelling to settle, so this factory supplies the one form the artifact + can carry and leaves the rest to the class. + """ + expression = ArithmeticOperation( + d, "add", Column(d, "a"), Literal(d, 1, inline=True) + ) + return ColumnGeneratedConstraint( + d, expression=expression, storage_type="STORED" + ) + + def foreign_key(d): + """A foreign key whose referential actions are words, not members. + + The core node stores an enum member when the caller passes nothing, and + a plain ``Enum`` param is the one thing a plain-JSON artifact cannot + carry -- the serializer names the type and stops. Passing the words keeps + the class on the side of the contract this matrix asserts; the default + path is pinned in :data:`ARTIFACT_GAPS`. + """ + return ForeignKeyConstraint( + d, + columns=["a"], + foreign_key_table=Table(d, "parent"), + foreign_key_columns=["id"], + on_delete="NO ACTION", + on_update="NO ACTION", + ) + def create_table(d): from rhosocial.activerecord.backend.impl.sqlserver.expression.create_table import ( - SQLServerCreateTableExpression, + SQLServerCreateTableStatement, ) - return SQLServerCreateTableExpression(d, _table_obj(d), [_integer_column(d)]) + return SQLServerCreateTableStatement(d, _table_obj(d), [_integer_column(d)]) def as_graph_table(d): from rhosocial.activerecord.backend.impl.sqlserver.expression.ddl.graph import ( @@ -593,26 +747,26 @@ def as_graph_table(d): def create_function(d): from rhosocial.activerecord.backend.impl.sqlserver.expression.ddl.routine import ( - SQLServerCreateFunctionExpression, + SQLServerCreateFunctionStatement, ) - return SQLServerCreateFunctionExpression( + return SQLServerCreateFunctionStatement( d, "fn", returns="INT", body="SELECT 1" ) def create_procedure(d): from rhosocial.activerecord.backend.impl.sqlserver.expression.ddl.routine import ( - SQLServerCreateProcedureExpression, + SQLServerCreateProcedureStatement, ) - return SQLServerCreateProcedureExpression(d, "p", body="SELECT 1") + return SQLServerCreateProcedureStatement(d, "p", body="SELECT 1") def create_trigger(d): from rhosocial.activerecord.backend.impl.sqlserver.expression.ddl.trigger import ( - SQLServerCreateTriggerExpression, + SQLServerCreateTriggerStatement, ) - return SQLServerCreateTriggerExpression( + return SQLServerCreateTriggerStatement( d, "trg", "t", timing="AFTER", events=["INSERT"], body="SELECT 1", ) @@ -670,17 +824,18 @@ def shortest_path(d): return SQLServerShortestPathExpression(d, graph_pattern(d), 1, 3) - # ``make_instance`` matches suffixes with ``str.endswith``, and core also has - # a ``pivot.PivotExpression``. The longer suffix keeps this registration on - # the SQL Server class instead of capturing core's as well. Core's own - # PivotExpression is left to the guess: SQL Server's PIVOT spelling needs an - # aggregate the guess does not supply, and it is pinned as a non-render below - # rather than given a constructor here. + # Core also has a ``pivot.Pivot`` of its own, and ``make_instance`` matches + # suffixes with ``str.endswith``, so the longer suffix keeps this + # registration on the SQL Server class instead of capturing core's as well. + # Core's generic Pivot and Unpivot render on this dialect now -- they are + # rows in the rendered half of the matrix, not in LEGITIMATE_NON_RENDERS -- + # and SQL Server's own spelling rides its own class and its own + # constructor. register_special_constructor( - "sqlserver.expression.pivot.PivotExpression", pivot + "sqlserver.expression.pivot.Pivot", pivot ) register_special_constructor( - "columnstore.SQLServerColumnstoreIndexExpression", columnstore + "columnstore.SQLServerColumnstoreIndexStatement", columnstore ) register_special_constructor( "partition.SQLServerPartitionByRangeClause", partition_range @@ -695,23 +850,42 @@ def shortest_path(d): register_special_constructor("type.SQLServerTableTypeDefinition", table_type) register_special_constructor("type.SQLServerClrTypeDefinition", clr_type) register_special_constructor("type.SQLServerDropTypeExpression", drop_type) - register_special_constructor("type.SQLServerRenameTypeExpression", rename_type) + register_special_constructor("type.SQLServerRenameTypeStatement", rename_type) register_special_constructor("column.SQLServerColumnDefinition", column_definition) register_special_constructor("alter_column.SQLServerAlterColumn", alter_column) + # -- predicates and constraints that must render with no bind parameters --- + # The operator normalises, the CHECK condition is a DDL position, and the + # generated expression inlines its literal: three places the guess's bound + # ``Literal`` is refused by name. + register_special_constructor("predicates.ComparisonPredicate", comparison) + register_special_constructor("ddl_table.TableCheckConstraint", check_constraint) + register_special_constructor( + "ddl_table.ColumnCheckConstraint", column_check_constraint + ) + register_special_constructor( + "ddl_table.ColumnGeneratedConstraint", generated_constraint + ) + # A core node whose own default is an enum member cannot travel: the words + # are supplied here so the class is constructible on the side of the + # contract this matrix asserts, and the default path stays recorded in + # ARTIFACT_GAPS rather than being quietly un-testable. + register_special_constructor( + "statements.ddl_table.ForeignKeyConstraint", foreign_key + ) register_special_constructor( - "create_table.SQLServerCreateTableExpression", create_table + "create_table.SQLServerCreateTableStatement", create_table ) register_special_constructor( "ddl.graph.SQLServerAsGraphTableExpression", as_graph_table ) register_special_constructor( - "ddl.routine.SQLServerCreateFunctionExpression", create_function + "ddl.routine.SQLServerCreateFunctionStatement", create_function ) register_special_constructor( - "ddl.routine.SQLServerCreateProcedureExpression", create_procedure + "ddl.routine.SQLServerCreateProcedureStatement", create_procedure ) register_special_constructor( - "ddl.trigger.SQLServerCreateTriggerExpression", create_trigger + "ddl.trigger.SQLServerCreateTriggerStatement", create_trigger ) register_special_constructor("dml.SQLServerMergeExpression", sqlserver_merge) register_special_constructor("option_hint.SQLServerOptionHintClause", option_hint) @@ -728,11 +902,67 @@ def shortest_path(d): # neither belongs in UNCONSTRUCTIBLE -- a constructor is the honest answer # for a class that constructs fine once its own slot is filled. register_special_constructor( - "types.custom.CustomType", lambda d: CustomType(d, "VARCHAR(10)") + "types.custom.CustomType", lambda d: CustomType(d, raw="VARCHAR(10)") ) register_special_constructor( "uuid.UUIDConstantExpression", lambda d: UUIDConstantExpression(d, "nil") ) + # -- one backend-specific question, answered in the form a name allows ----- + # The extended-properties statement names its object one of two ways -- the + # catalogue id or the table name -- and refuses a statement that names + # neither, because "the properties of nothing" is not a question. The + # introspective guess invents neither, so the table-name form is filled in + # here: it is the one a caller holding only a name uses, and the one the + # CLI asks. + from rhosocial.activerecord.backend.impl.sqlserver.expression.introspection import ( + SQLServerExtendedPropertyListQueryStatement, + ) + + register_special_constructor( + "introspection.SQLServerExtendedPropertyListQueryStatement", + lambda d: SQLServerExtendedPropertyListQueryStatement(d, table_name="users"), + ) + # -- scalar string functions whose parameters are typed operands ---------- + # The introspective guess supplies a bare string where these classes take + # SQLValue / BaseExpression operands, so the constructor refuses; each + # one renders as soon as a real operand rides in the slot. + register_special_constructor( + "functions.string.ChrFunction", + lambda d: _string_fn(d, "ChrFunction", Literal(d, 65)), + ) + register_special_constructor( + "functions.string.LeftFunction", + lambda d: _string_fn(d, "LeftFunction", Column(d, "a"), Literal(d, 3)), + ) + register_special_constructor( + "functions.string.RightFunction", + lambda d: _string_fn(d, "RightFunction", Column(d, "a"), Literal(d, 3)), + ) + register_special_constructor( + "functions.string.SpaceFunction", + lambda d: _string_fn(d, "SpaceFunction", Literal(d, 3)), + ) + register_special_constructor( + "functions.string.SubstringExpression", + lambda d: _string_fn(d, "SubstringExpression", Column(d, "a"), Literal(d, 1)), + ) + register_special_constructor( + "functions.string.OverlayExpression", + lambda d: _string_fn( + d, "OverlayExpression", Column(d, "a"), Column(d, "b"), Literal(d, 1) + ), + ) + # -- the EXTRACT family's shared base and its concrete node --------------- + # Both take a field plus a BaseExpression source; the guess supplies a + # bare string for the source and the class refuses it. + register_special_constructor( + "datetime.ExtractFieldExpression", + lambda d: _datetime_fn(d, "ExtractFieldExpression", "YEAR", Column(d, "ts")), + ) + register_special_constructor( + "datetime._ExtractBase", + lambda d: _datetime_fn(d, "_ExtractBase", "YEAR", Column(d, "ts")), + ) register_specials() @@ -764,15 +994,13 @@ def shortest_path(d): #: turns out to be a genuine capability gap rather than a render, which is why all #: four moved to :data:`LEGITIMATE_NON_RENDERS` rather than to a render assertion. UNCONSTRUCTIBLE = ( - # ALTER CONSTRAINT. `name` and `constraint_type` sit behind defaulted - # positionals and are keyword-only, so the introspective constructor skips - # them and the class refuses an incomplete action (`ValueError: constraint_name - # must be a non-empty string`, because the defaulted `constraint_name` stays - # None). - "rhosocial.activerecord.backend.expression.statements.ddl_alter.AlterConstraint", - # VALIDATE CONSTRAINT. Same shape as AlterConstraint: the required `name` is - # keyword-only behind a defaulted positional. - "rhosocial.activerecord.backend.expression.statements.ddl_alter.ValidateConstraint", + # ALTER CONSTRAINT (renamed AlterConstraintAction by the core's "name an + # action by what it is" wave). `name` and `constraint_type` sit behind + # defaulted positionals and are keyword-only, so the introspective + # constructor skips them and the class refuses an incomplete action + # (`ValueError: constraint_name must be a non-empty string`, because the + # defaulted `constraint_name` stays None). + "rhosocial.activerecord.backend.expression.statements.ddl_alter.AlterConstraintAction", # ADD DOMAIN CHECK. Widens a SQLPredicate into a DomainCheckConstraint and # needs one; the guess supplies a bare comparison whose literal would have # to render as a bind parameter, which DDL cannot carry. (It supplies a bare @@ -781,10 +1009,6 @@ def shortest_path(d): # falls through to its name-based default and the class raises # `TypeError: check must be a DomainCheckConstraint instance, got str`.) "rhosocial.activerecord.backend.expression.statements.ddl_domain.AddDomainCheckAction", - # ENUM type. Its `values` list is keyword-only behind a defaulted positional, - # so the introspective constructor skips it and the type declares no members - # (`ValueError: EnumType requires values`). - "rhosocial.activerecord.backend.expression.types.enum_.EnumType", ) #: Message fragments the pins below match on. @@ -816,7 +1040,7 @@ def shortest_path(d): NotImplementedError, _NO_FORMATTER, ), - "rhosocial.activerecord.backend.expression.bases.SQLValueExpression": ( + "rhosocial.activerecord.backend.expression.bases.SQLValue": ( NotImplementedError, _NO_FORMATTER, ), @@ -841,7 +1065,7 @@ def shortest_path(d): ), # Expression-category bases whose concrete members each name their own # formatter: an ALTER TABLE action, an INSERT row source, a transaction - # step, a temporal value, an introspection query. + # step, a temporal value, an introspection query, a DDL column clause. "rhosocial.activerecord.backend.expression.statements.ddl_alter.AlterTableAction": ( NotImplementedError, _NO_FORMATTER, @@ -850,11 +1074,25 @@ def shortest_path(d): NotImplementedError, _NO_FORMATTER, ), - "rhosocial.activerecord.backend.expression.transaction.TransactionExpression": ( + "rhosocial.activerecord.backend.expression.transaction.TransactionStatement": ( NotImplementedError, _NO_FORMATTER, ), - "rhosocial.activerecord.backend.expression.datetime._TemporalValueExpression": ( + "rhosocial.activerecord.backend.expression.datetime._TemporalValue": ( + NotImplementedError, + _NO_FORMATTER, + ), + "rhosocial.activerecord.backend.expression.statements.ddl_table.ColumnClause": ( + NotImplementedError, + _NO_FORMATTER, + ), + "rhosocial.activerecord.backend.expression.statements.ddl_table." + "ColumnConstraintBranch": ( + NotImplementedError, + _NO_FORMATTER, + ), + "rhosocial.activerecord.backend.expression.statements.ddl_table." + "TableConstraintBranch": ( NotImplementedError, _NO_FORMATTER, ), @@ -862,6 +1100,23 @@ def shortest_path(d): NotImplementedError, _NO_FORMATTER, ), + # Two introspection statements ask for a *bundle* -- a table with its + # columns, indexes and keys; a trigger with its table and definition -- and + # this backend answers each part with a statement of its own (the column, + # index and foreign-key queries; ``sys.triggers`` filtered by name). It + # declares neither bundle formatter, so the core stub refuses it by name. + # Pinned rather than implemented: a formatter nobody asks for would be SQL + # this backend never runs. + "rhosocial.activerecord.backend.expression.introspection." + "TableInfoQueryStatement": ( + NotImplementedError, + "Subclass must implement format_table_info_query_statement", + ), + "rhosocial.activerecord.backend.expression.introspection." + "TriggerInfoQueryStatement": ( + NotImplementedError, + "Subclass must implement format_trigger_info_query_statement", + ), # ---- the root of the row-source tree ---- # No concrete source renders through `format_table_source`; each overrides @@ -910,7 +1165,7 @@ def shortest_path(d): "rhosocial.activerecord.backend.expression.xml.XMLElementExpression": ( UnsupportedFeatureError, _NO_SUCH_METHOD ), - "rhosocial.activerecord.backend.expression.xml.XMLExistsExpression": ( + "rhosocial.activerecord.backend.expression.xml.XMLExistsPredicate": ( UnsupportedFeatureError, _NO_SUCH_METHOD ), "rhosocial.activerecord.backend.expression.xml.XMLForestExpression": ( @@ -937,13 +1192,13 @@ def shortest_path(d): # CREATE/ALTER/DROP DOMAIN (nine): SQL Server has no DOMAIN. The three # statements and their five actions plus the check constraint and the domain # value all name a formatter the dialect does not have. - "rhosocial.activerecord.backend.expression.statements.ddl_domain.CreateDomainExpression": ( + "rhosocial.activerecord.backend.expression.statements.ddl_domain.CreateDomainStatement": ( UnsupportedFeatureError, _NO_SUCH_METHOD ), - "rhosocial.activerecord.backend.expression.statements.ddl_domain.AlterDomainExpression": ( + "rhosocial.activerecord.backend.expression.statements.ddl_domain.AlterDomainStatement": ( UnsupportedFeatureError, _NO_SUCH_METHOD ), - "rhosocial.activerecord.backend.expression.statements.ddl_domain.DropDomainExpression": ( + "rhosocial.activerecord.backend.expression.statements.ddl_domain.DropDomainStatement": ( UnsupportedFeatureError, _NO_SUCH_METHOD ), "rhosocial.activerecord.backend.expression.statements.ddl_domain.DomainCheckConstraint": ( @@ -979,35 +1234,27 @@ def shortest_path(d): "rhosocial.activerecord.backend.expression.graph.VertexTable": ( UnsupportedFeatureError, _NO_SUCH_METHOD ), - "rhosocial.activerecord.backend.expression.graph.EdgeTable": ( - UnsupportedFeatureError, _NO_SUCH_METHOD - ), + # ``graph.EdgeTable`` used to be pinned here; the class now lives in + # ``objects.relation`` and renders through the same format_table_object + # every table uses, so the row is gone. "rhosocial.activerecord.backend.expression.graph.ColumnsClause": ( UnsupportedFeatureError, _NO_SUCH_METHOD ), "rhosocial.activerecord.backend.expression.graph.TablePropertiesClause": ( UnsupportedFeatureError, _NO_SUCH_METHOD ), - "rhosocial.activerecord.backend.expression.graph.CreatePropertyGraphExpression": ( - UnsupportedFeatureError, _NO_SUCH_METHOD - ), - "rhosocial.activerecord.backend.expression.graph.AlterPropertyGraphExpression": ( - UnsupportedFeatureError, _NO_SUCH_METHOD - ), - "rhosocial.activerecord.backend.expression.graph.DropPropertyGraphExpression": ( + "rhosocial.activerecord.backend.expression.graph.CreatePropertyGraphStatement": ( UnsupportedFeatureError, _NO_SUCH_METHOD ), - # PIVOT / UNPIVOT (two): SQL Server spells PIVOT its own way, through - # SQLServerPivotExpression. Core's generic PIVOT is not the same statement. - "rhosocial.activerecord.backend.expression.pivot.PivotExpression": ( + "rhosocial.activerecord.backend.expression.graph.AlterPropertyGraphStatement": ( UnsupportedFeatureError, _NO_SUCH_METHOD ), - "rhosocial.activerecord.backend.expression.pivot.UnpivotExpression": ( + "rhosocial.activerecord.backend.expression.graph.DropPropertyGraphStatement": ( UnsupportedFeatureError, _NO_SUCH_METHOD ), # COMMENT ON: SQL Server has no standalone COMMENT ON statement, which the # dialect's negative list records as CommentSupport. - "rhosocial.activerecord.backend.expression.statements.ddl_comment.CommentOnExpression": ( + "rhosocial.activerecord.backend.expression.statements.ddl_comment.CommentOnStatement": ( UnsupportedFeatureError, _NO_SUCH_METHOD ), @@ -1015,10 +1262,13 @@ def shortest_path(d): # Every concrete type declares its own generic `name`, which is what # `format_data_type` dispatches on; the root declares none, so there is # nothing to dispatch. TypeError, not UnsupportedFeatureError, because the - # class is incomplete rather than the dialect being unable. + # class is incomplete rather than the dialect being unable. The message + # names the missing concept rather than the missing declaration: a bare + # DataType carries no name to dispatch, so the refusal speaks of the + # generic type it could not resolve. "rhosocial.activerecord.backend.expression.types._base.DataType": ( TypeError, - "does not declare a valid generic type name", + "does not support the generic type", ), # Core types with no SQL Server spelling (eight). SQL Server has no ARRAY, # no JSONB, no TIMESTAMPTZ, and spells BINARY as its own types, so these @@ -1045,45 +1295,238 @@ def shortest_path(d): "rhosocial.activerecord.backend.expression.types.uuid_.UUIDType": ( TypeError, _NO_SUCH_TYPE ), + # ENUM (nine, and the ninth is this one's reason to exist): the class + # constructs now that its keyword-only ``values`` gets a list from the + # guess, and the render refuses exactly what the eight above refuse -- + # SQL Server has no ENUM generic, and the dialect says so while + # suggesting the VARCHAR it actually uses. + "rhosocial.activerecord.backend.expression.types.enum_.EnumType": ( + TypeError, _NO_SUCH_TYPE + ), + # The declared-result annotations: the base and every concrete member of + # it. Each carries only the node it annotates; the alias the core's + # ``format_declared_result`` reads belongs to the concrete named + # annotations, so the guessed instance -- a bare ``Literal`` -- has none, + # and the formatter fails naming it. The core formatter reading it is the + # moving edge of the value-literal rework (a narrowed ``Literal`` no longer + # carries ``alias`` at all); these rows record what the tree actually does + # today, one per class, because the concrete annotations are collected in + # their own right and an unnamed failure is a defect by this matrix's rule. + "rhosocial.activerecord.backend.expression.declared._Annotation": ( + AttributeError, + "has no attribute 'alias'", + ), + "rhosocial.activerecord.backend.expression.declared.ArrayAnnotation": ( + AttributeError, + "has no attribute 'alias'", + ), + "rhosocial.activerecord.backend.expression.declared.BinaryAnnotation": ( + AttributeError, + "has no attribute 'alias'", + ), + "rhosocial.activerecord.backend.expression.declared.BooleanAnnotation": ( + AttributeError, + "has no attribute 'alias'", + ), + "rhosocial.activerecord.backend.expression.declared.IntegerAnnotation": ( + AttributeError, + "has no attribute 'alias'", + ), + "rhosocial.activerecord.backend.expression.declared.JSONAnnotation": ( + AttributeError, + "has no attribute 'alias'", + ), + "rhosocial.activerecord.backend.expression.declared.NumberAnnotation": ( + AttributeError, + "has no attribute 'alias'", + ), + "rhosocial.activerecord.backend.expression.declared.TemporalAnnotation": ( + AttributeError, + "has no attribute 'alias'", + ), + "rhosocial.activerecord.backend.expression.declared.TextAnnotation": ( + AttributeError, + "has no attribute 'alias'", + ), + "rhosocial.activerecord.backend.expression.declared.UUIDAnnotation": ( + AttributeError, + "has no attribute 'alias'", + ), + "rhosocial.activerecord.backend.expression.declared.XMLAnnotation": ( + AttributeError, + "has no attribute 'alias'", + ), # ---- core expressions SQL Server has no T-SQL spelling for ---- # - # These four are the shadowing problem, and they are the reason this matrix - # exists in this form. SQL Server's own T-SQL DDL for full-text indexes, - # functions and triggers is spelled by formatters that took over the *core - # formatter names*: ``format_create_fulltext_index_statement`` here takes - # ``(table, columns, key_index, catalog_name)`` where core's takes the - # expression, and ``format_create_function_statement`` takes a - # ``SQLServerCreateFunctionExpression`` where core's takes a - # ``CreateFunctionExpression``. So when the core expression dispatches -- - # which is what ``format_method`` does -- it calls a function whose - # signature it cannot satisfy. + # The full-text and routine rows below are gone: SQL Server's own T-SQL DDL + # used to take over the *core formatter names* -- + # ``format_create_fulltext_index_statement`` took ``(table, columns, + # key_index, catalog_name)`` where core's takes the expression, so the core + # expression dispatched into a signature it could not satisfy. The + # statement/name unification renamed the backend's formatters to their own + # spelling (``format_create_full_text_index_statement`` and friends), + # which freed the core names: the core expressions now reach core's generic + # formatters and render, so there is nothing left to pin here. # - # This is a recorded gap, not an oversight, and each entry pins the exact - # failure so the shape of it cannot change unnoticed. The fix is to rename - # the backend's formatters (``format_sqlserver_create_fulltext_index_statement`` - # and friends) so the core names are free again, and to point the backend's - # own expressions at the renamed methods. That is a rename across five files - # and four protocol declarations, which is its own change with its own - # review; it is not smuggled in here behind a comment. - "rhosocial.activerecord.backend.expression.statements.ddl_index.CreateFulltextIndexExpression": ( - TypeError, - "missing 3 required positional arguments", - ), - "rhosocial.activerecord.backend.expression.statements.ddl_index.DropFulltextIndexExpression": ( - ValueError, - "name must be a non-empty string", + # The two rows below changed shape when the formatters stopped probing + # for ``name`` and started reading it: the probe's default (``""``) used + # to turn a node with no ``name`` field into "the name is missing", and + # the direct read says what is actually wrong -- the shadowed SQL:1999 + # statements carry no ``name`` field at all. + "rhosocial.activerecord.backend.expression.statements.ddl_function.CreateFunctionStatement": ( + AttributeError, + "has no attribute 'name'", + ), + "rhosocial.activerecord.backend.expression.statements.ddl_trigger.DropTriggerStatement": ( + AttributeError, + "has no attribute 'name'", ), - "rhosocial.activerecord.backend.expression.statements.ddl_function.CreateFunctionExpression": ( - ValueError, - "function name is required", +} + + +# --------------------------------------------------------------------------- +# The artifact gap: a node the plain-JSON payload cannot carry, or one channel +# of it cannot read back +# --------------------------------------------------------------------------- +# +# Rendering is this matrix's first axis; travelling is its second, and a class +# can answer "yes" to one and "no" to the other. Two shapes, both named here +# rather than swallowed: +# +# * a parameter whose type is not a JSON scalar. The serializer names the type +# and stops -- "is not one of the value kinds this package can write" -- and +# that is a decision on the *node*'s side, not this dialect's. Where the node +# can be built with a scalar in that slot, the matrix's constructor does so +# and the class asserts normally; the rows below are the ones where it cannot +# (``CreateViewStatement`` normalises ``options=None`` into a +# ``ViewOptions()`` no matter what the caller passes). +# * one channel failing where the others succeed. The XML codec used to spell +# an enum parameter as ``Class.MEMBER`` while the dict and JSON codecs spelled +# it as the member's own value, which every one of these constructors +# accepts; no reader turned the word back into a member, so the constructor +# refused it. That was a core-side gap, recorded here with the channel name +# so the day core spells it as the value, the row goes -- and the rows have +# gone: core spells the enum as its value on every channel now, so the +# EXTRACT family, the interval arithmetic and the XML document-type +# expressions all travel. +# +# Each entry is ``(channels, exception type, message fragment)``. ``channels`` +# is one of ``"dict"`` / ``"json"`` / ``"xml"``, a tuple of them, or ``"all"``; +# a type of ``None`` names the other shape of gap, where the channel carries the +# node but restores a different *spelling* of one parameter. Either way the row +# is asserted against the tree: a class that starts travelling fails until its +# row goes, and a class that stops travelling with no row fails the per-class +# assertion. + +#: Nodes whose artifact cannot be written or read back, by channel and reason. +ARTIFACT_GAPS = { + # CREATE VIEW: ``options`` is a dataclass parameter and the constructor + # normalises a missing one into ``ViewOptions()``, so the class cannot be + # built with a scalar there. SQL Server has no view options of its own -- + # the dialect renders the statement and ignores them -- but the artifact + # still has to carry what the constructor stored. + "rhosocial.activerecord.backend.expression.statements.ddl_view." + "CreateViewStatement": ( + "all", + TypeError, + "not one of the value kinds", ), - "rhosocial.activerecord.backend.expression.statements.ddl_trigger.DropTriggerExpression": ( - ValueError, - "trigger name is required", + # SQL/PGQ property graphs: ``GraphColumn`` is a dataclass, and a dataclass + # parameter is the other thing a plain-JSON artifact cannot carry. No + # construction choice avoids it -- a COLUMNS clause is made of columns. + "rhosocial.activerecord.backend.expression.graph.GraphTableExpression": ( + "all", + TypeError, + "not one of the value kinds", + ), + # CREATE TABLE ... CLONE: ``mode`` is a ``(str, Enum)`` but the constructor + # refuses the bare word the artifact carries ("mode must be a + # CreateTableCloneMode, got str") -- the dict channel keeps the Python + # member and round-trips, so only the two JSON-family channels are pinned. + "rhosocial.activerecord.backend.expression.statements.ddl_table." + "CreateTableCloneStatement": ( + ("json", "xml"), + TypeError, + "must be a CreateTableCloneMode", ), } +#: The channels a row's ``channels`` field may name. +_ARTIFACT_CHANNELS = ("dict", "json", "xml") + +#: The codec pair each channel runs, in one place because three call sites +#: need the same mapping and a drift between them would let one channel be +#: tested on a codec another channel rendered with. +_ARTIFACT_CODECS = {} + + +def _artifact_codecs(): + """(encode, decode) per channel, imported here rather than at module level. + + The serialization module is the only place these names live, and the matrix + imports them where it uses them -- the same discipline the rest of this file + follows for ``UnsupportedFeatureError``. + """ + if not _ARTIFACT_CODECS: + from rhosocial.activerecord.backend.expression.serialization import ( + deserialize, + deserialize_json, + deserialize_xml, + serialize, + serialize_json, + serialize_xml, + ) + + _ARTIFACT_CODECS.update( + { + "dict": (serialize, deserialize), + "json": (serialize_json, deserialize_json), + "xml": (serialize_xml, deserialize_xml), + } + ) + return _ARTIFACT_CODECS + + +def _channel_channels(channels): + """The channel names one pin row covers. + + ``"all"`` covers every channel, because a node whose parameter is not a + JSON scalar cannot be written on any of them -- the three codecs agree + about what a scalar is. + """ + if channels == "all": + return _ARTIFACT_CHANNELS + if isinstance(channels, str): + return (channels,) + return tuple(channels) + + +def _record_channel(fqn: str, channel: str, exc: Exception) -> None: + """Assert *exc* on this channel is the pin :data:`ARTIFACT_GAPS` names. + + Raises: + AssertionError: The class has no row, its row names other channels, or + the exception is not the row's type and fragment -- any of which + means the failure is new and this matrix must not absorb it. + """ + row = ARTIFACT_GAPS.get(fqn) + assert row is not None, ( + f"{fqn}: the {channel} channel could not carry this node " + f"({type(exc).__name__}: {exc}) and the class is not in ARTIFACT_GAPS." + ) + pinned_channels, pinned_type, fragment = row + assert channel in _channel_channels(pinned_channels), ( + f"{fqn}: the {channel} channel failed but the pin names " + f"{pinned_channels!r}. Either the gap moved to another channel or the " + "row needs its channels updated." + ) + assert type(exc) is pinned_type and fragment in str(exc), ( + f"{fqn}: ARTIFACT_GAPS pins {pinned_type.__name__} containing " + f"{fragment!r}, but the {channel} channel raised " + f"{type(exc).__name__}: {exc}" + ) + # --------------------------------------------------------------------------- # The dispatch gap: a missing formatter is a decision, not an omission @@ -1144,20 +1587,22 @@ def _dispatched_formatter(exc): #: those refusals were allowed on their type alone. UNMODELLED_FORMATTERS = { "SQL/XML (SQL Server's native XML/XQuery is not the SQL/XML feature set, " - "so the dialect declares no ``format_xml*`` method at all)": ( - "format_xmlagg_expression", - "format_xmlattributes_expression", - "format_xmlcomment_expression", - "format_xmlconcat_expression", - "format_xmlelement_expression", - "format_xmlexists_expression", - "format_xmlforest_expression", + "so the dialect declares no ``format_xml*`` method at all; the PASSING " + "clause rides that same family)": ( + "format_xml_agg_expression", + "format_xml_attributes_expression", + "format_xml_comment_expression", + "format_xml_concat_expression", + "format_xml_element_expression", + "format_xmlexists_predicate", + "format_xml_forest_expression", "format_xmlpi_expression", - "format_xmlparse_expression", - "format_xmlquery_expression", - "format_xmlroot_expression", - "format_xmlserialize_expression", - "format_xmltable_expression", + "format_xml_parse_expression", + "format_xml_query_expression", + "format_xml_root_expression", + "format_xml_serialize_expression", + "format_xml_table_expression", + "format_passing_clause", ), "DOMAIN (SQL Server has no DOMAIN object, so no domain statement or " "action renders)": ( @@ -1174,13 +1619,13 @@ def _dispatched_formatter(exc): "format_drop_property_graph_statement", "format_graph_table_expression", "format_vertex_table", - "format_edge_table", + "format_edge_table_definition", "format_graph_columns_clause", "format_table_properties_clause", ), "COMMENT ON (SQL Server has no standalone COMMENT ON; extended properties " "spell it, and the dialect's negative list records CommentSupport)": ( - "format_comment_statement", + "format_comment_on_statement", ), "LPAD / RPAD (SQL Server has no pad functions; padding is spelled with " "REPLICATE together with RIGHT / LEFT)": ( @@ -1188,12 +1633,6 @@ def _dispatched_formatter(exc): "format_rpad_expression", ), "REPEAT (SQL Server spells it REPLICATE)": ("format_repeat_expression",), - "the core PIVOT / UNPIVOT expressions (SQL Server spells PIVOT its own " - "way, through SQLServerPivotExpression; core's generic PIVOT is not the " - "same statement)": ( - "format_pivot_expression", - "format_unpivot_expression", - ), "the TableSource root (every concrete row source overrides its formatter; " "the root carries nothing but an alias and is never rendered)": ("format_table_source",), } @@ -1229,6 +1668,11 @@ def assert_sql_roundtrip_classified( in :data:`UNMODELLED_FORMATTERS`; the table entry is the reason. * **anything else** -- a failure naming the class and the exception. + A channel that cannot carry the node is not a render answer either: it is + asserted against :data:`ARTIFACT_GAPS` by :func:`_record_channel`, and the + class is reported as ``"artifact-gap"`` rather than as ``"rendered"`` so the + distribution does not claim three channels where one is pinned. + The dict is consulted before the ``UnsupportedFeatureError`` branch, not after. A missing formatter is now reported through that same type, so with the ``except`` clause in front a pinned dispatch-failure entry would never be @@ -1242,22 +1686,14 @@ def assert_sql_roundtrip_classified( dialect: The dialect to render it against. Returns: - A short string naming the branch taken, so the classification - distribution can be reported. + A short string naming the branch taken -- ``"rendered"``, + ``"artifact-gap"``, ``"non-render"``, ``"unsupported"`` or + ``"unmodelled"`` -- so the classification distribution can be reported. Raises: AssertionError: On a round-trip mismatch, on an unexpected exception type, or when a class's rendering outcome changed. """ - from rhosocial.activerecord.backend.expression.serialization import ( - deserialize, - deserialize_json, - deserialize_xml, - serialize, - serialize_json, - serialize_xml, - ) - try: expected_sql, expected_params = instance.to_sql() except Exception as exc: @@ -1296,22 +1732,36 @@ def assert_sql_roundtrip_classified( f" Exception: {exc}" ) from exc - for channel, decoded in ( - ("dict", deserialize(serialize(instance), dialect)), - ("json", deserialize_json(serialize_json(instance), dialect)), - ("xml", deserialize_xml(serialize_xml(instance), dialect)), - ): - decoded_sql, decoded_params = decoded.to_sql() + gapped = [] + for channel, codec in ((name, _artifact_codecs()[name]) for name in _ARTIFACT_CHANNELS): + encode, decode = codec + try: + decoded = decode(encode(instance), dialect) + decoded_sql, decoded_params = decoded.to_sql() + except Exception as exc: + # A channel that cannot carry the node is recorded, not swallowed: + # _record_channel asserts the pin's type and fragment, so a new + # failure on a new class fails here. + _record_channel(fqn, channel, exc) + gapped.append(channel) + continue assert decoded_sql == expected_sql, ( f"{fqn}: {channel} round-trip changed the SQL.\n" f" original: {expected_sql!r}\n" f" {channel}: {decoded_sql!r}" ) - assert decoded_params == expected_params, ( + # A tuple goes out as an array -- JSON draws no distinction -- and a + # constructor handed a list may keep it as one. The artifact promised + # the members, so the members are what is compared. + assert list(decoded_params) == list(expected_params) and len( + decoded_params + ) == len(expected_params), ( f"{fqn}: {channel} round-trip changed the bind parameters.\n" f" original: {expected_params!r}\n" f" {channel}: {decoded_params!r}" ) + if gapped: + return "artifact-gap" return "rendered" @@ -1337,12 +1787,238 @@ def sqlserver_expr_case(request, sqlserver_dialect): return fqn, instance +def _same_host_value(restored: Any, original: Any) -> bool: + """Whether a round-tripped host value still names the same value. + + Two shapes are not ``==`` to themselves after a round-trip: NaN, and a + tuple, which the JSON-family channels hand back as a list because JSON draws + no distinction between the two -- while the dict channel, which never leaves + Python, keeps the tuple. Both spell the same sequence, and the harness's own + comparison allows both; this is the same tolerance asked of one value. + """ + if isinstance(original, (tuple, list)): + return isinstance(restored, (tuple, list)) and list(original) == list( + restored + ) + if isinstance(original, float) and original != original: + return restored != restored + return restored == original + + +class TestLiteralValueKinds: + """Every value kind travels as a plain JSON scalar, and inlines as text. + + One ``Literal`` class carries every value, so a class-level matrix has one + row for it -- but an artifact is keyed by *kind*, and the sixteen kinds are + sixteen shapes. The table below is that registration: the spelling each kind + packs its host value into, so a kind that started travelling as anything but + a scalar fails here, and so does one whose inline spelling this dialect + changed. NaN and +/-Infinity have kinds of their own now (``nan_value`` / + ``infinity_value``, carrying the words ``"nan"`` / ``"+inf"`` / + ``"-inf"``) rather than being finite floats a text spelling would corrupt. + """ + + #: (host value, kind, the JSON spelling the artifact carries) + VALUE_KINDS = ( + (None, "null_value", None), + (True, "boolean_value", True), + (1, "integer_value", 1), + (2.5, "float_value", 2.5), + (Decimal("1.5"), "decimal_value", "1.5"), + (float("nan"), "nan_value", "nan"), + (float("inf"), "infinity_value", "+inf"), + (float("-inf"), "infinity_value", "-inf"), + ("x", "string_value", "x"), + (b"\x01\x02", "binary_value", "AQI="), + (datetime.date(2026, 1, 2), "date_value", "2026-01-02"), + ( + datetime.datetime(2026, 1, 2, 3, 4, 5), + "timestamp_value", + "2026-01-02 03:04:05", + ), + (datetime.timedelta(days=1), "interval_value", "P1D"), + ([1, 2], "list_value", [1, 2]), + ((1, 2), "tuple_value", [1, 2]), + ( + uuid_module.UUID("01234567-89ab-cdef-0123-456789abcdef"), + "uuid_value", + "01234567-89ab-cdef-0123-456789abcdef", + ), + ) + + @pytest.mark.parametrize( + "value,kind,spelling", + VALUE_KINDS, + ids=[kind for _value, kind, _spelling in VALUE_KINDS], + ) + def test_the_artifact_carries_the_plain_spelling( + self, value, kind, spelling, sqlserver_dialect + ): + import json + + from rhosocial.activerecord.backend.expression.serialization import ( + serialize, + serialize_json, + ) + + literal = Literal(sqlserver_dialect, value) + artifact = serialize(literal) + assert artifact == { + "kind": kind, + "params": {"value": spelling, "inline": False}, + }, f"{kind}: the artifact is not the spelling the table names" + assert json.loads(serialize_json(literal)) == artifact, ( + f"{kind}: the JSON text does not decode to the same artifact" + ) + + @pytest.mark.parametrize( + "value,kind,spelling", + VALUE_KINDS, + ids=[kind for _value, kind, _spelling in VALUE_KINDS], + ) + def test_every_kind_round_trips_through_all_three_channels( + self, value, kind, spelling, sqlserver_dialect + ): + from rhosocial.activerecord.backend.expression.serialization import ( + deserialize, + deserialize_json, + deserialize_xml, + serialize, + serialize_json, + serialize_xml, + ) + + literal = Literal(sqlserver_dialect, value) + for name, encode, decode in ( + ("dict", serialize, deserialize), + ("json", serialize_json, deserialize_json), + ("xml", serialize_xml, deserialize_xml), + ): + restored = decode(encode(literal), sqlserver_dialect) + # Re-encoding rather than reading the class's own kind(): one + # Literal class carries every value kind, so the *artifact* is what + # names it, and a restored node must still encode as the same kind. + assert serialize(restored)["kind"] == kind, ( + f"{kind}: {name} restored another kind" + ) + assert _same_host_value(restored.value, value), ( + f"{kind}: {name} changed the host value: {restored.value!r}" + ) + assert restored.inline is False, f"{kind}: {name} changed the position" + + def test_an_inline_position_writes_text_not_a_parameter(self, sqlserver_dialect): + """The DDL position: the value reaches SQL as text and takes no bind. + + Each kind's *rendered* spelling is the dialect's business and is pinned + in ``test_sqlserver_typed_values.py``; what this row pins is the + position -- ``inline=True`` must give text and no parameters for every + kind that has an inline form on this dialect. The ones with none are + refusals rather than guesses: the non-finite floats, which no SQL text + may carry (``nan_value`` / ``infinity_value`` exist so those payloads + can travel in an artifact *without* ever reaching SQL text); the + interval, whose absence this dialect keeps structural; and the two + sequence kinds, which are a host container rather than a scalar SQL + literal -- SQL Server spells a list as a ``VALUES`` clause or a + table-valued parameter, not as a literal. + """ + refused = { + "nan_value": ValueError, + "infinity_value": ValueError, + "interval_value": TypeError, + "list_value": TypeError, + "tuple_value": TypeError, + } + for value, kind, _spelling in self.VALUE_KINDS: + if kind in refused: + with pytest.raises(refused[kind]): + Literal(sqlserver_dialect, value, inline=True).to_sql() + continue + sql, params = Literal(sqlserver_dialect, value, inline=True).to_sql() + assert params == (), f"{kind}: an inline literal took a bind parameter" + assert sql, f"{kind}: an inline literal rendered nothing" + + def test_the_position_travels_with_the_literal(self, sqlserver_dialect): + """``inline`` is part of the artifact: a position is not a class. + + The class-level strategy wave moved this decision onto the instance, so + a DDL-position literal must come back a DDL-position literal. + """ + from rhosocial.activerecord.backend.expression.serialization import ( + deserialize, + deserialize_json, + deserialize_xml, + serialize, + serialize_json, + serialize_xml, + ) + + inline = Literal(sqlserver_dialect, 1, inline=True) + for encode, decode in ( + (serialize_json, deserialize_json), + (serialize, deserialize), + (serialize_xml, deserialize_xml), + ): + restored = decode(encode(inline), sqlserver_dialect) + assert restored.inline is True + assert restored.to_sql() == ("1", ()) + + class TestSQLServerExpressionRoundtrip: """All constructible expression classes round-trip through all encodings.""" def test_get_params_roundtrip(self, sqlserver_expr_case, sqlserver_dialect): fqn, instance = sqlserver_expr_case - roundtrip_expression(fqn, instance, sqlserver_dialect) + row = ARTIFACT_GAPS.get(fqn) + if row is None: + roundtrip_expression(fqn, instance, sqlserver_dialect) + return + channels, exc_type, fragment = row + pinned = _channel_channels(channels) + codecs = _artifact_codecs() + # A pinned class runs its channels one at a time rather than through + # the three-channel helper: the pinned channel's outcome is the + # assertion here -- it must fail in exactly the recorded way, or the + # row is stale -- while the unpinned ones keep asserting normally. + for name in _ARTIFACT_CHANNELS: + encode, decode = codecs[name] + is_pinned = name in pinned + try: + restored = decode(encode(instance), sqlserver_dialect) + restored_params = restored.get_params() + except Exception as exc: + if is_pinned: + _record_channel(fqn, name, exc) + continue + raise AssertionError( + f"{fqn}: the {name} channel cannot carry this node " + f"({type(exc).__name__}: {exc}) and the pin names " + f"{channels!r}." + ) from exc + if exc_type is not None: + # the row names an exception, so a channel that carries the + # node contradicts it + assert not is_pinned, ( + f"{fqn}: ARTIFACT_GAPS pins {exc_type.__name__} on " + f"{channels!r} but the {name} channel carried the node -- " + "the row is stale" + ) + assert_params_equal(restored_params, instance.get_params()) + continue + if is_pinned: + # the spelling gap: the channel carries the node and hands back + # the same parameter under the XML codec's member spelling. The + # strict comparison must refuse it -- that is the thing the row + # records -- and the tolerant one must accept it, which proves + # the difference is only the spelling and nothing else. + with pytest.raises(AssertionError): + assert_params_equal(restored_params, instance.get_params()) + assert_params_equal( + restored_params, + instance.get_params(), + allow_enum_names=True, + ) + continue + assert_params_equal(restored_params, instance.get_params()) def test_to_sql_roundtrip_classified(self, sqlserver_expr_case, sqlserver_dialect): """A render must survive the round-trip; a non-render must be classified.""" @@ -1360,7 +2036,6 @@ def test_unconstructible_list_is_exact(self, sqlserver_dialect): a constructor and the entry is stale; a class that fails to build without being named would become a silent skip. Both fail here. """ - ExpressionRegistry._auto_register_builtins() actual = tuple( sorted( fqn @@ -1480,7 +2155,6 @@ class that appeared after import is caught. The package walk is used backends other test modules happened to import -- which is exactly the non-determinism this matrix moved off. """ - ExpressionRegistry._auto_register_builtins() for pkg in (CORE_EXPR_PKG, SS_EXPR_PKG): expected = set(_collect(pkg)) covered = { @@ -1499,20 +2173,77 @@ class that appeared after import is caught. The package walk is used def test_every_covered_class_is_registered_for_deserialization(self): """A class in the matrix can be found again when deserializing. - Deserialization looks the class up by name, so a class the matrix renders - but the registry cannot resolve would round-trip into the wrong thing or - nothing at all. + Deserialization resolves a kind through the framework's default + registry -- the same whitelist ``register_all`` filled at import -- + so a class the matrix renders but the registry cannot resolve would + round-trip into the wrong thing or nothing at all. """ - ExpressionRegistry._auto_register_builtins() - unresolved = sorted(set(REGISTERED) - set(ExpressionRegistry._registry)) + from rhosocial.activerecord.backend.expression.serialization import ( + default_registry, + ) + + registry = default_registry() + unresolved = sorted( + fqn + for fqn, cls in REGISTERED.items() + if registry.resolve_node(cls.kind()) is not cls + ) assert not unresolved, ( - f"the matrix covers classes the registry cannot resolve: {unresolved}" + f"the matrix covers classes the kind whitelist cannot resolve: {unresolved}" + ) + + def test_artifact_gap_list_is_exact(self, sqlserver_dialect): + """Pin the artifact-gap table against what the channels really carry. + + Both directions, and each row is checked against the tree right here: a + row naming no collected class, a channel outside the three the matrix + runs, or a *typed* row whose channel now carries the node is a typo that + would exempt nothing while reading as a decision. The ``None``-typed + rows are checked by the per-class test, which asserts both that the + strict comparison refuses the restored params and that the tolerant one + accepts them. + """ + unknown = set(ARTIFACT_GAPS) - set(REGISTERED) + assert not unknown, ( + f"ARTIFACT_GAPS names classes that were not collected: " + f"{sorted(unknown)}" ) + codecs = _artifact_codecs() + for fqn, (channels, exc_type, _fragment) in ARTIFACT_GAPS.items(): + for name in _channel_channels(channels): + assert name in _ARTIFACT_CHANNELS, ( + f"{fqn}: ARTIFACT_GAPS names channel {name!r}; the matrix " + f"runs {list(_ARTIFACT_CHANNELS)}" + ) + instance, _source = make_instance(REGISTERED[fqn], sqlserver_dialect) + assert instance is not None, ( + f"{fqn} is pinned as an artifact gap but cannot be built" + ) + if exc_type is None: + continue # the spelling rows are pinned by the per-class test + for name in _channel_channels(channels): + encode, decode = codecs[name] + try: + decode(encode(instance), sqlserver_dialect).to_sql() + except Exception as exc: + _record_channel(fqn, name, exc) + break + else: + raise AssertionError( + f"{fqn} is pinned in ARTIFACT_GAPS with " + f"{exc_type.__name__} but the {channels} channel carried " + "it -- remove the row" + ) def test_coverage_report(self, sqlserver_dialect): """Surface what the matrix covers, so coverage stays transparent.""" - ExpressionRegistry._auto_register_builtins() - branches = {"rendered": 0, "unsupported": 0, "unmodelled": 0, "non-render": 0} + branches = { + "rendered": 0, + "unsupported": 0, + "unmodelled": 0, + "non-render": 0, + "artifact-gap": 0, + } constructible = [] for fqn in sorted(REGISTERED): instance, source = make_instance(REGISTERED[fqn], sqlserver_dialect) @@ -1531,9 +2262,13 @@ def test_coverage_report(self, sqlserver_dialect): f"unsupported {branches['unsupported']}, " f"unmodelled {branches['unmodelled']} (pinned in " f"UNMODELLED_FORMATTERS), " - f"pinned non-render {branches['non-render']}" + f"pinned non-render {branches['non-render']}, " + f"artifact gap {branches['artifact-gap']}" ) for fqn in UNCONSTRUCTIBLE: print(f" not constructible: {fqn}") for feature, methods in sorted(UNMODELLED_FORMATTERS.items()): print(f" not modelled: {feature} ({len(methods)} formatters)") + for fqn in sorted(ARTIFACT_GAPS): + channel, _type, _fragment = ARTIFACT_GAPS[fqn] + print(f" artifact gap: {fqn} ({channel} channel)") diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_expressions.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/expression/test_expressions.py similarity index 92% rename from tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_expressions.py rename to tests/rhosocial/activerecord_sqlserver_test/feature/backend/expression/test_expressions.py index 80a8562..c0d4c16 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_expressions.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/expression/test_expressions.py @@ -1,4 +1,4 @@ -# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_expressions.py +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/expression/test_expressions.py """ Tests for SQL Server expression formatting. @@ -19,13 +19,13 @@ def dialect(self): def test_offset_fetch_basic(self, dialect): """Test basic OFFSET FETCH formatting.""" from rhosocial.activerecord.backend.expression import ( - QueryExpression, + QueryStatement, Column, ) from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.query_parts import LimitOffsetClause - query = QueryExpression( + query = QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=Table(dialect, "users"), @@ -39,13 +39,13 @@ def test_offset_fetch_basic(self, dialect): def test_offset_fetch_with_offset(self, dialect): """Test OFFSET FETCH with non-zero offset.""" from rhosocial.activerecord.backend.expression import ( - QueryExpression, + QueryStatement, Column, ) from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.query_parts import LimitOffsetClause - query = QueryExpression( + query = QueryStatement( dialect=dialect, select=[Column(dialect, "id")], from_=Table(dialect, "products"), @@ -59,7 +59,7 @@ def test_offset_fetch_with_offset(self, dialect): def test_offset_only_no_fetch(self, dialect): """Test OFFSET with LIMIT (SQL Server requires LIMIT with OFFSET).""" from rhosocial.activerecord.backend.expression import ( - QueryExpression, + QueryStatement, Column, ) from rhosocial.activerecord.backend.expression.objects import Table @@ -67,7 +67,7 @@ def test_offset_only_no_fetch(self, dialect): # In SQL Server, you cannot have OFFSET without LIMIT # So we test that OFFSET FETCH works correctly - query = QueryExpression( + query = QueryStatement( dialect=dialect, select=[Column(dialect, "id")], from_=Table(dialect, "items"), @@ -89,7 +89,7 @@ def dialect(self): def test_output_clause_insert(self, dialect): """Test OUTPUT clause in INSERT statement.""" from rhosocial.activerecord.backend.expression import ( - InsertExpression, + InsertStatement, ValuesSource, ReturningClause, Column, @@ -97,7 +97,7 @@ def test_output_clause_insert(self, dialect): from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.core import Literal - insert = InsertExpression( + insert = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["name"], @@ -113,7 +113,7 @@ def test_output_clause_insert(self, dialect): def test_output_clause_multiple_columns(self, dialect): """Test OUTPUT clause with multiple columns.""" from rhosocial.activerecord.backend.expression import ( - InsertExpression, + InsertStatement, ValuesSource, ReturningClause, Column, @@ -121,7 +121,7 @@ def test_output_clause_multiple_columns(self, dialect): from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.core import Literal - insert = InsertExpression( + insert = InsertStatement( dialect=dialect, into=Table(dialect, "products"), columns=["name", "price"], @@ -143,10 +143,10 @@ def dialect(self): def test_basic_index(self, dialect): """Test basic CREATE INDEX.""" - from rhosocial.activerecord.backend.expression import CreateIndexExpression + from rhosocial.activerecord.backend.expression import CreateIndexStatement from rhosocial.activerecord.backend.expression.objects import Index, Table - idx = CreateIndexExpression( + idx = CreateIndexStatement( dialect=dialect, index=Index(dialect, "idx_users_email"), table=Table(dialect, "users"), @@ -160,10 +160,10 @@ def test_basic_index(self, dialect): def test_unique_index(self, dialect): """Test CREATE UNIQUE INDEX.""" - from rhosocial.activerecord.backend.expression import CreateIndexExpression + from rhosocial.activerecord.backend.expression import CreateIndexStatement from rhosocial.activerecord.backend.expression.objects import Index, Table - idx = CreateIndexExpression( + idx = CreateIndexStatement( dialect=dialect, index=Index(dialect, "idx_users_email_unique"), table=Table(dialect, "users"), @@ -176,10 +176,10 @@ def test_unique_index(self, dialect): def test_composite_index(self, dialect): """Test CREATE INDEX with multiple columns.""" - from rhosocial.activerecord.backend.expression import CreateIndexExpression + from rhosocial.activerecord.backend.expression import CreateIndexStatement from rhosocial.activerecord.backend.expression.objects import Index, Table - idx = CreateIndexExpression( + idx = CreateIndexStatement( dialect=dialect, index=Index(dialect, "idx_orders_composite"), table=Table(dialect, "orders"), @@ -201,7 +201,7 @@ def dialect(self): def test_inner_join(self, dialect): """Test INNER JOIN formatting.""" from rhosocial.activerecord.backend.expression import ( - QueryExpression, + QueryStatement, Column, JoinClause, ) @@ -222,7 +222,7 @@ def test_inner_join(self, dialect): ), ) - query = QueryExpression( + query = QueryStatement( dialect=dialect, select=[Column(dialect, "name", "u")], from_=join, @@ -236,7 +236,7 @@ def test_inner_join(self, dialect): def test_left_join(self, dialect): """Test LEFT JOIN formatting.""" from rhosocial.activerecord.backend.expression import ( - QueryExpression, + QueryStatement, Column, JoinClause, ) @@ -257,7 +257,7 @@ def test_left_join(self, dialect): ), ) - query = QueryExpression( + query = QueryStatement( dialect=dialect, select=[Column(dialect, "name", "u")], from_=join, diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/expression/test_sqlserver_typed_values.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/expression/test_sqlserver_typed_values.py new file mode 100644 index 0000000..84c3c68 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/expression/test_sqlserver_typed_values.py @@ -0,0 +1,139 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/expression/test_sqlserver_typed_values.py +"""The value literals on SQL Server: bindings and spellings. + +One ``Literal`` class carries every value now, and the only question it owns +is how the value reaches SQL: bound (``inline=False``, the data-position +default -- a placeholder plus the host value handed to pyodbc untouched, +because bytes, ``uuid.UUID``, ``bool`` and ``timedelta`` are all natively +bindable) or written as text (``inline=True``, for the DDL syntax positions +that accept no bind parameters). The *spelling* of that text is the dialect's +``format_literal``, and SQL Server differs from the portable forms on three +types and no more: + +* a boolean inlines as ``1``/``0`` -- T-SQL's literal grammar has no + ``TRUE``/``FALSE`` keywords, and ``BIT``'s constants are numeric; +* bytes inline as the unquoted ``0x`` hex constant; +* a UUID inlines as the quoted canonical text a ``UNIQUEIDENTIFIER`` + converts. + +INTERVAL stays refused on purpose: T-SQL has no interval literal, so a +``timedelta`` at an inline position reaches the base implementation's ladder +and fails explicitly naming its type -- the structural absence is the +dialect's answer, not an oversight. + +The live half of the spelling question -- do the servers accept this text and +hand the same value back -- lives with the scenario-driven integration tests, +in ``feature/backend/test_literal_default_execution.py``: an expression test +takes no real connection. That file records what was written and that no +reachable server has confirmed it yet. +""" + +import datetime +import uuid as uuid_module + +import pytest + +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect +from rhosocial.activerecord.backend.impl.sqlserver.mixins.literal import ( + SQLServerLiteralValueMixin, +) + + +UUID_TEXT = "01234567-89ab-cdef-0123-456789abcdef" + + +# --------------------------------------------------------------------------- +# Wiring: the intercept must resolve ahead of the base class +# --------------------------------------------------------------------------- + + +class TestWiring: + """The dialect composes SQL Server's literal mixin ahead of the base.""" + + def test_format_literal_resolves_on_the_sqlserver_intercept(self): + """The override wins over SQLDialectBase's portable ladder. + + ``SQLServerLiteralValueMixin`` precedes ``SQLDialectBase`` in the + base list; if that position were lost, ``format_literal(True)`` + would render the portable keyword T-SQL cannot parse and bytes + would render as nothing at all (the base refuses them to a + backend, which is what makes the tier-2 override the backend's + obligation). + """ + dialect = SQLServerDialect(version=(16, 0, 0)) + assert ( + type(dialect).format_literal + is SQLServerLiteralValueMixin.format_literal + ) + + +# --------------------------------------------------------------------------- +# Offline: both answers of the one question a literal owns +# --------------------------------------------------------------------------- + + +class TestDataPositionBindsTheHostValue: + """``inline=False`` emits a placeholder and hands pyodbc the host value.""" + + @pytest.mark.parametrize( + "value", + [ + b"\x01\x02", + True, + uuid_module.UUID(UUID_TEXT), + datetime.date(2026, 10, 17), + datetime.timedelta(days=1), + ], + ids=["bytes", "bool", "uuid", "date", "timedelta"], + ) + def test_the_value_binds_untouched(self, value): + dialect = SQLServerDialect(version=(16, 0, 0)) + assert Literal(dialect, value).to_sql() == ("?", (value,)) + + +class TestInlinePositionSpellings: + """``inline=True`` writes the exact literal text, no parameters.""" + + def test_boolean_is_numeric(self): + """T-SQL has no TRUE/FALSE keywords; BIT takes 1/0 (see the mixin).""" + dialect = SQLServerDialect(version=(16, 0, 0)) + assert Literal(dialect, True, inline=True).to_sql() == ("1", ()) + assert Literal(dialect, False, inline=True).to_sql() == ("0", ()) + + def test_binary_is_an_unquoted_hex_constant(self): + dialect = SQLServerDialect(version=(16, 0, 0)) + assert Literal(dialect, b"\x01\x02", inline=True).to_sql() == ("0x0102", ()) + + def test_binary_accepts_a_bytearray(self): + dialect = SQLServerDialect(version=(16, 0, 0)) + assert ( + Literal(dialect, bytearray(b"\xff\x00"), inline=True).to_sql() + == ("0xFF00", ()) + ) + + def test_uuid_is_the_quoted_canonical_text(self): + dialect = SQLServerDialect(version=(16, 0, 0)) + value = uuid_module.UUID(UUID_TEXT) + assert Literal(dialect, value, inline=True).to_sql() == (f"'{UUID_TEXT}'", ()) + + def test_tier_one_kinds_keep_the_base_spelling(self): + """The portable scalars fall through to the base implementation.""" + dialect = SQLServerDialect(version=(16, 0, 0)) + assert ( + Literal(dialect, datetime.date(2026, 10, 17), inline=True).to_sql() + == ("'2026-10-17'", ()) + ) + assert Literal(dialect, "it's", inline=True).to_sql() == ("'it''s'", ()) + assert Literal(dialect, 42, inline=True).to_sql() == ("42", ()) + + def test_interval_refuses_explicitly(self): + """No interval literal exists in T-SQL; the refusal names the type. + + The base implementation's ladder is the refusal: this dialect adds + no ``timedelta`` branch, so the value reaches the tier that fails + loudly rather than a spelling that guesses. + """ + dialect = SQLServerDialect(version=(16, 0, 0)) + with pytest.raises(TypeError, match="timedelta"): + Literal(dialect, datetime.timedelta(days=1), inline=True).to_sql() diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/introspection_probe.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/introspection_probe.py new file mode 100644 index 0000000..00ec8f3 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/introspection_probe.py @@ -0,0 +1,136 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/introspection_probe.py +"""Schema objects the live introspection tests share. + +Both the CLI test and the introspector test need a table, a view, a +procedure, a function, a sequence, a trigger, a foreign key and an +``MS_Description`` to point at. Creating them here once keeps the two test +modules from drifting apart, and the names carry the process id so parallel +workers that share a database cannot collide. +""" + +import os + +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +_SUFFIX = f"_{os.getpid()}" + +PROBE_PARENT = f"zz_introspect_parent{_SUFFIX}" +PROBE_TABLE = f"zz_introspect_probe{_SUFFIX}" +PROBE_VIEW = f"zz_introspect_view{_SUFFIX}" +PROBE_PROCEDURE = f"zz_introspect_proc{_SUFFIX}" +PROBE_FUNCTION = f"zz_introspect_fn{_SUFFIX}" +PROBE_SEQUENCE = f"zz_introspect_seq{_SUFFIX}" +PROBE_TRIGGER = f"zz_introspect_trg{_SUFFIX}" +PROBE_TRIGGER_DISABLED = f"zz_introspect_trg_off{_SUFFIX}" +PROBE_DESCRIPTION = "introspection probe table" + +DDL_OPTIONS = ExecutionOptions(stmt_type=StatementType.DDL) + + +def probe_statements(): + """The statements that install the probe objects, in dependency order.""" + return [ + f"IF OBJECT_ID('dbo.{PROBE_TRIGGER}', 'TR') IS NOT NULL " + f"DROP TRIGGER dbo.{PROBE_TRIGGER}", + f"IF OBJECT_ID('dbo.{PROBE_TRIGGER_DISABLED}', 'TR') IS NOT NULL " + f"DROP TRIGGER dbo.{PROBE_TRIGGER_DISABLED}", + f"IF OBJECT_ID('dbo.{PROBE_TABLE}', 'U') IS NOT NULL " f"DROP TABLE dbo.{PROBE_TABLE}", + f"IF OBJECT_ID('dbo.{PROBE_PARENT}', 'U') IS NOT NULL " f"DROP TABLE dbo.{PROBE_PARENT}", + f"IF OBJECT_ID('dbo.{PROBE_VIEW}', 'V') IS NOT NULL " f"DROP VIEW dbo.{PROBE_VIEW}", + f"IF OBJECT_ID('dbo.{PROBE_PROCEDURE}', 'P') IS NOT NULL " + f"DROP PROCEDURE dbo.{PROBE_PROCEDURE}", + f"IF OBJECT_ID('dbo.{PROBE_FUNCTION}', 'FN') IS NOT NULL " + f"DROP FUNCTION dbo.{PROBE_FUNCTION}", + f"IF OBJECT_ID('dbo.{PROBE_SEQUENCE}', 'SO') IS NOT NULL " + f"DROP SEQUENCE dbo.{PROBE_SEQUENCE}", + f"CREATE TABLE dbo.{PROBE_PARENT} (id INT NOT NULL PRIMARY KEY)", + ( + f"CREATE TABLE dbo.{PROBE_TABLE} (" + "id INT NOT NULL PRIMARY KEY, " + "label NVARCHAR(20) NULL, " + "parent_id INT NULL " + f"CONSTRAINT fk_{PROBE_TABLE} REFERENCES dbo.{PROBE_PARENT}(id))" + ), + f"CREATE VIEW dbo.{PROBE_VIEW} AS " f"SELECT id, label FROM dbo.{PROBE_TABLE}", + f"CREATE PROCEDURE dbo.{PROBE_PROCEDURE} AS SELECT 1 AS one", + (f"CREATE FUNCTION dbo.{PROBE_FUNCTION}(@x INT) RETURNS INT " "AS BEGIN RETURN @x + 1 END"), + (f"CREATE SEQUENCE dbo.{PROBE_SEQUENCE} AS BIGINT " "START WITH 5 INCREMENT BY 2"), + ( + f"CREATE TRIGGER dbo.{PROBE_TRIGGER} ON dbo.{PROBE_TABLE} " + "AFTER INSERT AS BEGIN SET NOCOUNT ON; END" + ), + ( + f"CREATE TRIGGER dbo.{PROBE_TRIGGER_DISABLED} ON dbo.{PROBE_TABLE} " + "AFTER UPDATE AS BEGIN SET NOCOUNT ON; END" + ), + f"DISABLE TRIGGER dbo.{PROBE_TRIGGER_DISABLED} ON dbo.{PROBE_TABLE}", + ( + "EXEC sys.sp_addextendedproperty " + "@name=N'MS_Description', " + f"@value=N'{PROBE_DESCRIPTION}', " + "@level0type=N'SCHEMA', @level0name=N'dbo', " + f"@level1type=N'TABLE', @level1name=N'{PROBE_TABLE}'" + ), + ] + + +def teardown_statements(): + """The statements that remove them, children first.""" + return [ + f"IF OBJECT_ID('dbo.{PROBE_TRIGGER}', 'TR') IS NOT NULL " + f"DROP TRIGGER dbo.{PROBE_TRIGGER}", + f"IF OBJECT_ID('dbo.{PROBE_TRIGGER_DISABLED}', 'TR') IS NOT NULL " + f"DROP TRIGGER dbo.{PROBE_TRIGGER_DISABLED}", + f"IF OBJECT_ID('dbo.{PROBE_VIEW}', 'V') IS NOT NULL " f"DROP VIEW dbo.{PROBE_VIEW}", + f"IF OBJECT_ID('dbo.{PROBE_PROCEDURE}', 'P') IS NOT NULL " + f"DROP PROCEDURE dbo.{PROBE_PROCEDURE}", + f"IF OBJECT_ID('dbo.{PROBE_FUNCTION}', 'FN') IS NOT NULL " + f"DROP FUNCTION dbo.{PROBE_FUNCTION}", + f"IF OBJECT_ID('dbo.{PROBE_SEQUENCE}', 'SO') IS NOT NULL " + f"DROP SEQUENCE dbo.{PROBE_SEQUENCE}", + f"IF OBJECT_ID('dbo.{PROBE_TABLE}', 'U') IS NOT NULL " f"DROP TABLE dbo.{PROBE_TABLE}", + f"IF OBJECT_ID('dbo.{PROBE_PARENT}', 'U') IS NOT NULL " f"DROP TABLE dbo.{PROBE_PARENT}", + ] + + +def create_probe_objects(backend): + """Install the probe objects on a synchronous backend.""" + for statement in probe_statements(): + backend.execute(statement, options=DDL_OPTIONS) + + +async def create_probe_objects_async(backend): + """Install the probe objects on an asynchronous backend.""" + for statement in probe_statements(): + await backend.execute(statement, options=DDL_OPTIONS) + + +def drop_probe_objects(backend): + """Remove the probe objects, ignoring individual failures.""" + for statement in teardown_statements(): + try: + backend.execute(statement, options=DDL_OPTIONS) + except Exception: + pass + + +async def drop_probe_objects_async(backend): + """Remove the probe objects, ignoring individual failures.""" + for statement in teardown_statements(): + try: + await backend.execute(statement, options=DDL_OPTIONS) + except Exception: + pass + + +def probe_object_id(backend): + """The ``object_id`` of the probe table, for extended-property lookups.""" + result = backend.execute("SELECT OBJECT_ID(?) AS oid", (f"dbo.{PROBE_TABLE}",)) + return result.data[0]["oid"] + + +async def probe_object_id_async(backend): + """``probe_object_id`` against an asynchronous backend.""" + result = await backend.execute("SELECT OBJECT_ID(?) AS oid", (f"dbo.{PROBE_TABLE}",)) + return result.data[0]["oid"] diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/sqlserver/test_uuid_and_json_truth.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/sqlserver/test_uuid_and_json_truth.py index 373b147..1e5616c 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/sqlserver/test_uuid_and_json_truth.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/sqlserver/test_uuid_and_json_truth.py @@ -17,7 +17,7 @@ Column, Literal, StringColumn, - UUIDCastExpression, + UUIDCastFunction, UUIDConstantExpression, UUIDGenerationExpression, ) @@ -76,7 +76,7 @@ def test_constants_are_cast_literals(which, expected): def test_cast_uses_the_t_sql_form(): dialect = _dialect() - expr = UUIDCastExpression(dialect, Literal(dialect, "not-a-uuid")) + expr = UUIDCastFunction(dialect, Literal(dialect, "not-a-uuid")) assert expr.to_sql() == ("CAST(? AS UNIQUEIDENTIFIER)", ("not-a-uuid",)) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_alter_table_if_exists_backend.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_alter_table_if_exists_backend.py index 07f4eae..f36965f 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_alter_table_if_exists_backend.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_alter_table_if_exists_backend.py @@ -11,9 +11,9 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.statements import ColumnDefinition from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( - AddColumn, - DropColumn, - DropTableConstraint, + AddColumnAction, + DropColumnAction, + DropTableConstraintAction, ) from rhosocial.activerecord.backend.expression.types import TextType from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect @@ -33,14 +33,14 @@ def test_supports_switches(self, dialect): class TestSQLServerAddColumn: def test_add_column_omits_column_literal(self, dialect): - action = AddColumn(dialect, ColumnDefinition(dialect, "content", TextType(dialect))) + action = AddColumnAction(dialect, ColumnDefinition(dialect, "content", TextType(dialect))) sql, params = action.to_sql() assert sql.startswith("ADD [content]") assert "ADD COLUMN" not in sql assert params == () def test_add_column_if_not_exists_raises(self, dialect): - action = AddColumn( + action = AddColumnAction( dialect, ColumnDefinition(dialect, "content", TextType(dialect)), if_not_exists=True, @@ -51,13 +51,13 @@ def test_add_column_if_not_exists_raises(self, dialect): class TestSQLServerDropColumnIfExists: def test_if_exists_renders_qualifier(self, dialect): - action = DropColumn(dialect, column_name="x", if_exists=True) + action = DropColumnAction(dialect, column_name="x", if_exists=True) sql, params = action.to_sql() assert "DROP COLUMN IF EXISTS [x]" == sql assert params == () def test_none_renders_plain_form(self, dialect): - action = DropColumn(dialect, column_name="x") + action = DropColumnAction(dialect, column_name="x") sql, params = action.to_sql() assert "DROP COLUMN [x]" == sql assert "IF EXISTS" not in sql @@ -66,7 +66,7 @@ def test_none_renders_plain_form(self, dialect): class TestSQLServerDropConstraintIfExists: def test_if_exists_renders_qualifier(self, dialect): - action = DropTableConstraint( + action = DropTableConstraintAction( dialect, constraint_name="fk", if_exists=True ) sql, params = action.to_sql() @@ -74,7 +74,7 @@ def test_if_exists_renders_qualifier(self, dialect): assert params == () def test_none_renders_plain_form(self, dialect): - action = DropTableConstraint(dialect, constraint_name="fk") + action = DropTableConstraintAction(dialect, constraint_name="fk") sql, params = action.to_sql() assert "DROP CONSTRAINT [fk]" == sql assert "IF EXISTS" not in sql diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_auto_increment_ddl.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_auto_increment_ddl.py index 9bce4b8..026fefe 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_auto_increment_ddl.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_auto_increment_ddl.py @@ -12,7 +12,8 @@ import pytest from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, ColumnDefinition, ColumnConstraint, ColumnConstraintType, + CreateTableStatement, ColumnDefinition, ColumnConstraint, + ColumnDefaultConstraint, ColumnNotNullConstraint, ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import ( BooleanType, IntegerType, TextType, TimestampType, VarCharType, @@ -27,19 +28,19 @@ def dialect(): def _pk(dialect): - return [ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)] + return [ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))] def _build_table(dialect): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, "test_tbl"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=_pk(dialect)), ColumnDefinition(dialect, "name", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "flag", BooleanType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, True))]), ColumnDefinition(dialect, "code", VarCharType(dialect, length=16)), ColumnDefinition(dialect, "created_at", TimestampType(dialect)), ], @@ -58,20 +59,20 @@ def test_all_standard_types_compile(self, dialect): assert isinstance(params, tuple) def test_boolean_default_true(self, dialect): - expr = CreateTableExpression( + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "bool_test"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=_pk(dialect)), ColumnDefinition(dialect, "flag", BooleanType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, True))]), ], ) sql, _ = dialect.format_create_table_statement(expr) assert "BIT" in sql.upper() def test_timestamp_type(self, dialect): - expr = CreateTableExpression( + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "ts_test"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=_pk(dialect)), diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_backend_explain.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_backend_explain.py index e672457..3db9f2c 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_backend_explain.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_backend_explain.py @@ -3,9 +3,9 @@ from rhosocial.activerecord.backend.explain import SyncExplainBackendProtocol from rhosocial.activerecord.backend.expression import ( - QueryExpression, + QueryStatement, + AllColumnsWildcard, Column, - WildcardExpression, Literal, WhereClause, ) @@ -92,9 +92,9 @@ class TestSyncExplainBuildSQL: def test_build_explain_sql_structure(self, sqlserver_backend_single): backend = sqlserver_backend_single dialect = backend.dialect - expr = QueryExpression( + expr = QueryStatement( dialect, - select=[WildcardExpression(dialect)], + select=[AllColumnsWildcard(dialect)], from_=Table(dialect, "t") ) sql, params = backend._build_explain_sql(expr) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_classed_function_entries.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_classed_function_entries.py new file mode 100644 index 0000000..ae61dee --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_classed_function_entries.py @@ -0,0 +1,203 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_classed_function_entries.py +"""The classed function entries and the bare VALUES cells (guide wave M1). + +Two follow-ups the core has already landed and this backend owed: + +* **Classed entries** (core 9b559030 + c5210245): the standard functions + stopped riding the generic ``FunctionCall`` -- each has its own node, + its own kind and its own dialect formatter, and ``FunctionCall`` is + reserved for UDFs. This backend's factories build the classed nodes for + MAX / MIN / AVG / COALESCE / TRIM, its dialect spells CHAR_LENGTH as + ``LEN`` (the core docstring names SQL Server as the backend whose length + function answers in UTF-16 code units, excluding trailing blanks), and + NOW / CURRENT_DATE / CURRENT_TIME keep their T-SQL spellings through the + classed formatters that replaced the deleted rename tables. +* **Bare VALUES cells** (core 8e2e4d82): an INSERT's VALUES cell may be a + plain host value, which binds; the dialect's INSERT override now + delegates that decision to the core's ``format_insert_values_source`` + instead of re-implementing the loop and failing on a bare scalar. +""" + +import pytest + +from rhosocial.activerecord.backend.expression import Column, TrimExpression +from rhosocial.activerecord.backend.expression.core import FunctionCall, Literal +from rhosocial.activerecord.backend.expression.functions.datetime import ( + current_date, + current_time, + current_timestamp, + now, +) +from rhosocial.activerecord.backend.expression.functions.string import CharLengthFunction +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.statements import ( + InsertStatement, + ValuesSource, +) +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect +from rhosocial.activerecord.backend.impl.sqlserver.functions.aggregate import ( + avg, + max_, + min_, +) +from rhosocial.activerecord.backend.impl.sqlserver.functions.string import trim +from rhosocial.activerecord.backend.impl.sqlserver.functions.utility import coalesce +from rhosocial.activerecord.backend.expression.functions import ( + AvgFunction, + MaxFunction, + MinFunction, +) +from rhosocial.activerecord.backend.expression.functions.string import CoalesceFunction + + +DIALECT = SQLServerDialect(version=(16, 0, 0)) + + +class TestClassedAggregateFactories: + """max_/min_/avg build the classed aggregates, not generic calls.""" + + def test_max_builds_the_class_node(self): + node = max_(DIALECT, "price") + assert isinstance(node, MaxFunction) + assert node.to_sql() == ("MAX([price])", ()) + + def test_min_builds_the_class_node(self): + node = min_(DIALECT, Column(DIALECT, "age")) + assert isinstance(node, MinFunction) + assert node.to_sql() == ("MIN([age])", ()) + + def test_avg_builds_the_class_node(self): + node = avg(DIALECT, Column(DIALECT, "score")) + assert isinstance(node, AvgFunction) + assert node.to_sql() == ("AVG([score])", ()) + + +class TestCharLengthSpellsLen: + """CHAR_LENGTH is LEN here -- UTF-16 units, trailing blanks excluded.""" + + def test_char_length_renders_len(self): + assert CharLengthFunction(DIALECT, Column(DIALECT, "name")).to_sql() == ( + "LEN([name])", + (), + ) + + def test_the_override_lives_on_the_dialect_body(self): + """The MRO must find this spelling, not the core's portable one. + + ``format_char_length_function`` rides into the MRO early through + ``ExpressionMixin`` -> ``FunctionCallMixin``; the dialect body is + the one position guaranteed ahead of it (see the method's + docstring), and this pins that resolution. + """ + assert ( + type(DIALECT).format_char_length_function + is SQLServerDialect.format_char_length_function + ) + + +class TestClassedNiladicSpellings: + """NOW / CURRENT_DATE / CURRENT_TIME through the classed formatters. + + These used to be rename entries in the deleted ``format_function_call`` + tables; the classed nodes dispatch straight to the dialect formatters + now, so the spellings live here (CURRENT_TIMESTAMP stays the standard + niladic keyword T-SQL accepts as-is). + """ + + def test_now_is_sysdatetime(self): + assert now(DIALECT).to_sql() == ("SYSDATETIME()", ()) + + def test_current_date_is_cast_getdate(self): + assert current_date(DIALECT).to_sql() == ("CAST(GETDATE() AS DATE)", ()) + + def test_current_time_is_cast_getdate(self): + assert current_time(DIALECT).to_sql() == ("CAST(GETDATE() AS TIME)", ()) + + def test_current_timestamp_keeps_the_standard_keyword(self): + assert current_timestamp(DIALECT).to_sql() == ("CURRENT_TIMESTAMP", ()) + + @pytest.mark.parametrize( + "factory", + [now, current_date, current_time], + ids=["now", "current_date", "current_time"], + ) + def test_a_precision_is_refused_rather_than_dropped(self, factory): + """No T-SQL spelling takes a precision, so one is an explicit error.""" + with pytest.raises(UnsupportedFeatureError, match="precision"): + factory(DIALECT, precision=Literal(DIALECT, 3)).to_sql() + + +class TestClassedStringEntries: + """trim and coalesce build the classed nodes with this factory's semantics.""" + + def test_trim_without_characters(self): + node = trim(DIALECT, "name") + assert isinstance(node, TrimExpression) + assert node.to_sql() == ("TRIM([name])", ()) + + def test_trim_puts_the_characters_first(self): + """T-SQL spells the character set before the operand (2017+).""" + node = trim(DIALECT, "name", "x") + assert isinstance(node, TrimExpression) + assert node.to_sql() == ("TRIM(? FROM [name])", ("x",)) + + def test_coalesce_builds_the_class_node(self): + """The factory's conversion (bare string names a column) is kept.""" + node = coalesce(DIALECT, "a", 1) + assert isinstance(node, CoalesceFunction) + assert node.to_sql() == ("COALESCE([a], ?)", (1,)) + + +class TestFunctionCallIsUdfOnly: + """With the rename tables gone, a generic call renders as written. + + ``FunctionCall`` is the UDF escape hatch (core c5210245); the standard + names it used to remap have their own classed formatters, so a call + constructed here reaches the server verbatim -- which is the contract + a UDF caller needs. + """ + + def test_a_udf_call_renders_as_written(self): + """The core spells the name uppercased, as it did through the old + override's passthrough; T-SQL names are case-insensitive.""" + call = FunctionCall(DIALECT, "dbo.my_udf", Column(DIALECT, "x")) + assert call.to_sql() == ("DBO.MY_UDF([x])", ()) + + +class TestInsertBareValuesCells: + """An INSERT's VALUES cell may be a bare host value (core 8e2e4d82).""" + + def test_bare_cells_bind(self): + insert = InsertStatement( + dialect=DIALECT, + into=Table(DIALECT, "t"), + columns=["a", "b"], + source=ValuesSource(DIALECT, [[1, "x"]]), + ) + assert insert.to_sql() == ("INSERT INTO [t] ([a], [b]) VALUES (?, ?)", (1, "x")) + + def test_bare_and_expression_cells_mix(self): + """An expression cell renders its own SQL; a bare cell binds.""" + insert = InsertStatement( + dialect=DIALECT, + into=Table(DIALECT, "t"), + columns=["a", "b"], + source=ValuesSource( + DIALECT, + [[1, "x"], [Column(DIALECT, "c"), None]], + ), + ) + assert insert.to_sql() == ( + "INSERT INTO [t] ([a], [b]) VALUES (?, ?), ([c], ?)", + (1, "x", None), + ) + + def test_expression_cells_render_their_own_sql(self): + insert = InsertStatement( + dialect=DIALECT, + into=Table(DIALECT, "t"), + columns=["a"], + source=ValuesSource(DIALECT, [[Literal(DIALECT, 7)]]), + ) + assert insert.to_sql() == ("INSERT INTO [t] ([a]) VALUES (?)", (7,)) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_clause_pair_guard_sqlserver.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_clause_pair_guard_sqlserver.py index 2af33e1..1815490 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_clause_pair_guard_sqlserver.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_clause_pair_guard_sqlserver.py @@ -20,7 +20,7 @@ Two kinds of pair appear here, and both are checked the same way: -* **spellable** (``AlterSequenceExpression.cycle`` and friends): the dialect +* **spellable** (``AlterSequenceStatement.cycle`` and friends): the dialect has a spelling for both sides, so the render asserts the spelling. * **unspellable** (``IdentityClause.cycle`` and friends): the server refuses the spelling, so an explicitly set parameter must be refused by name -- @@ -29,10 +29,9 @@ Which pairs the dialect consumes was read from the source, not guessed: ``format_alter_sequence_statement`` and ``format_identity_clause`` read the -sequence/identity pairs; ``format_set_operation_expression`` reads ``all_``; -the SQL Server overrides of the core TRUNCATE / DROP VIEW / DROP SCHEMA / -constraint / transaction formatters *replace* core's gated versions, so they -carry the gates for the pairs those nodes grew. +sequence/identity pairs; the SQL Server overrides of the core TRUNCATE / +DROP VIEW / DROP SCHEMA / constraint / transaction formatters *replace* core's +gated versions, so they carry the gates for the pairs those nodes grew. Every "unspellable" verdict is backed by a live-server measurement (SQL Server 2019 / 2022 / 2025, ``{SQL Server}`` driver), recorded in the round's plan @@ -51,32 +50,31 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.core import Column from rhosocial.activerecord.backend.expression.objects import Schema, Sequence, Table, View -from rhosocial.activerecord.backend.expression.query_sources import SetOperationExpression from rhosocial.activerecord.backend.expression.statements.ddl_schema import ( - DropSchemaExpression, + DropSchemaStatement, ) from rhosocial.activerecord.backend.expression.statements.ddl_sequence import ( - AlterSequenceExpression, + AlterSequenceStatement, ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + ColumnCheckConstraint, ColumnConstraint, - ColumnConstraintType, ColumnDefinition, ForeignKeyConstraint, IdentityClause, + TableCheckConstraint, TableConstraint, - TableConstraintType, ) from rhosocial.activerecord.backend.expression.statements.ddl_truncate import ( - TruncateExpression, + TruncateStatement, ) from rhosocial.activerecord.backend.expression.statements.ddl_view import ( - DropViewExpression, + DropViewStatement, ) -from rhosocial.activerecord.backend.expression.statements.dql import QueryExpression +from rhosocial.activerecord.backend.expression.statements.dql import QueryStatement from rhosocial.activerecord.backend.expression.transaction import ( - BeginTransactionExpression, - SetTransactionExpression, + BeginTransactionStatement, + SetTransactionStatement, ) from rhosocial.activerecord.backend.expression.types import IntegerType from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect @@ -84,7 +82,7 @@ SQLServerAlterColumn, ) from rhosocial.activerecord.backend.impl.sqlserver.expression.columnstore import ( - SQLServerColumnstoreIndexExpression, + SQLServerColumnstoreIndexStatement, ) @@ -101,13 +99,7 @@ def _table(d, name="t"): def _query(d, table="t1"): - return QueryExpression(d, select=[Column(d, "a")], from_=_table(d, table)) - - -def _union(d, **kw): - return SetOperationExpression( - d, left=_query(d, "t1"), right=_query(d, "t2"), operation="UNION", **kw - ) + return QueryStatement(d, select=[Column(d, "a")], from_=_table(d, table)) def _predicate(d): @@ -130,13 +122,7 @@ def _fk(d, **kw): def _check(d, **kw): - return TableConstraint( - d, - TableConstraintType.CHECK, - name="ck", - check_condition=_predicate(d), - **kw, - ) + return TableConstraint(d, branch=TableCheckConstraint(d, _predicate(d)), name="ck", **kw) def _column_check(d, **kw): @@ -151,13 +137,7 @@ def _column_check(d, **kw): "a", IntegerType(d), constraints=[ - ColumnConstraint( - d, - ColumnConstraintType.CHECK, - name="ck", - check_condition=_predicate(d), - **kw, - ) + ColumnConstraint(d, branch=ColumnCheckConstraint(d, _predicate(d)), name="ck", **kw) ], ) @@ -170,7 +150,7 @@ def _columnstore(d, **kw): """A columnstore index; NONCLUSTERED takes its key list, the rest do not.""" if kw.get("nonclustered"): kw.setdefault("columns", ["c"]) - return SQLServerColumnstoreIndexExpression(d, "ix", "t", **kw) + return SQLServerColumnstoreIndexStatement(d, "ix", "t", **kw) class PairCase: @@ -216,16 +196,16 @@ def render(self, dialect, **kwargs): PAIR_CASES = ( # --- ALTER SEQUENCE: CYCLE / CACHE are spellable, ORDER is not --- PairCase( - "AlterSequenceExpression.cycle", - lambda d, **kw: AlterSequenceExpression(d, _seq(d), **kw), + "AlterSequenceStatement.cycle", + lambda d, **kw: AlterSequenceStatement(d, _seq(d), **kw), "cycle", "no_cycle", r"(? 0 + assert prop.minor_id == 0 + + +class TestSyncBackendSpecific: + def test_list_schemas(self, probed_backend): + assert_schemas(probed_backend.introspector.list_schemas()) + + def test_list_procedures(self, probed_backend): + assert_procedures(probed_backend.introspector.list_procedures(schema="dbo")) + + def test_list_functions(self, probed_backend): + assert_functions(probed_backend.introspector.list_functions(schema="dbo")) + + def test_list_sequences(self, probed_backend): + assert_sequences(probed_backend.introspector.list_sequences(schema="dbo")) + + def test_list_extended_properties(self, probed_backend): + object_id = probe_object_id(probed_backend) + properties = probed_backend.introspector.list_extended_properties(object_id, schema="dbo") + assert_extended_properties(properties) + + def test_tables_carry_their_comment(self, probed_backend): + """``MS_Description`` reaches ``TableInfo.comment`` through the join.""" + tables = probed_backend.introspector.list_tables("dbo") + probe = [t for t in tables if t.name == PROBE_TABLE][0] + assert probe.comment == PROBE_DESCRIPTION + + def test_extended_properties_unknown_class_refused(self, probed_backend): + with pytest.raises(ValueError): + probed_backend.introspector.list_extended_properties(1, class_name="NOT_A_CLASS") + + def test_extended_properties_schema_only_narrows_objects(self, probed_backend): + with pytest.raises(ValueError): + probed_backend.introspector.list_extended_properties( + 1, class_name="DATABASE", schema="dbo" + ) + + +class TestAsyncBackendSpecific: + async def test_list_schemas(self, probed_async_backend): + assert_schemas(await probed_async_backend.introspector.list_schemas()) + + async def test_list_procedures(self, probed_async_backend): + assert_procedures(await probed_async_backend.introspector.list_procedures(schema="dbo")) + + async def test_list_functions(self, probed_async_backend): + assert_functions(await probed_async_backend.introspector.list_functions(schema="dbo")) + + async def test_list_sequences(self, probed_async_backend): + assert_sequences(await probed_async_backend.introspector.list_sequences(schema="dbo")) + + async def test_list_extended_properties(self, probed_async_backend): + object_id = await probe_object_id_async(probed_async_backend) + properties = await probed_async_backend.introspector.list_extended_properties( + object_id, schema="dbo" + ) + assert_extended_properties(properties) + + async def test_tables_carry_their_comment(self, probed_async_backend): + tables = await probed_async_backend.introspector.list_tables("dbo") + probe = [t for t in tables if t.name == PROBE_TABLE][0] + assert probe.comment == PROBE_DESCRIPTION diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_introspection_sql_builders.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_introspection_sql_builders.py index b5592c6..5793928 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_introspection_sql_builders.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_introspection_sql_builders.py @@ -1,14 +1,41 @@ # tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_introspection_sql_builders.py -"""Offline tests for the introspector's pure-Python paths. - -Covers the SQL injection hardening changes: every ``_build_*_sql`` method -now returns parameterized SQL (``?`` placeholders) with separate params, -instead of string-interpolated values. Also covers the catalog-row to -``full_type`` rebuild in ``_parse_columns``: the ``-1`` (MAX) marker that the -rebuild used to drop, and the ``DATETIME_PRECISION`` parameter of -``time``/``datetime2``/``datetimeoffset``, which it used to ignore. +"""Offline tests for the introspection questions, and the SQL they render. + +Covers the injection hardening: every question is parameterized (``?`` +placeholders) with its values kept in a separate tuple, instead of +string-interpolated. Also covers the catalog-row to ``full_type`` rebuild in +``parse_columns``: the ``-1`` (MAX) marker that the rebuild used to drop, and +the ``DATETIME_PRECISION`` parameter of ``time``/``datetime2``/ +``datetimeoffset``, which it used to ignore. + +Every question here is a ``QueryStatement`` over expression nodes, rendered by +the dialect (org guide §16.2). For the families core models that means the +dialect's ``format_*_query_statement`` writes the text -- the introspector only +constructs the statement; for the families only this backend has, the +statement class lives in ``expression/introspection.py`` and its formatter in +``mixins/introspection.py``, and the introspector's ``*_statement`` factory +names one. Either way what the assertions read is the *rendered* statement: the +value the caller passed became a bind parameter and no value leaked into the +text. The identifier spelling is the dialect's, so it is asserted as +``[schema].[TABLE]`` where a position matters and left free where it does not. """ + +from decimal import Decimal + +import pytest + +from rhosocial.activerecord.backend.expression.introspection import ( + ColumnInfoQueryStatement, + DatabaseInfoQueryStatement, + ForeignKeyQueryStatement, + IndexInfoQueryStatement, + TableListQueryStatement, + TriggerListQueryStatement, + ViewInfoQueryStatement, + ViewListQueryStatement, +) from rhosocial.activerecord.backend.expression.types import DataType, TextType +from rhosocial.activerecord.backend.introspection.types import TableType from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig from rhosocial.activerecord.backend.impl.sqlserver.expression.types import ( @@ -19,117 +46,742 @@ ) from rhosocial.activerecord.backend.impl.sqlserver.introspection import ( SyncSQLServerIntrospector, + SyncSQLServerStatusIntrospector, ) -def make_introspector() -> SyncSQLServerIntrospector: +def make_introspector(version=(16, 0, 0)) -> SyncSQLServerIntrospector: config = SQLServerConnectionConfig( - host="localhost", port=1433, database="master", username="sa", password="", + host="localhost", + port=1433, + database="master", + username="sa", + password="", ) - backend = SQLServerBackend(connection_config=config) - backend._version = (16, 0, 0) + backend = SQLServerBackend(connection_config=config, version=version) introspector = SyncSQLServerIntrospector(backend, executor=object()) return introspector +def make_dialect(): + """The dialect the introspector renders its statements with.""" + return make_introspector()._backend.dialect + + class TestBuildTableListSql: + """The table list reads ``sys.objects``, where the kind is a letter. + + ``INFORMATION_SCHEMA.TABLES`` spelled the kind as two words and dropped + every ``is_ms_shipped`` row, so a system table was indistinguishable from + a user table and the CLI's ``--include-system`` flag changed nothing. The + catalogue view carries ``sys.objects.type`` (``U``/``V``/``S``) and the + ``is_ms_shipped`` flag, which are the two facts the question is about. + """ + def test_default_schema_parameterized(self): - introspector = make_introspector() - sql, params = introspector._build_table_list_sql(None, False) - assert "TABLE_SCHEMA = ?" in sql + sql, params = TableListQueryStatement(make_dialect()).to_sql() + assert "[s].[name] = ?" in sql assert params == ("dbo",) + assert "'dbo'" not in sql + + def test_kinds_are_the_catalogue_codes(self): + sql, _ = TableListQueryStatement(make_dialect()).to_sql() + assert "[o].[type] IN ('U', 'V')" in sql + # System tables are ms-shipped objects: they stay out unless asked for. + assert "[o].[is_ms_shipped] = 0" in sql + assert "[sys].[objects] AS [o]" in sql def test_with_table_type_parameterized(self): - introspector = make_introspector() - sql, params = introspector._build_table_list_sql("sys", False, table_type="BASE TABLE") - assert "TABLE_TYPE = ?" in sql - assert params == ("sys", "BASE TABLE") + sql, params = TableListQueryStatement( + make_dialect(), schema="sys", table_type="BASE TABLE" + ).to_sql() + assert "[s].[name] = ?" in sql + assert "[o].[type] IN ('U')" in sql + assert params == ("sys",) + assert "'sys'" not in sql def test_without_views_no_table_type(self): - introspector = make_introspector() - sql, params = introspector._build_table_list_sql("dbo", False, include_views=False) - assert "BASE TABLE" in sql + sql, params = TableListQueryStatement( + make_dialect(), schema="dbo", include_views=False + ).to_sql() + assert "[o].[type] IN ('U')" in sql assert params == ("dbo",) + def test_include_system_admits_the_system_kind(self): + sql, params = TableListQueryStatement( + make_dialect(), schema="sys", include_system=True + ).to_sql() + assert "[o].[type] IN ('U', 'V', 'S')" in sql + assert "is_ms_shipped" not in sql + assert params == ("sys",) + + def test_naming_the_system_kind_implies_include_system(self): + """``table_type='SYSTEM TABLE'`` would otherwise filter out its own rows.""" + sql, params = TableListQueryStatement( + make_dialect(), schema="sys", table_type="SYSTEM TABLE" + ).to_sql() + assert "[o].[type] IN ('S')" in sql + assert "is_ms_shipped" not in sql + assert params == ("sys",) + + def test_the_comment_is_joined_from_extended_properties(self): + """SQL Server has no COMMENT clause; ``MS_Description`` is the comment.""" + sql, params = TableListQueryStatement(make_dialect(), schema="dbo").to_sql() + assert "LEFT JOIN [sys].[extended_properties] AS [ep]" in sql + assert "[ep].[major_id] = [o].[object_id]" in sql + assert "[ep].[minor_id] = 0" in sql, "the object-level property, not a column's" + assert "[ep].[name] = 'MS_Description'" in sql + assert "CAST([ep].[value] AS NVARCHAR(4000)) AS [table_comment]" in sql + assert params == ("dbo",), "the comment adds no parameter" + + +class TestParseTables: + """``sys.objects.type`` becomes the shared table-kind vocabulary.""" + + @staticmethod + def _parse(code): + row = {"TABLE_NAME": "t", "TABLE_TYPE": code} + return make_introspector().parse_tables([row], "dbo")[0].table_type + + def test_user_table_is_a_base_table(self): + assert self._parse("U") == TableType.BASE_TABLE + + def test_view_stays_a_view(self): + assert self._parse("V") == TableType.VIEW + + def test_system_table_is_named_as_one(self): + assert self._parse("S") == TableType.SYSTEM_TABLE + + def test_information_schema_spellings_still_parse(self): + assert self._parse("BASE TABLE") == TableType.BASE_TABLE + assert self._parse("VIEW") == TableType.VIEW + + def test_the_joined_comment_is_read(self): + row = {"TABLE_NAME": "t", "TABLE_TYPE": "U", "table_comment": "what it holds"} + assert make_introspector().parse_tables([row], "dbo")[0].comment == "what it holds" + + def test_a_table_without_a_comment_keeps_none(self): + """The LEFT JOIN answers NULL, which is "no comment", not an empty one.""" + row = {"TABLE_NAME": "t", "TABLE_TYPE": "U", "table_comment": None} + assert make_introspector().parse_tables([row], "dbo")[0].comment is None + + +class TestParseDatabaseInfo: + """The version comes from what the server told the dialect, never a raise.""" + + def test_version_is_taken_from_the_adapted_server(self): + introspector = make_introspector(version=(16, 0, 4250)) + info = introspector.parse_database_info( + [{"db_name": "master", "version": "16.0.4250.1", "collation": "x"}] + ) + assert info.version == "16.0.4250" + assert info.version_tuple == (16, 0, 4250) + assert info.name == "master" + assert info.collation == "x" + + def test_row_is_the_fallback_when_nothing_adapted(self): + introspector = make_introspector() + introspector._backend._version = None + introspector._backend._dialect._version = None + info = introspector.parse_database_info( + [{"db_name": "master", "version": "16.0.4250.1"}] + ) + assert info.version == "16.0.4250.1" + assert info.version_tuple == (16, 0, 4250) + + def test_unknown_version_is_spelled_out(self): + introspector = make_introspector() + introspector._backend._version = None + introspector._backend._dialect._version = None + info = introspector.parse_database_info([]) + assert info.version == "unknown" + assert info.version_tuple == (0, 0, 0) + + def test_a_statement_answering_no_size_reports_none(self): + info = make_introspector().parse_database_info([{"db_name": "master"}]) + assert info.table_count is None + assert info.size_bytes is None + class TestBuildColumnInfoSql: def test_parameterized(self): - introspector = make_introspector() - sql, params = introspector._build_column_info_sql("users", "dbo") - assert "c.TABLE_SCHEMA = ?" in sql + sql, params = ColumnInfoQueryStatement( + make_dialect(), "users", schema="dbo" + ).to_sql() + assert "[TABLE_SCHEMA] = ?" in sql + assert "[TABLE_NAME] = ?" in sql assert params == ("dbo", "users") def test_selects_datetime_precision(self): """The temporal parameter lives only in ``DATETIME_PRECISION``.""" - introspector = make_introspector() - sql, _ = introspector._build_column_info_sql("users", "dbo") - assert "c.DATETIME_PRECISION" in sql + sql, _ = ColumnInfoQueryStatement(make_dialect(), "users", schema="dbo").to_sql() + assert "DATETIME_PRECISION" in sql + + def test_identity_flag_rides_the_column(self): + """``COLUMNPROPERTY(..., 'IsIdentity')`` keeps its alias in the node.""" + sql, _ = ColumnInfoQueryStatement(make_dialect(), "users", schema="dbo").to_sql() + assert "COLUMNPROPERTY" in sql + assert "IS_IDENTITY" in sql + assert "IsIdentity" in sql + + def test_the_comment_is_joined_through_sys_columns(self): + """The property is keyed by the object id and the column id. + + ``INFORMATION_SCHEMA.COLUMNS`` carries neither, so ``sys.columns`` is + joined to reach them; both joins are LEFT so a column without a + description keeps its row. + """ + sql, params = ColumnInfoQueryStatement( + make_dialect(), "users", schema="dbo" + ).to_sql() + assert "LEFT JOIN [sys].[columns] AS [sc]" in sql + assert "[sc].[object_id] = OBJECT_ID(" in sql + assert "[sc].[name] = [c].[COLUMN_NAME]" in sql + assert "LEFT JOIN [sys].[extended_properties] AS [epc]" in sql + assert "[epc].[minor_id] = [sc].[column_id]" in sql + assert "CAST([epc].[value] AS NVARCHAR(4000)) AS [column_comment]" in sql + assert params == ("dbo", "users"), "the comment adds no parameter" class TestBuildPrimaryKeySql: def test_parameterized(self): introspector = make_introspector() - sql, params = introspector._build_primary_key_sql("users", "dbo") - assert "tc.TABLE_SCHEMA = ?" in sql + sql, params = introspector.primary_key_statement(table_name="users", schema="dbo").to_sql() + assert "[TABLE_SCHEMA] = ?" in sql + assert "[TABLE_NAME] = ?" in sql assert params == ("dbo", "users") + assert "'PRIMARY KEY'" in sql, "the constraint kind is part of the question" class TestBuildIndexInfoSql: def test_parameterized(self): - introspector = make_introspector() - sql, params = introspector._build_index_info_sql("users", "dbo") - assert "s.name = ?" in sql + sql, params = IndexInfoQueryStatement( + make_dialect(), "users", schema="dbo" + ).to_sql() + assert "[s].[name] = ?" in sql + assert "[t].[name] = ?" in sql assert params == ("dbo", "users") class TestBuildForeignKeySql: def test_parameterized(self): - introspector = make_introspector() - sql, params = introspector._build_foreign_key_sql("users", "dbo") - assert "s.name = ?" in sql + sql, params = ForeignKeyQueryStatement( + make_dialect(), "users", schema="dbo" + ).to_sql() + assert "[s].[name] = ?" in sql + assert "[t].[name] = ?" in sql assert params == ("dbo", "users") class TestBuildViewListSql: def test_parameterized(self): - introspector = make_introspector() - sql, params = introspector._build_view_list_sql("dbo", False) - assert "TABLE_SCHEMA = ?" in sql + sql, params = ViewListQueryStatement(make_dialect(), schema="dbo").to_sql() + assert "[TABLE_SCHEMA] = ?" in sql assert params == ("dbo",) + def test_list_projects_the_definition_it_used_to_drop(self): + """The list answers the same two facts as the single-view question. + + It used to project ``TABLE_NAME`` alone, so every listed view carried + ``definition = null`` while the single-view statement had always + selected it. + """ + sql, params = ViewListQueryStatement(make_dialect(), schema="dbo").to_sql() + assert "[VIEW_DEFINITION] AS [definition]" in sql + assert "[IS_UPDATABLE]" in sql + assert params == ("dbo",), "the definition adds no parameter" + + +class TestParseViews: + """The list parser reads what the list statement projects.""" + + def test_definition_and_updatability_are_read(self): + views = make_introspector().parse_views( + [ + { + "view_name": "v1", + "definition": "CREATE VIEW v1 AS SELECT 1", + "IS_UPDATABLE": "YES", + }, + { + "view_name": "v2", + "definition": "CREATE VIEW v2 AS SELECT 2", + "IS_UPDATABLE": "NO", + }, + ], + "dbo", + ) + assert views[0].definition == "CREATE VIEW v1 AS SELECT 1" + assert views[0].is_updatable is True + assert views[1].is_updatable is False + + def test_a_withheld_definition_stays_none(self): + """``VIEW_DEFINITION`` is NULL when the caller may not read the text.""" + views = make_introspector().parse_views( + [{"view_name": "v1", "definition": None, "IS_UPDATABLE": "NO"}], "dbo" + ) + assert views[0].definition is None + assert views[0].schema == "dbo" + class TestBuildViewInfoSql: def test_parameterized(self): - introspector = make_introspector() - sql, params = introspector._build_view_info_sql("active_users", "dbo") - assert "TABLE_SCHEMA = ?" in sql + sql, params = ViewInfoQueryStatement( + make_dialect(), "active_users", schema="dbo" + ).to_sql() + assert "[TABLE_SCHEMA] = ?" in sql + assert "[TABLE_NAME] = ?" in sql assert params == ("dbo", "active_users") class TestBuildTriggerListSql: def test_with_table_name_parameterized(self): - introspector = make_introspector() - sql, params = introspector._build_trigger_list_sql("orders", "dbo") - assert "AND t.name = ?" in sql + sql, params = TriggerListQueryStatement( + make_dialect(), schema="dbo", table_name="orders" + ).to_sql() + assert "[s].[name] = ? AND [t].[name] = ?" in sql assert params == ("dbo", "orders") def test_without_table_name(self): - introspector = make_introspector() - sql, params = introspector._build_trigger_list_sql(None, "dbo") - assert "AND t.name = ?" not in sql + sql, params = TriggerListQueryStatement(make_dialect(), schema="dbo").to_sql() + assert "[t].[name] = ?" not in sql assert params == ("dbo",) + def test_events_are_joined_from_the_trigger_events_view(self): + """The events are not a column of ``sys.triggers``; they need a join.""" + sql, params = TriggerListQueryStatement(make_dialect(), schema="dbo").to_sql() + assert "[sys].[trigger_events] AS [te]" in sql + assert "LEFT JOIN" in sql + assert "[te].[type_desc] AS [event]" in sql + # Ordered by trigger, then event, so a trigger's rows are contiguous. + assert sql.endswith("ORDER BY [tr].[name], [te].[type_desc]") + assert params == ("dbo",), "the join adds no parameter" + + +class TestParseTriggers: + """The one-row-per-event answer is folded back into one row per trigger.""" + + @staticmethod + def _row(**overrides): + row = { + "trigger_name": "trg", + "table_name": "t", + "is_disabled": 0, + "is_instead_of_trigger": 0, + "event": "INSERT", + "definition": "CREATE TRIGGER trg ON t AFTER INSERT AS SELECT 1", + } + row.update(overrides) + return row + + def test_events_are_collected_per_trigger_in_order(self): + triggers = make_introspector().parse_triggers( + [ + self._row(event="DELETE"), + self._row(event="INSERT"), + self._row(event="UPDATE"), + self._row(trigger_name="other", event="INSERT"), + ], + "dbo", + ) + assert [t.name for t in triggers] == ["trg", "other"] + assert triggers[0].events == ["DELETE", "INSERT", "UPDATE"] + assert triggers[1].events == ["INSERT"] + + def test_status_follows_is_disabled(self): + trigger = make_introspector().parse_triggers([self._row()], "dbo")[0] + assert trigger.status == "ENABLED" + disabled = make_introspector().parse_triggers( + [self._row(is_disabled=1)], "dbo" + )[0] + assert disabled.status == "DISABLED" + + def test_a_trigger_with_no_event_row_keeps_an_empty_list(self): + """The LEFT JOIN answers NULL, which is not an event.""" + trigger = make_introspector().parse_triggers([self._row(event=None)], "dbo")[0] + assert trigger.events == [] + + def test_timing_and_definition_still_read(self): + trigger = make_introspector().parse_triggers( + [self._row(is_instead_of_trigger=1, event="DELETE")], "dbo" + )[0] + assert trigger.timing == "INSTEAD OF" + assert trigger.table_name == "t" + assert trigger.definition.startswith("CREATE TRIGGER") + class TestBuildDatabaseInfoSql: def test_returns_sql(self): - introspector = make_introspector() - sql, params = introspector._build_database_info_sql() + sql, params = DatabaseInfoQueryStatement(make_dialect()).to_sql() assert "DB_NAME()" in sql + assert "SERVERPROPERTY('ProductVersion')" in sql + assert params == () + + +class TestBuildSchemaListSql: + def test_owner_resolved_by_principal_id_not_name(self): + introspector = make_introspector() + sql, params = introspector.schema_list_statement().to_sql() + assert sql == ( + "SELECT [s].[schema_id], [s].[name] AS [schema_name], " + "[p].[name] AS [owner] FROM [sys].[schemas] AS [s] " + "LEFT JOIN [sys].[database_principals] AS [p] " + "ON [s].[principal_id] = [p].[principal_id] ORDER BY [s].[name]" + ) + # The keep of a schema whose principal was dropped is why the join to + # the principal is a LEFT JOIN rather than an inner one. + assert "LEFT JOIN" in sql assert params == () + def test_dangling_principal_row_still_a_schema(self): + """A NULL owner parses to None, the row survives (mirrors the join).""" + introspector = make_introspector() + schemas = introspector.parse_schemas( + [{"schema_id": 5, "schema_name": "orphan", "owner": None}] + ) + assert schemas[0].owner is None + assert schemas[0].schema_id == 5 + + +class TestBuildExtendedPropertyListSql: + def test_every_filter_is_a_bind_parameter(self): + introspector = make_introspector() + sql, params = introspector.extended_property_list_statement( + major_id=123, minor_id=1, class_name="OBJECT_OR_COLUMN", schema="dbo" + ).to_sql() + assert params == (123, "OBJECT_OR_COLUMN", 1, "dbo") + assert "'dbo'" not in sql and "'OBJECT_OR_COLUMN'" not in sql + assert "[ep].[major_id] = ?" in sql + assert "[ep].[class_desc] = ?" in sql + assert "[ep].[minor_id] = ?" in sql + assert "[s].[name] = ?" in sql + + def test_value_cast_off_the_sql_variant(self): + """``value`` is sql_variant, which the driver cannot fetch (HY106).""" + introspector = make_introspector() + sql, params = introspector.extended_property_list_statement( + major_id=123, minor_id=None, class_name="OBJECT_OR_COLUMN", schema=None + ).to_sql() + assert "CAST([ep].[value] AS NVARCHAR(4000)) AS [property_value]" in sql + # No minor_id, no schema: two predicates only. + assert params == (123, "OBJECT_OR_COLUMN") + + def test_unknown_class_refused_before_the_query(self): + introspector = make_introspector() + for bad in ("", "MS_Description", "object_or_column"): + # The refusal is the formatter's -- it happens when the statement + # is rendered, before anything reaches the server. + with pytest.raises(ValueError): + introspector.extended_property_list_statement( + major_id=123, minor_id=None, class_name=bad, schema=None + ).to_sql() + + def test_schema_only_narrows_object_scoped_classes(self): + introspector = make_introspector() + # INDEX is object-scoped (a major_id in sys.objects space)... + introspector.extended_property_list_statement( + major_id=1, minor_id=None, class_name="INDEX", schema="dbo" + ).to_sql() + # ...but DATABASE/SCHEMA properties have no object to narrow by. + with pytest.raises(ValueError): + introspector.extended_property_list_statement( + major_id=1, minor_id=None, class_name="DATABASE", schema="dbo" + ).to_sql() + with pytest.raises(ValueError): + introspector.extended_property_list_statement( + major_id=1, minor_id=None, class_name="SCHEMA", schema="dbo" + ).to_sql() + + def test_public_manager_refuses_at_the_same_point(self): + """``list_extended_properties`` is where the refusal is observed.""" + introspector = make_introspector() + with pytest.raises(ValueError, match="unknown extended-property class"): + introspector.list_extended_properties(123, class_name="MS_Description") + with pytest.raises(ValueError, match="object-scoped extended properties"): + introspector.list_extended_properties(1, class_name="DATABASE", schema="dbo") + + def test_a_table_name_resolves_the_id_in_the_same_query(self): + """The named selection joins ``sys.tables``/``sys.schemas`` itself. + + That is what makes the CLI's ``extended-properties users`` one query + rather than "find the object id, then read the properties". + """ + introspector = make_introspector() + sql, params = introspector.extended_property_list_statement( + table_name="users", schema="dbo" + ).to_sql() + assert "[sys].[extended_properties] AS [ep]" in sql + assert "[sys].[tables] AS [t]" in sql + assert "[sys].[schemas] AS [s]" in sql + assert "[t].[object_id]" in sql and "[ep].[major_id]" in sql + assert "CAST([ep].[value] AS NVARCHAR(4000)) AS [property_value]" in sql + # The name and schema are the caller's, so they stay bound; the + # class is part of the question, so it stays an inline literal. + assert params == ("OBJECT_OR_COLUMN", "users", "dbo") + assert "'users'" not in sql and "'dbo'" not in sql + + def test_a_table_name_defaults_the_schema(self): + sql, params = make_introspector().extended_property_list_statement( + table_name="users" + ).to_sql() + assert params == ("OBJECT_OR_COLUMN", "users", "dbo") + + def test_a_table_name_only_names_object_or_column_properties(self): + introspector = make_introspector() + with pytest.raises(ValueError, match="a table name only names OBJECT_OR_COLUMN"): + introspector.extended_property_list_statement( + table_name="users", class_name="INDEX" + ).to_sql() + + def test_exactly_one_of_id_and_table_name(self): + introspector = make_introspector() + with pytest.raises(ValueError, match="exactly one of"): + introspector.extended_property_list_statement() + with pytest.raises(ValueError, match="exactly one of"): + introspector.extended_property_list_statement( + major_id=1, table_name="users" + ) + + +class TestBuildRoutineListSql: + def test_procedures_ask_for_p_and_pc(self): + introspector = make_introspector() + sql, params = introspector.procedure_list_statement(schema=None).to_sql() + assert "[o].[type] IN ('P', 'PC')" in sql + assert params == ("dbo",) + + def test_functions_ask_for_the_five_codes(self): + introspector = make_introspector() + sql, params = introspector.function_list_statement(schema=None).to_sql() + assert "[o].[type] IN ('FN', 'IF', 'TF', 'FS', 'FT')" in sql + assert params == ("dbo",) + + def test_explicit_schema_is_a_parameter(self): + introspector = make_introspector() + sql, params = introspector.procedure_list_statement(schema="sales").to_sql() + assert "[s].[name] = ?" in sql + assert "'sales'" not in sql + assert params == ("sales",) + + def test_procedures_and_functions_differ_only_in_codes(self): + introspector = make_introspector() + procs, _ = introspector.procedure_list_statement(schema="dbo").to_sql() + fns, _ = introspector.function_list_statement(schema="dbo").to_sql() + assert procs.replace("IN ('P', 'PC')", "") == fns.replace( + "IN ('FN', 'IF', 'TF', 'FS', 'FT')", "" + ) + + +class TestBuildSequenceListSql: + def test_sql_variant_columns_are_cast_to_text(self): + introspector = make_introspector() + sql, params = introspector.sequence_list_statement(schema=None).to_sql() + for column in ( + "start_value", + "increment", + "minimum_value", + "maximum_value", + "current_value", + ): + assert f"CAST([q].[{column}] AS NVARCHAR(64)) AS [{column}]" in sql + assert "CAST([q].[cache_size] AS BIGINT) AS [cache_size]" in sql + assert "TYPE_NAME([q].[user_type_id]) AS [data_type]" in sql + assert params == ("dbo",) + + def test_explicit_schema_is_a_parameter(self): + introspector = make_introspector() + sql, params = introspector.sequence_list_statement(schema="sales").to_sql() + assert "[s].[name] = ?" in sql + assert "'sales'" not in sql + assert params == ("sales",) + + +class TestParseBackendSpecificTypes: + """The parsers read the aliases the builders above project.""" + + @staticmethod + def _routine(**overrides): + row = { + "routine_name": "usp_x", + "schema_name": "dbo", + "type_code": " P ", + "type_desc": "SQL_STORED_PROCEDURE", + "created_at": None, + "modified_at": None, + "is_ms_shipped": 0, + **overrides, + } + return make_introspector().parse_routines([row])[0] + + def test_routine_type_code_stripped_and_flagged(self): + routine = self._routine() + assert routine.type_code == "P" + assert routine.name == "usp_x" + assert routine.schema == "dbo" + # ``is_ms_shipped`` arrives as the driver's int. + assert routine.is_ms_shipped is False + assert self._routine(is_ms_shipped=1).is_ms_shipped is True + + def test_sequence_values_read_back_as_exact_numbers(self): + introspector = make_introspector() + row = { + "sequence_name": "seq", + "schema_name": "dbo", + "data_type": "bigint", + "start_value": "5", + "increment": "2", + "minimum_value": "1", + "maximum_value": "9223372036854775807", + "current_value": None, + "is_cycling": 0, + "is_cached": 1, + "cache_size": 50, + } + seq = introspector.parse_sequences([row])[0] + assert seq.start_value == Decimal("5") + assert seq.increment == Decimal("2") + assert seq.maximum_value == Decimal("9223372036854775807") + # An unstarted sequence has no current_value, and it stays None. + assert seq.current_value is None + assert seq.is_cached is True and seq.is_cycling is False + assert seq.cache_size == 50 + + def test_unparseable_sequence_value_degrades_to_none(self): + introspector = make_introspector() + rows = [ + {"sequence_name": "seq", "start_value": "not-a-number"}, + {"sequence_name": "seq", "cache_size": None}, + ] + parsed = introspector.parse_sequences(rows) + assert parsed[0].start_value is None + assert parsed[0].cache_size is None + + def test_null_extended_property_value_stays_none(self): + introspector = make_introspector() + row = { + "class_id": 1, + "class_name": "OBJECT_OR_COLUMN", + "major_id": 77, + "minor_id": 0, + "property_name": "MS_Description", + "property_value": None, + } + prop = introspector.parse_extended_properties([row])[0] + # A property set to NULL is a value, not a missing row. + assert prop.value is None + assert prop.name == "MS_Description" + assert (prop.class_id, prop.major_id, prop.minor_id) == (1, 77, 0) + + +def make_status_introspector(version=(16, 0, 0)) -> SyncSQLServerStatusIntrospector: + config = SQLServerConnectionConfig( + host="localhost", + port=1433, + database="master", + username="sa", + password="", + ) + backend = SQLServerBackend(connection_config=config, version=version) + return SyncSQLServerStatusIntrospector(backend) + + +class TestStatusQueries: + """The status queries are nodes too, and the caps they used are version-gated. + + Each row shape here is the contract the parsers above read: the aliases in + the projection are what ``row["counter_name"]`` and friends look up, so a + rename here would silently empty a status report rather than fail. + """ + + def test_version_query_asks_the_engine(self): + sql, params = make_status_introspector()._version_sql() + assert sql == "SELECT CAST(SERVERPROPERTY('ProductVersion') AS NVARCHAR(128))" + assert params == () + + def test_configuration_projection_and_aliases(self): + sql, params = make_status_introspector()._configuration_sql() + assert "[value_in_use]" in sql and "AS [value_in_use]" in sql + assert "FROM [sys].[configurations]" in sql + assert "ORDER BY [name]" in sql + assert params == () + + def test_performance_cap_is_pagination_not_top(self): + sql, _ = make_status_introspector()._performance_metrics_sql(50) + assert "OFFSET 0 ROWS FETCH NEXT 50 ROWS ONLY" in sql + assert "TOP 50" not in sql + assert "ORDER BY [cntr_value] DESC" in sql + # The cap precedes the ORDER BY it depends on, so the ordering the + # old TOP 50 relied on for "busiest first" is still what is asked for. + + def test_performance_cap_dropped_before_2012(self): + """``OFFSET/FETCH`` arrived in 2012; older servers read every counter.""" + introspector = make_status_introspector(version=(10, 0, 0)) + assert introspector._performance_metrics_limit() is None + sql, _ = introspector._performance_metrics_sql(None) + assert "OFFSET" not in sql + assert "ORDER BY [cntr_value] DESC" in sql + + def test_performance_cap_kept_from_2012(self): + assert make_status_introspector(version=(11, 0, 0))._performance_metrics_limit() == 50 + + def test_connection_info_halves_are_subqueries(self): + sql, params = make_status_introspector()._connection_info_sql() + assert "(SELECT COUNT(*) FROM [sys].[dm_exec_connections]) AS [active]" in sql + assert "AS [max_conn]" in sql + assert params == (), "the setting name is part of the question" + + def test_storage_sums_the_page_units(self): + sql, params = make_status_introspector()._storage_info_sql() + assert sql == ( + "SELECT SUM([size] * 8 * 1024) AS [total_bytes] " + "FROM [sys].[master_files] WHERE [database_id] > 0" + ) + assert params == () + + def test_database_list_groups_and_orders_by_name(self): + sql, params = make_status_introspector()._database_list_sql() + assert "LEFT JOIN [sys].[master_files] AS [mf]" in sql + assert "GROUP BY [d].[name]" in sql + assert "ORDER BY [d].[name]" in sql + assert params == () + + def test_user_list_filters_login_types(self): + sql, params = make_status_introspector()._user_list_sql() + assert "[type] IN ('S', 'U', 'G')" in sql + assert params == () + + def test_session_info_keeps_the_niladic_login_name_unquoted(self): + """``SYSTEM_USER`` is the value function, not a column to look up.""" + sql, params = make_status_introspector()._session_info_sql() + assert sql.startswith("SELECT SYSTEM_USER AS [session_user]") + assert "CONNECTIONPROPERTY('net_transport')" in sql + assert params == () + + +class TestParseColumnComment: + """The ``column_comment`` the joined property adds reaches ``ColumnInfo``.""" + + @staticmethod + def _parse_one(**catalog): + row = { + "COLUMN_NAME": "col", + "DATA_TYPE": "int", + "ORDINAL_POSITION": 1, + "IS_NULLABLE": "YES", + **catalog, + } + return make_introspector().parse_columns([row], "t", "dbo")[0] + + def test_a_column_comment_is_read(self): + assert self._parse_one(column_comment="what it means").comment == "what it means" + + def test_a_column_without_a_comment_keeps_none(self): + assert self._parse_one(column_comment=None).comment is None + assert self._parse_one().comment is None + class TestParseColumnsFullTypeRebuild: - """Offline tests for the ``full_type`` rebuild in ``_parse_columns``. + """Offline tests for the ``full_type`` rebuild in ``parse_columns``. ``INFORMATION_SCHEMA.COLUMNS.CHARACTER_MAXIMUM_LENGTH`` reports ``-1`` for the MAX-capable declarations. The rebuild used to skip any @@ -149,7 +801,7 @@ def _parse_one(data_type: str, **catalog): **catalog, } introspector = make_introspector() - return introspector._parse_columns([row], "t", "dbo")[0] + return introspector.parse_columns([row], "t", "dbo")[0] def test_nvarchar_max_marker_rebuilds_to_max(self): dialect = make_introspector()._backend.dialect @@ -208,7 +860,7 @@ def test_legacy_lob_and_xml_marker_handling_unchanged(self): class TestParseColumnsDatetimePrecision: - """Offline tests for the ``DATETIME_PRECISION`` rebuild in ``_parse_columns``. + """Offline tests for the ``DATETIME_PRECISION`` rebuild in ``parse_columns``. Appendix F.4-1..3: the three temporal types that carry a fractional-seconds precision report it only in ``DATETIME_PRECISION`` @@ -241,31 +893,28 @@ def _parse_one(data_type: str, dt_precision): "DATETIME_PRECISION": dt_precision, } introspector = make_introspector() - return introspector._parse_columns([row], "t", "dbo")[0] + return introspector.parse_columns([row], "t", "dbo")[0] def test_explicit_precision_rebuilds_and_matches_declared(self): dialect = make_introspector()._backend.dialect for word in self.PRECISIONED: col = self._parse_one(word, 3) assert col.data_type_full == f"{word}(3)" - assert col.parsed_data_type == DataType.parse_data_type_str( - dialect, f"{word}(3)") + assert col.parsed_data_type == DataType.parse_data_type_str(dialect, f"{word}(3)") def test_zero_precision_is_rebuilt(self): dialect = make_introspector()._backend.dialect for word in self.PRECISIONED: col = self._parse_one(word, 0) assert col.data_type_full == f"{word}(0)" - assert col.parsed_data_type == DataType.parse_data_type_str( - dialect, f"{word}(0)") + assert col.parsed_data_type == DataType.parse_data_type_str(dialect, f"{word}(0)") def test_server_default_precision_stays_bare(self): dialect = make_introspector()._backend.dialect for word in self.PRECISIONED: col = self._parse_one(word, 7) assert col.data_type_full == word - assert col.parsed_data_type == DataType.parse_data_type_str( - dialect, word) + assert col.parsed_data_type == DataType.parse_data_type_str(dialect, word) def test_missing_catalog_precision_stays_bare(self): # Defensive: a row without the field rebuilds exactly as before. @@ -291,5 +940,4 @@ def test_legacy_datetime_aliases_keep_their_bare_path(self): assert dt.parsed_data_type == DataType.parse_data_type_str(dialect, "datetime") sdt = self._parse_one("smalldatetime", 0) assert sdt.data_type_full == "smalldatetime" - assert sdt.parsed_data_type == DataType.parse_data_type_str( - dialect, "smalldatetime") + assert sdt.parsed_data_type == DataType.parse_data_type_str(dialect, "smalldatetime") diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_json_functions_backend.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_json_functions_backend.py index 4d0603e..931a069 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_json_functions_backend.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_json_functions_backend.py @@ -124,7 +124,7 @@ def test_json_contains_function(self, sqlserver_backend): sqlserver_backend.execute("DROP TABLE IF EXISTS #test_json_contains") def test_format_json_extract_integration(self, sqlserver_backend): - """Test format_json_extract with database execution.""" + """Test format_json_extract_expression with database execution.""" if sqlserver_backend.dialect.version < (13, 0, 0): pytest.skip("JSON functions require SQL Server 2016+") @@ -269,7 +269,7 @@ async def test_async_json_contains_function(self, async_sqlserver_backend): @pytest.mark.asyncio async def test_async_format_json_extract_integration(self, async_sqlserver_backend): - """Test format_json_extract with database execution (async).""" + """Test format_json_extract_expression with database execution (async).""" if async_sqlserver_backend.dialect.version < (13, 0, 0): pytest.skip("JSON functions require SQL Server 2016+") diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_literal_default_execution.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_literal_default_execution.py new file mode 100644 index 0000000..adf54c7 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_literal_default_execution.py @@ -0,0 +1,75 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_literal_default_execution.py +"""Live confirmation that the dialect's DDL literals really execute. + +The expression area asserts *what text the dialect writes*; this file asserts +that a server accepts that text and hands the same value back. It lives beside +the scenario-driven integration tests for exactly that reason -- the org rules +keep live connections out of the expression tests, and a DEFAULT clause is the +one place a literal is observable at all. + +The sentinel comes first on purpose: a deliberately invalid statement must come +back REJECTED before any verdict from this file is trusted, then a CREATE TABLE +whose DEFAULT clauses embed the rendered literals is read back through +``INSERT ... DEFAULT VALUES`` and compared as Python values. + +Written against the documented T-SQL grammar -- the ``0x`` constant form +and the string-to-UNIQUEIDENTIFIER / BIT conversions -- and not yet +server-verified: the scenario ports were refused while this was written, so a +reachable server is what confirms it. +""" + +from rhosocial.activerecord.backend.expression.execution_testing import ( + ExecutionOutcome, + classify_execution, +) + + +class TestLiteralDefaultsExecute: + """The rendered literals as column DEFAULTs survive a read-back.""" + + BINARY = b"\x01\x02" + UUID_TEXT = "01234567-89ab-cdef-0123-456789abcdef" + + def test_sentinel_rejected_and_control_accepted(self, sqlserver_backend): + """The classifier must see both answers before any verdict is trusted.""" + assert ( + classify_execution(sqlserver_backend, "THIS IS NOT SQL") + is ExecutionOutcome.REJECTED + ) + assert ( + classify_execution(sqlserver_backend, "SELECT 1") + is ExecutionOutcome.ACCEPTED + ) + + def test_column_defaults_round_trip(self, sqlserver_backend): + """The binary, UUID and boolean literals as DEFAULTs survive a read-back.""" + import uuid + + dialect = sqlserver_backend.dialect + b_default = dialect.format_literal(self.BINARY) + u_default = dialect.format_literal(uuid.UUID(self.UUID_TEXT)) + f_default = dialect.format_literal(True) + + table = "test_literal_defaults" + sqlserver_backend.execute(f""" + CREATE TABLE {table} ( + b VARBINARY(8) DEFAULT {b_default}, + u UNIQUEIDENTIFIER DEFAULT {u_default}, + f BIT DEFAULT {f_default} + ) + """) + try: + sqlserver_backend.execute(f"INSERT INTO {table} DEFAULT VALUES") + row = sqlserver_backend.fetch_one( + f"SELECT b AS b, u AS u, f AS f FROM {table}" + ) + assert row is not None + assert bytes(row["b"]) == self.BINARY + # A UNIQUEIDENTIFIER comes back in the server's own canonical + # spelling, which is upper-case hex; the answer is parsed rather + # than compared as text, so the casing is not part of what is + # asserted and the value is what travels. + assert uuid.UUID(str(row["u"])) == uuid.UUID(self.UUID_TEXT) + assert bool(row["f"]) is True + finally: + sqlserver_backend.execute(f"DROP TABLE IF EXISTS {table}") diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_master_probes_guard_sqlserver.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_master_probes_guard_sqlserver.py index 641c9bc..fced30b 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_master_probes_guard_sqlserver.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_master_probes_guard_sqlserver.py @@ -1,8 +1,8 @@ # tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_master_probes_guard_sqlserver.py -"""Guard: the four master probes that gate clauses SQL Server cannot spell. +"""Guard: the master probes that gate clauses SQL Server cannot spell. The round gated three previously-decorative master probes behind real call -sites in core (``format_cte_expression`` consults ``supports_materialized_cte``, +sites in core (``format_cte_clause`` consults ``supports_materialized_cte``, ``format_truncate_statement`` consults ``supports_truncate``, and the new ``supports_with_data_clause`` decides ``WITH [NO] DATA`` on CTAS / materialized view create / materialized view refresh), and added ``supports_transaction_wait`` @@ -23,7 +23,13 @@ empties the table; ``TRUNCATE TABLE dbo.t CASCADE`` rejected. * ``supports_with_data_clause()`` -> **False**: ``SELECT ... INTO dbo.t`` accepted, but the same statement with ``WITH DATA`` or ``WITH NO DATA`` is a - syntax error near ``with``; ``CREATE TABLE ... AS`` is a syntax error too. + syntax error near ``with``. +* ``supports_create_table_as()`` -> **False**: SQL Server has no + ``CREATE TABLE ... AS`` statement at all -- the portable spelling is a + syntax error near the keyword ``SELECT``, so the statement is refused by + name. SQL Server's own population spelling is ``SELECT ... INTO
``, + which is a different statement shape, so the portable one is not rendered in + a form the server rejects. * ``supports_materialized_cte()`` -> **False**: ``WITH c AS (...)`` accepted; ``AS MATERIALIZED`` and ``AS NOT MATERIALIZED`` are syntax errors. * ``supports_transaction_wait()`` -> **False**: ``BEGIN TRANSACTION WAIT`` is @@ -34,17 +40,19 @@ Scope notes: -* ``supports_materialized_cte`` and ``supports_with_data_clause`` are consulted - by core formatters this dialect inherits (``format_cte_expression``, - ``format_create_table_as_statement``), so the load-bearing walk flips them - through a subclass and checks the core render/refuse paths. +* ``supports_materialized_cte`` is consulted by a core formatter this dialect + inherits (``format_cte_clause``), so the load-bearing walk flips it through a + subclass and checks the core render/refuse paths. * Materialized view create / refresh are refused *as statements* by this - dialect before any ``WITH [NO] DATA`` gate could run; the walk therefore - covers CTAS and the focused test below documents the subsumption instead of - pretending a clause-level refusal exists for a statement that does not exist. + dialect before any ``WITH [NO] DATA`` gate could run, and CTAS is refused + the same way -- SQL Server has no ``CREATE TABLE ... AS`` either. The walk + therefore covers the CTEs and the focused tests below document the + subsumption instead of pretending a clause-level refusal exists for a + statement that does not exist. ``supports_with_data_clause`` stays declared + so the measurement cannot drift, but it has no consumer here. * ``supports_truncate`` and ``supports_transaction_wait`` are consulted by this backend's own formatters (``format_truncate_statement``, - ``format_begin_transaction`` / ``format_set_transaction``), which is where the + ``format_begin_transaction_statement`` / ``format_set_transaction_statement``), which is where the pre-fix defects lived: the probes' answers could not change the behaviour. The guard is deliberately able to fail: ``TestTheGuardCanSeeADroppingFormatter`` @@ -60,21 +68,21 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.core import Column from rhosocial.activerecord.backend.expression.objects import MaterializedView, Table -from rhosocial.activerecord.backend.expression.query_sources import CTEExpression +from rhosocial.activerecord.backend.expression.query_sources import CTEClause from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - CreateTableAsExpression, + CreateTableAsStatement, ) from rhosocial.activerecord.backend.expression.statements.ddl_truncate import ( - TruncateExpression, + TruncateStatement, ) from rhosocial.activerecord.backend.expression.statements.ddl_view import ( - CreateMaterializedViewExpression, - RefreshMaterializedViewExpression, + CreateMaterializedViewStatement, + RefreshMaterializedViewStatement, ) -from rhosocial.activerecord.backend.expression.statements.dql import QueryExpression +from rhosocial.activerecord.backend.expression.statements.dql import QueryStatement from rhosocial.activerecord.backend.expression.transaction import ( - BeginTransactionExpression, - SetTransactionExpression, + BeginTransactionStatement, + SetTransactionStatement, ) from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect @@ -93,7 +101,7 @@ def _table(d, name="mp_guard_t"): def _query(d): - return QueryExpression(d, select=[Column(d, "a")], from_=_table(d, "s")) + return QueryStatement(d, select=[Column(d, "a")], from_=_table(d, "s")) def _declaring_class(probe_name): @@ -133,7 +141,7 @@ class MasterProbeCase(NamedTuple): True, "TRUNCATE", r"TRUNCATE TABLE", - lambda d: TruncateExpression(d, _table(d)), + lambda d: TruncateStatement(d, _table(d)), ), MasterProbeCase( "materialized-cte", @@ -141,7 +149,7 @@ class MasterProbeCase(NamedTuple): False, "MATERIALIZED CTE", r"AS MATERIALIZED", - lambda d: CTEExpression(d, "c", _query(d), materialized=True), + lambda d: CTEClause(d, "c", _query(d), materialized=True), ), MasterProbeCase( "not-materialized-cte", @@ -149,23 +157,7 @@ class MasterProbeCase(NamedTuple): False, "NOT MATERIALIZED CTE", r"AS NOT MATERIALIZED", - lambda d: CTEExpression(d, "c", _query(d), not_materialized=True), - ), - MasterProbeCase( - "ctas-with-data", - "supports_with_data_clause", - False, - "WITH DATA", - r"WITH DATA", - lambda d: CreateTableAsExpression(d, _table(d), _query(d), with_data=True), - ), - MasterProbeCase( - "ctas-no-data", - "supports_with_data_clause", - False, - "WITH NO DATA", - r"WITH NO DATA", - lambda d: CreateTableAsExpression(d, _table(d), _query(d), no_data=True), + lambda d: CTEClause(d, "c", _query(d), not_materialized=True), ), MasterProbeCase( "begin-wait", @@ -173,7 +165,7 @@ class MasterProbeCase(NamedTuple): False, "transaction WAIT", r"(? bool: return False with pytest.raises(UnsupportedFeatureError) as excinfo: - TruncateExpression(NoTruncateDialect(SQL_SERVER_2022), _table(_dialect())).to_sql() + TruncateStatement(NoTruncateDialect(SQL_SERVER_2022), _table(_dialect())).to_sql() assert "TRUNCATE" in str(excinfo.value), str(excinfo.value) @@ -413,14 +404,31 @@ class TestTheOtherThreeFormattersConsumeTheSpelling: """Focused checks that the request is never silently dropped.""" def test_plain_cte_is_not_gated_by_the_materialized_probe(self): - sql, _params = CTEExpression(_dialect(), "c", _query(_dialect())).to_sql() + sql, _params = CTEClause(_dialect(), "c", _query(_dialect())).to_sql() assert "MATERIALIZED" not in sql, sql - def test_ctas_without_the_clause_is_not_gated(self): - sql, _params = CreateTableAsExpression( - _dialect(), _table(_dialect()), _query(_dialect()) - ).to_sql() - assert "WITH DATA" not in sql, sql + def test_ctas_is_refused_by_the_statement_name(self): + """SQL Server has no ``CREATE TABLE ... AS``; the statement is refused. + + The portable spelling is a syntax error near the keyword ``SELECT`` + (measured on 2019 / 2022 / 2025), so the request is refused by the + statement name rather than rendered in a form the server rejects. + """ + with pytest.raises(UnsupportedFeatureError) as excinfo: + CreateTableAsStatement( + _dialect(), _table(_dialect()), _query(_dialect()) + ).to_sql() + assert "CREATE TABLE ... AS" in str(excinfo.value), str(excinfo.value) + + def test_the_with_data_measurement_is_still_pinned(self): + """The clause gate stays declared even though no consumer remains. + + CTAS and materialized view create / refresh are both refused as + statements, so ``WITH [NO] DATA`` has no consumer for this dialect. + The answer is still pinned here so a core default change cannot move + the measurement the next time a population spelling appears. + """ + assert _dialect().supports_with_data_clause() is False def test_materialized_view_statements_subsume_the_with_data_gate(self): """CREATE / REFRESH are refused as statements; the clause gate never runs. @@ -431,13 +439,13 @@ def test_materialized_view_statements_subsume_the_with_data_gate(self): """ for builder, feature in ( ( - lambda d: CreateMaterializedViewExpression( + lambda d: CreateMaterializedViewStatement( d, MaterializedView(d, "mv"), _query(d), with_data=True ), "CREATE MATERIALIZED VIEW", ), ( - lambda d: RefreshMaterializedViewExpression( + lambda d: RefreshMaterializedViewStatement( d, MaterializedView(d, "mv"), with_data=True ), "REFRESH MATERIALIZED VIEW", @@ -455,7 +463,6 @@ def test_case_list_covers_every_probe(self): assert set(PROBE_NAMES) == { "supports_truncate", "supports_materialized_cte", - "supports_with_data_clause", "supports_transaction_wait", } @@ -509,7 +516,7 @@ def supports_transaction_wait(self) -> bool: # The declaration says yes; the formatter below ignores it. return True - def format_begin_transaction(self, expr): + def format_begin_transaction_statement(self, expr): # The pre-fix shape: the wait pair was not read at all, so no # spelling followed the declaration. return "BEGIN TRANSACTION", () diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_niladic_functions.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_niladic_functions.py index d53e1fb..09788b5 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_niladic_functions.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_niladic_functions.py @@ -16,9 +16,9 @@ import pytest from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, - QueryExpression, + CreateTableStatement, + DropTableStatement, + QueryStatement, ) from rhosocial.activerecord.backend.expression.core import FunctionCall from rhosocial.activerecord.backend.expression.functions.datetime import ( @@ -30,7 +30,8 @@ from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import IntegerType, TimestampType from rhosocial.activerecord.backend.options import ExecutionOptions @@ -46,7 +47,7 @@ class TestSQLServerNiladicSelectContext: def test_select_current_timestamp_niladic(self, sqlserver_backend): """SELECT CURRENT_TIMESTAMP (niladic) works in SQL Server.""" - query = QueryExpression( + query = QueryStatement( dialect=sqlserver_backend.dialect, select=[current_timestamp(sqlserver_backend.dialect)], ) @@ -59,7 +60,7 @@ def test_select_current_timestamp_niladic(self, sqlserver_backend): def test_select_sysdatetime(self, sqlserver_backend): """SELECT SYSDATETIME() (the SQL Server equivalent of NOW()) works.""" - query = QueryExpression( + query = QueryStatement( dialect=sqlserver_backend.dialect, select=[FunctionCall(sqlserver_backend.dialect, 'SYSDATETIME')], ) @@ -71,7 +72,7 @@ def test_select_sysdatetime(self, sqlserver_backend): def test_select_current_date(self, sqlserver_backend): """SELECT CAST(GETDATE() AS DATE) (CURRENT_DATE equivalent) works.""" - query = QueryExpression( + query = QueryStatement( dialect=sqlserver_backend.dialect, select=[current_date(sqlserver_backend.dialect)], ) @@ -83,7 +84,7 @@ def test_select_current_date(self, sqlserver_backend): def test_select_current_time(self, sqlserver_backend): """SELECT CAST(GETDATE() AS TIME) (CURRENT_TIME equivalent) works.""" - query = QueryExpression( + query = QueryStatement( dialect=sqlserver_backend.dialect, select=[current_time(sqlserver_backend.dialect)], ) @@ -95,7 +96,7 @@ def test_select_current_time(self, sqlserver_backend): def test_select_now(self, sqlserver_backend): """SELECT SYSDATETIME() (regular function, always with parentheses) works.""" - query = QueryExpression( + query = QueryStatement( dialect=sqlserver_backend.dialect, select=[now(sqlserver_backend.dialect)], ) @@ -115,21 +116,22 @@ def test_ddl_default_current_timestamp_niladic(self, sqlserver_backend): table_name = 'test_niladic_ddl_1' # Clean up - sqlserver_backend.execute(*DropTableExpression( + sqlserver_backend.execute(*DropTableStatement( dialect=dialect, table=Table(dialect, table_name), if_exists=True ).to_sql()) - create = CreateTableExpression( + create = CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), columns=[ ColumnDefinition(dialect, 'id', IntegerType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ), ]), ColumnDefinition(dialect, 'ts', TimestampType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, - default_value=current_timestamp(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, current_timestamp(dialect))), ]), ], if_not_exists=True, @@ -145,7 +147,7 @@ def test_ddl_default_current_timestamp_niladic(self, sqlserver_backend): col_names = [c.name for c in cols] assert 'ts' in col_names finally: - sqlserver_backend.execute(*DropTableExpression( + sqlserver_backend.execute(*DropTableStatement( dialect=dialect, table=Table(dialect, table_name), if_exists=True ).to_sql()) @@ -155,21 +157,27 @@ def test_ddl_default_sysdatetime(self, sqlserver_backend): table_name = 'test_niladic_ddl_2' # Clean up - sqlserver_backend.execute(*DropTableExpression( + sqlserver_backend.execute(*DropTableStatement( dialect=dialect, table=Table(dialect, table_name), if_exists=True ).to_sql()) - create = CreateTableExpression( + create = CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), columns=[ ColumnDefinition(dialect, 'id', IntegerType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ), ]), ColumnDefinition(dialect, 'ts', TimestampType(dialect, precision=6), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, - default_value=FunctionCall(dialect, 'SYSDATETIME')), + ColumnConstraint( + dialect, + branch=ColumnDefaultConstraint( + dialect, FunctionCall(dialect, 'SYSDATETIME') + ), + ), ]), ], if_not_exists=True, @@ -184,7 +192,7 @@ def test_ddl_default_sysdatetime(self, sqlserver_backend): col_names = [c.name for c in cols] assert 'ts' in col_names finally: - sqlserver_backend.execute(*DropTableExpression( + sqlserver_backend.execute(*DropTableStatement( dialect=dialect, table=Table(dialect, table_name), if_exists=True ).to_sql()) @@ -195,7 +203,7 @@ class TestAsyncSQLServerNiladicSelectContext: @pytest.mark.asyncio async def test_async_select_current_timestamp_niladic(self, async_sqlserver_backend): """SELECT CURRENT_TIMESTAMP (niladic) works in SQL Server (async).""" - query = QueryExpression( + query = QueryStatement( dialect=async_sqlserver_backend.dialect, select=[current_timestamp(async_sqlserver_backend.dialect)], ) @@ -216,21 +224,22 @@ async def test_async_ddl_default_current_timestamp_niladic(self, async_sqlserver table_name = 'test_async_niladic_ddl_1' # Clean up - await async_sqlserver_backend.execute(*DropTableExpression( + await async_sqlserver_backend.execute(*DropTableStatement( dialect=dialect, table=Table(dialect, table_name), if_exists=True ).to_sql()) - create = CreateTableExpression( + create = CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), columns=[ ColumnDefinition(dialect, 'id', IntegerType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint( + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), + ), ]), ColumnDefinition(dialect, 'ts', TimestampType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, - default_value=current_timestamp(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, current_timestamp(dialect))), ]), ], if_not_exists=True, @@ -243,6 +252,6 @@ async def test_async_ddl_default_current_timestamp_niladic(self, async_sqlserver col_names = [c.name for c in cols] assert 'ts' in col_names finally: - await async_sqlserver_backend.execute(*DropTableExpression( + await async_sqlserver_backend.execute(*DropTableStatement( dialect=dialect, table=Table(dialect, table_name), if_exists=True ).to_sql()) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p1_coverage_features.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p1_coverage_features.py index 1c5b39c..b1206be 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 @@ -32,7 +32,7 @@ SQLServerUnpivotExpression, ) from rhosocial.activerecord.backend.impl.sqlserver.expression.columnstore import ( - SQLServerColumnstoreIndexExpression, + SQLServerColumnstoreIndexStatement, ) from rhosocial.activerecord.backend.impl.sqlserver.mixins import ( SQLServerPivotMixin, @@ -241,7 +241,7 @@ def test_select_into_requires_target_table(self, dialect): class TestColumnstoreIndex: - """Tests SQLServerColumnstoreIndexExpression and version gating.""" + """Tests SQLServerColumnstoreIndexStatement and version gating.""" @pytest.fixture def dialect(self): @@ -251,18 +251,18 @@ def test_mixin_registered(self, dialect): assert isinstance(dialect, SQLServerColumnstoreIndexMixin) def test_clustered_columnstore(self, dialect): - expr = SQLServerColumnstoreIndexExpression(dialect, "cci", "t", clustered=True) + expr = SQLServerColumnstoreIndexStatement(dialect, "cci", "t", clustered=True) sql, params = expr.to_sql() assert sql == "CREATE CLUSTERED COLUMNSTORE INDEX [cci] ON [t]" assert params == () def test_default_columnstore_omits_keyword(self, dialect): - expr = SQLServerColumnstoreIndexExpression(dialect, "cci", "t") + expr = SQLServerColumnstoreIndexStatement(dialect, "cci", "t") sql, _ = expr.to_sql() assert sql == "CREATE COLUMNSTORE INDEX [cci] ON [t]" def test_nonclustered_columnstore(self, dialect): - expr = SQLServerColumnstoreIndexExpression( + expr = SQLServerColumnstoreIndexStatement( dialect, "ncci", "t", columns=["c"], nonclustered=True ) sql, params = expr.to_sql() @@ -270,7 +270,7 @@ def test_nonclustered_columnstore(self, dialect): assert params == () def test_ordered_clustered_columnstore(self, dialect): - expr = SQLServerColumnstoreIndexExpression( + expr = SQLServerColumnstoreIndexStatement( dialect, "cci", "t", clustered=True, order_columns=["c1", "c2"] ) sql, _ = expr.to_sql() @@ -280,25 +280,25 @@ def test_ordered_clustered_columnstore(self, dialect): def test_ncci_validation(self, dialect): with pytest.raises(ValueError): - SQLServerColumnstoreIndexExpression( + SQLServerColumnstoreIndexStatement( dialect, "ncci", "t", nonclustered=True ).validate() def test_clustered_and_nonclustered_are_mutually_exclusive(self, dialect): with pytest.raises(ValueError, match="clustered and nonclustered"): - SQLServerColumnstoreIndexExpression( + SQLServerColumnstoreIndexStatement( dialect, "cci", "t", clustered=True, nonclustered=True ) def test_cci_forbids_columns(self, dialect): with pytest.raises(ValueError): - SQLServerColumnstoreIndexExpression( + SQLServerColumnstoreIndexStatement( dialect, "cci", "t", columns=["c"], clustered=True ).validate() def test_ncci_gate_below_2012(self): d = SQLServerDialect(SQL_SERVER_2008) - expr = SQLServerColumnstoreIndexExpression(d, "ncci", "t", columns=["c"], nonclustered=True) + expr = SQLServerColumnstoreIndexStatement(d, "ncci", "t", columns=["c"], nonclustered=True) with pytest.raises(UnsupportedFeatureError) as exc_info: expr.to_sql() assert exc_info.value.feature_name == "NONCLUSTERED COLUMNSTORE INDEX" @@ -306,14 +306,14 @@ def test_ncci_gate_below_2012(self): def test_ncci_supported_on_2012(self): d = SQLServerDialect(SQL_SERVER_2012) - expr = SQLServerColumnstoreIndexExpression(d, "ncci", "t", columns=["c"], nonclustered=True) + expr = SQLServerColumnstoreIndexStatement(d, "ncci", "t", columns=["c"], nonclustered=True) assert expr.to_sql()[0] == ( "CREATE NONCLUSTERED COLUMNSTORE INDEX [ncci] ON [t] ([c])" ) def test_cci_gate_below_2014(self): d = SQLServerDialect(SQL_SERVER_2012) - expr = SQLServerColumnstoreIndexExpression(d, "cci", "t", clustered=True) + expr = SQLServerColumnstoreIndexStatement(d, "cci", "t", clustered=True) with pytest.raises(UnsupportedFeatureError) as exc_info: expr.to_sql() assert exc_info.value.feature_name == "CLUSTERED COLUMNSTORE INDEX" @@ -321,12 +321,12 @@ def test_cci_gate_below_2014(self): def test_cci_supported_on_2014(self): d = SQLServerDialect(SQL_SERVER_2014) - expr = SQLServerColumnstoreIndexExpression(d, "cci", "t", clustered=True) + expr = SQLServerColumnstoreIndexStatement(d, "cci", "t", clustered=True) assert expr.to_sql()[0] == "CREATE CLUSTERED COLUMNSTORE INDEX [cci] ON [t]" def test_order_gate_below_2022(self): d = SQLServerDialect(SQL_SERVER_2014) - expr = SQLServerColumnstoreIndexExpression( + expr = SQLServerColumnstoreIndexStatement( d, "cci", "t", clustered=True, order_columns=["c1"] ) with pytest.raises(UnsupportedFeatureError) as exc_info: @@ -336,7 +336,7 @@ def test_order_gate_below_2022(self): def test_order_supported_on_2022(self): d = SQLServerDialect(SQL_SERVER_2022) - expr = SQLServerColumnstoreIndexExpression( + expr = SQLServerColumnstoreIndexStatement( d, "cci", "t", clustered=True, order_columns=["c1", "c2"] ) assert expr.to_sql()[0] == ( @@ -353,9 +353,9 @@ def dialect(self): def _alter(self, d, action): from rhosocial.activerecord.backend.expression.objects import Table - from rhosocial.activerecord.backend.expression.statements import AlterTableExpression + from rhosocial.activerecord.backend.expression.statements import AlterTableStatement - return AlterTableExpression(d, Table(d, "t"), actions=[action]).to_sql()[0] + return AlterTableStatement(d, Table(d, "t"), actions=[action]).to_sql()[0] def _alter_column(self, d, column_name="c", operation="SET DATA TYPE", new_value=None, **options): from rhosocial.activerecord.backend.impl.sqlserver.expression import ( diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p2_coverage_features.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_p2_coverage_features.py index ad9c925..eb03240 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 @@ -18,19 +18,19 @@ from rhosocial.activerecord.backend.expression.objects import Table, View from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect from rhosocial.activerecord.backend.impl.sqlserver.expression.fulltext import ( - SQLServerCreateFullTextCatalogExpression, - SQLServerDropFullTextCatalogExpression, - SQLServerCreateFullTextIndexExpression, - SQLServerDropFullTextIndexExpression, + SQLServerCreateFullTextCatalogStatement, + SQLServerDropFullTextCatalogStatement, + SQLServerCreateFullTextIndexStatement, + SQLServerDropFullTextIndexStatement, ) from rhosocial.activerecord.backend.impl.sqlserver.expression.ddl.routine import ( - SQLServerCreateProcedureExpression, - SQLServerCreateFunctionExpression, - SQLServerDropRoutineExpression, + SQLServerCreateProcedureStatement, + SQLServerCreateFunctionStatement, + SQLServerDropRoutineStatement, ) from rhosocial.activerecord.backend.impl.sqlserver.expression.ddl.trigger import ( - SQLServerCreateTriggerExpression, - SQLServerDropTriggerExpression, + SQLServerCreateTriggerStatement, + SQLServerDropTriggerStatement, ) from rhosocial.activerecord.backend.impl.sqlserver.mixins import ( SQLServerMemoryOptimizedMixin, @@ -51,8 +51,8 @@ def _make_query(d, columns=("id", "name"), from_="users", where=None): - from rhosocial.activerecord.backend.expression import Column, QueryExpression - return QueryExpression( + from rhosocial.activerecord.backend.expression import Column, QueryStatement + return QueryStatement( dialect=d, select=[Column(d, col) for col in columns], from_=Table(d, from_), @@ -69,11 +69,11 @@ def dialect(self): def _create_table(self, d, memory_optimized=True, durability="SCHEMA_ONLY", hash_indexes=None): - from rhosocial.activerecord.backend.expression import CreateTableExpression + from rhosocial.activerecord.backend.expression import CreateTableStatement from rhosocial.activerecord.backend.expression.statements import ( ColumnConstraint, - ColumnConstraintType, ColumnDefinition, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import IntegerType from rhosocial.activerecord.backend.impl.sqlserver.expression import ( @@ -82,7 +82,7 @@ def _create_table(self, d, memory_optimized=True, durability="SCHEMA_ONLY", ) col = ColumnDefinition(d, "id", IntegerType(d)) - col.constraints.append(ColumnConstraint(d, ColumnConstraintType.PRIMARY_KEY)) + col.constraints.append(ColumnConstraint(d, branch=ColumnPrimaryKeyConstraint(d))) indexes = [] if hash_indexes: for name, (columns, bucket_count) in hash_indexes.items(): @@ -100,7 +100,7 @@ def _create_table(self, d, memory_optimized=True, durability="SCHEMA_ONLY", table_options = SQLServerCreateTableOptions( d, memory_optimized=True, durability=durability ) - return CreateTableExpression( + return CreateTableStatement( d, Table(d, "t"), [col], indexes=indexes, table_options=table_options ) @@ -198,11 +198,11 @@ def test_create_table_with_hash_index(self, dialect): assert params == () def test_create_table_with_hash_index_missing_bucket_count(self, dialect): - from rhosocial.activerecord.backend.expression import CreateTableExpression + from rhosocial.activerecord.backend.expression import CreateTableStatement from rhosocial.activerecord.backend.expression.statements import ( ColumnConstraint, - ColumnConstraintType, ColumnDefinition, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import IntegerType from rhosocial.activerecord.backend.impl.sqlserver.expression import ( @@ -211,8 +211,8 @@ def test_create_table_with_hash_index_missing_bucket_count(self, dialect): ) col = ColumnDefinition(dialect, "id", IntegerType(dialect)) - col.constraints.append(ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)) - ct = CreateTableExpression( + col.constraints.append(ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))) + ct = CreateTableStatement( dialect, Table(dialect, "t"), [col], @@ -249,9 +249,9 @@ def dialect(self): return SQLServerDialect(SQL_SERVER_2022) def _view(self, d, view_name="v", columns=("id", "name")): - from rhosocial.activerecord.backend.expression import CreateViewExpression + from rhosocial.activerecord.backend.expression import CreateViewStatement - return CreateViewExpression(d, View(d, view_name), _make_query(d, columns)) + return CreateViewStatement(d, View(d, view_name), _make_query(d, columns)) def test_supports_indexed_view(self): assert SQLServerDialect(SQL_SERVER_2005).supports_indexed_view() is True @@ -276,11 +276,11 @@ def test_indexed_view_with_column_aliases(self, dialect): def test_create_view_with_schemabinding_option(self, dialect): from rhosocial.activerecord.backend.expression import ( - CreateViewExpression, + CreateViewStatement, ViewOptions, ) - view = CreateViewExpression( + view = CreateViewStatement( dialect, View(dialect, "v"), _make_query(dialect), @@ -308,22 +308,22 @@ def test_protocol_conformance(self, dialect): assert isinstance(dialect, SQLServerFullTextSearchSupport) def test_create_fulltext_catalog(self, dialect): - sql, params = SQLServerCreateFullTextCatalogExpression(dialect, "ftc").to_sql() + sql, params = SQLServerCreateFullTextCatalogStatement(dialect, "ftc").to_sql() assert sql == "CREATE FULLTEXT CATALOG [ftc]" assert params == () def test_create_fulltext_catalog_as_default(self, dialect): - expr = SQLServerCreateFullTextCatalogExpression(dialect, "ftc", is_default=True) + expr = SQLServerCreateFullTextCatalogStatement(dialect, "ftc", is_default=True) sql, _ = expr.to_sql() assert sql == "CREATE FULLTEXT CATALOG [ftc] AS DEFAULT" def test_drop_fulltext_catalog(self, dialect): - sql, params = SQLServerDropFullTextCatalogExpression(dialect, "ftc").to_sql() + sql, params = SQLServerDropFullTextCatalogStatement(dialect, "ftc").to_sql() assert sql == "DROP FULLTEXT CATALOG [ftc]" assert params == () def test_create_fulltext_index(self, dialect): - expr = SQLServerCreateFullTextIndexExpression( + expr = SQLServerCreateFullTextIndexStatement( dialect, "t", ["body"], key_index="pk", catalog_name="ftc" ) sql, params = expr.to_sql() @@ -333,7 +333,7 @@ def test_create_fulltext_index(self, dialect): assert params == () def test_create_fulltext_index_multiple_columns(self, dialect): - expr = SQLServerCreateFullTextIndexExpression( + expr = SQLServerCreateFullTextIndexStatement( dialect, "t", ["body", "title"], key_index="pk", catalog_name="ftc" ) sql, _ = expr.to_sql() @@ -342,19 +342,19 @@ def test_create_fulltext_index_multiple_columns(self, dialect): ) def test_drop_fulltext_index(self, dialect): - sql, params = SQLServerDropFullTextIndexExpression(dialect, "t").to_sql() + sql, params = SQLServerDropFullTextIndexStatement(dialect, "t").to_sql() assert sql == "DROP FULLTEXT INDEX ON [t]" assert params == () def test_fulltext_catalog_gate_below_2005(self): d = SQLServerDialect(SQL_SERVER_2005) assert d.supports_fulltext_catalog() is True - sql, params = SQLServerCreateFullTextCatalogExpression(d, "ftc").to_sql() + sql, params = SQLServerCreateFullTextCatalogStatement(d, "ftc").to_sql() assert sql == "CREATE FULLTEXT CATALOG [ftc]" assert params == () def test_direct_formatter_default_suffix(self, dialect): - sql, params = SQLServerCreateFullTextCatalogExpression( + sql, params = SQLServerCreateFullTextCatalogStatement( dialect, "ftc", is_default=True ).to_sql() assert sql == "CREATE FULLTEXT CATALOG [ftc] AS DEFAULT" @@ -362,15 +362,15 @@ def test_direct_formatter_default_suffix(self, dialect): def test_validation_requires_fields(self, dialect): with pytest.raises(ValueError): - SQLServerCreateFullTextIndexExpression( + SQLServerCreateFullTextIndexStatement( dialect, "", [], key_index="pk", catalog_name="ftc" ).validate() with pytest.raises(ValueError): - SQLServerCreateFullTextIndexExpression( + SQLServerCreateFullTextIndexStatement( dialect, "t", [], key_index="pk", catalog_name="ftc" ).validate() with pytest.raises(ValueError): - SQLServerDropFullTextIndexExpression(dialect, "").validate() + SQLServerDropFullTextIndexStatement(dialect, "").validate() class TestProcedureFunctionDdl: @@ -391,7 +391,7 @@ def test_capabilities(self): assert d.supports_drop_function() is True def test_create_procedure(self, dialect): - expr = SQLServerCreateProcedureExpression( + expr = SQLServerCreateProcedureStatement( dialect, "p", ["@x INT"], @@ -406,7 +406,7 @@ def test_create_procedure(self, dialect): assert params == () def test_create_procedure_multiple_params(self, dialect): - expr = SQLServerCreateProcedureExpression( + expr = SQLServerCreateProcedureStatement( dialect, "p", ["@a INT", "@b VARCHAR(50)"], body="SELECT @a;" ) sql, _ = expr.to_sql() @@ -415,19 +415,19 @@ def test_create_procedure_multiple_params(self, dialect): ) def test_create_procedure_or_alter(self, dialect): - expr = SQLServerCreateProcedureExpression( + expr = SQLServerCreateProcedureStatement( dialect, "p", ["@x INT"], body="SELECT @x;", or_alter=True ) sql, _ = expr.to_sql() assert sql == "CREATE OR ALTER PROCEDURE [p] @x INT AS BEGIN SELECT @x; END;" def test_create_procedure_no_params(self, dialect): - expr = SQLServerCreateProcedureExpression(dialect, "p", body="SELECT 1;") + expr = SQLServerCreateProcedureStatement(dialect, "p", body="SELECT 1;") sql, _ = expr.to_sql() assert sql == "CREATE PROCEDURE [p] AS BEGIN SELECT 1; END;" def test_create_function(self, dialect): - expr = SQLServerCreateFunctionExpression( + expr = SQLServerCreateFunctionStatement( dialect, "f", ["@x INT"], @@ -442,14 +442,14 @@ def test_create_function(self, dialect): assert params == () def test_create_function_no_params(self, dialect): - expr = SQLServerCreateFunctionExpression( + expr = SQLServerCreateFunctionStatement( dialect, "f", returns="INT", body="RETURN 42;" ) sql, _ = expr.to_sql() assert sql == "CREATE FUNCTION [f] () RETURNS INT AS BEGIN RETURN 42; END;" def test_create_function_dict_params(self, dialect): - expr = SQLServerCreateFunctionExpression( + expr = SQLServerCreateFunctionStatement( dialect, "f", [{"name": "@x", "type": "INT"}], @@ -460,45 +460,45 @@ def test_create_function_dict_params(self, dialect): assert sql == "CREATE FUNCTION [f] (@x INT) RETURNS INT AS BEGIN RETURN @x; END;" def test_drop_procedure(self, dialect): - sql, params = SQLServerDropRoutineExpression(dialect, "p", "PROCEDURE").to_sql() + sql, params = SQLServerDropRoutineStatement(dialect, "p", "PROCEDURE").to_sql() assert sql == "DROP PROCEDURE [p]" assert params == () def test_drop_function(self, dialect): - sql, _ = SQLServerDropRoutineExpression(dialect, "f", "FUNCTION").to_sql() + sql, _ = SQLServerDropRoutineStatement(dialect, "f", "FUNCTION").to_sql() assert sql == "DROP FUNCTION [f]" def test_drop_procedure_schema_qualified(self, dialect): - expr = SQLServerDropRoutineExpression(dialect, "p", "PROCEDURE", schema="dbo") + expr = SQLServerDropRoutineStatement(dialect, "p", "PROCEDURE", schema="dbo") sql, _ = expr.to_sql() assert sql == "DROP PROCEDURE [dbo].[p]" def test_drop_routine_if_exists_supported_on_2016(self): d = SQLServerDialect(SQL_SERVER_2016) - expr = SQLServerDropRoutineExpression(d, "p", "PROCEDURE", if_exists=True) + expr = SQLServerDropRoutineStatement(d, "p", "PROCEDURE", if_exists=True) sql, _ = expr.to_sql() assert sql == "DROP PROCEDURE IF EXISTS [p]" def test_drop_routine_if_exists_omitted_below_2016(self): d = SQLServerDialect(SQL_SERVER_2012) - expr = SQLServerDropRoutineExpression(d, "p", "PROCEDURE", if_exists=True) + expr = SQLServerDropRoutineStatement(d, "p", "PROCEDURE", if_exists=True) sql, _ = expr.to_sql() assert sql == "DROP PROCEDURE [p]" def test_validation(self, dialect): with pytest.raises(ValueError): - SQLServerCreateProcedureExpression(dialect, "p", body="").validate() + SQLServerCreateProcedureStatement(dialect, "p", body="").validate() with pytest.raises(ValueError): - SQLServerCreateFunctionExpression(dialect, "f", returns="INT", body="").validate() + SQLServerCreateFunctionStatement(dialect, "f", returns="INT", body="").validate() with pytest.raises(ValueError): - SQLServerCreateFunctionExpression(dialect, "f", body="RETURN 1;").validate() + SQLServerCreateFunctionStatement(dialect, "f", body="RETURN 1;").validate() with pytest.raises(ValueError): - SQLServerDropRoutineExpression(dialect, "p", "VIEW").validate() + SQLServerDropRoutineStatement(dialect, "p", "VIEW").validate() def test_version_boundary_all_supported_at_2005(self): d = SQLServerDialect(SQL_SERVER_2005) - proc = SQLServerCreateProcedureExpression(d, "p", ["@x INT"], body="SELECT @x;") - func = SQLServerCreateFunctionExpression( + proc = SQLServerCreateProcedureStatement(d, "p", ["@x INT"], body="SELECT @x;") + func = SQLServerCreateFunctionStatement( d, "f", ["@x INT"], returns="INT", body="RETURN @x;" ) assert proc.to_sql()[0] == "CREATE PROCEDURE [p] @x INT AS BEGIN SELECT @x; END;" @@ -522,7 +522,7 @@ def test_capabilities(self): assert d.supports_drop_trigger() is True def test_create_trigger_after_insert(self, dialect): - expr = SQLServerCreateTriggerExpression( + expr = SQLServerCreateTriggerStatement( dialect, "trg", "t", timing="AFTER", events=["INSERT"], body="SELECT 1;" ) sql, params = expr.to_sql() @@ -532,7 +532,7 @@ def test_create_trigger_after_insert(self, dialect): assert params == () def test_create_trigger_instead_of(self, dialect): - expr = SQLServerCreateTriggerExpression( + expr = SQLServerCreateTriggerStatement( dialect, "trg", "t", @@ -547,7 +547,7 @@ def test_create_trigger_instead_of(self, dialect): ) def test_create_trigger_multiple_events(self, dialect): - expr = SQLServerCreateTriggerExpression( + expr = SQLServerCreateTriggerStatement( dialect, "trg", "t", @@ -561,7 +561,7 @@ def test_create_trigger_multiple_events(self, dialect): ) def test_create_trigger_or_alter(self, dialect): - expr = SQLServerCreateTriggerExpression( + expr = SQLServerCreateTriggerStatement( dialect, "trg", "t", events=["INSERT"], body="SELECT 1;", or_alter=True ) sql, _ = expr.to_sql() @@ -570,37 +570,37 @@ def test_create_trigger_or_alter(self, dialect): ) def test_drop_trigger(self, dialect): - sql, params = SQLServerDropTriggerExpression(dialect, "trg").to_sql() + sql, params = SQLServerDropTriggerStatement(dialect, "trg").to_sql() assert sql == "DROP TRIGGER [trg]" assert params == () def test_drop_trigger_if_exists_supported_on_2016(self): d = SQLServerDialect(SQL_SERVER_2016) - expr = SQLServerDropTriggerExpression(d, "trg", if_exists=True) + expr = SQLServerDropTriggerStatement(d, "trg", if_exists=True) sql, _ = expr.to_sql() assert sql == "DROP TRIGGER IF EXISTS [trg]" def test_drop_trigger_if_exists_omitted_below_2016(self): d = SQLServerDialect(SQL_SERVER_2012) - expr = SQLServerDropTriggerExpression(d, "trg", if_exists=True) + expr = SQLServerDropTriggerStatement(d, "trg", if_exists=True) sql, _ = expr.to_sql() assert sql == "DROP TRIGGER [trg]" def test_validation(self, dialect): with pytest.raises(ValueError): - SQLServerCreateTriggerExpression(dialect, "trg", "t", events=[], body="SELECT 1;").validate() + SQLServerCreateTriggerStatement(dialect, "trg", "t", events=[], body="SELECT 1;").validate() with pytest.raises(ValueError): - SQLServerCreateTriggerExpression(dialect, "trg", "t", events=["INSERT"], body="").validate() + SQLServerCreateTriggerStatement(dialect, "trg", "t", events=["INSERT"], body="").validate() with pytest.raises(ValueError): - SQLServerCreateTriggerExpression( + SQLServerCreateTriggerStatement( dialect, "trg", "t", timing="BEFORE", events=["INSERT"], body="SELECT 1;" ).validate() with pytest.raises(ValueError): - SQLServerDropTriggerExpression(dialect, "").validate() + SQLServerDropTriggerStatement(dialect, "").validate() def test_version_boundary_supported_at_2005(self): d = SQLServerDialect(SQL_SERVER_2005) - expr = SQLServerCreateTriggerExpression( + expr = SQLServerCreateTriggerStatement( d, "trg", "t", events=["INSERT"], body="SELECT 1;" ) assert expr.to_sql()[0] == ( diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_protocol_support.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_protocol_support.py index 5a9cc1f..5676943 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 @@ -19,7 +19,7 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect from rhosocial.activerecord.backend.impl.sqlserver.expression.fulltext import ( - SQLServerCreateFullTextCatalogExpression, + SQLServerCreateFullTextCatalogStatement, ) from rhosocial.activerecord.backend.impl.sqlserver.expression.functions import ( SQLServerContainsPredicate, @@ -197,20 +197,20 @@ def test_next_value_for_gate_on_2012(self): def test_next_value_for_in_select(self): d = SQLServerDialect(SQL_SERVER_2022) expr = SQLServerNextValueForExpression(d, "my_seq") - from rhosocial.activerecord.backend.expression import QueryExpression + from rhosocial.activerecord.backend.expression import QueryStatement - query = QueryExpression(dialect=d, select=[expr]) + query = QueryStatement(dialect=d, select=[expr]) sql, params = query.to_sql() assert "SELECT NEXT VALUE FOR [my_seq]" in sql def test_next_value_for_in_insert_values(self): d = SQLServerDialect(SQL_SERVER_2022) - from rhosocial.activerecord.backend.expression import InsertExpression + from rhosocial.activerecord.backend.expression import InsertStatement from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ValuesSource seq = SQLServerNextValueForExpression(d, "my_seq") - insert = InsertExpression( + insert = InsertStatement( dialect=d, into=Table(d, "t"), columns=["id"], @@ -369,7 +369,7 @@ def test_supports_fulltext_catalog(self, dialect): assert dialect.supports_fulltext_catalog() is True def test_format_create_fulltext_catalog_statement(self, dialect): - sql, params = SQLServerCreateFullTextCatalogExpression(dialect, "ftc").to_sql() + sql, params = SQLServerCreateFullTextCatalogStatement(dialect, "ftc").to_sql() assert sql == "CREATE FULLTEXT CATALOG [ftc]" assert params == () @@ -513,16 +513,16 @@ def test_supports_indexed_view(self, dialect): def test_format_create_indexed_view_statement(self, dialect): from rhosocial.activerecord.backend.expression import ( Column, - CreateViewExpression, - QueryExpression, + CreateViewStatement, + QueryStatement, ) from rhosocial.activerecord.backend.expression.objects import Table, View - query = QueryExpression( + query = QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=Table(dialect, "users"), ) - view = CreateViewExpression(dialect, View(dialect, "user_ids"), query) + view = CreateViewStatement(dialect, View(dialect, "user_ids"), query) sql, params = dialect.format_create_indexed_view_statement(view) assert sql == ( "CREATE VIEW [user_ids] WITH SCHEMABINDING AS " @@ -531,8 +531,8 @@ def test_format_create_indexed_view_statement(self, dialect): assert params == () def test_format_create_temporal_table_not_implemented(self, dialect): - from rhosocial.activerecord.backend.expression import Column, QueryExpression + from rhosocial.activerecord.backend.expression import Column, QueryStatement - query = QueryExpression(dialect=dialect, select=[Column(dialect, "id")]) + query = QueryStatement(dialect=dialect, select=[Column(dialect, "id")]) with pytest.raises(UnsupportedFeatureError): dialect.format_create_temporal_table_statement(query) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_schema_diff.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_schema_diff.py index 72d5a9d..17fe9d1 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_schema_diff.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_schema_diff.py @@ -8,8 +8,8 @@ SchemaDiffer, ) from rhosocial.activerecord.backend.introspection.types import ( - ColumnInfo, ColumnNullable, TableInfo, TableType, - IndexInfo, ForeignKeyInfo, + IntrospectionColumnInfo, ColumnNullable, IntrospectionTableInfo, TableType, + IndexInfo, IntrospectionForeignKeyInfo, ) from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect @@ -18,10 +18,10 @@ class TestSQLServerSchemaDiffer: - def _make_snapshot(self, dialect_class: str, columns: Dict[str, List[ColumnInfo]]) -> SchemaSnapshot: + def _make_snapshot(self, dialect_class: str, columns: Dict[str, List[IntrospectionColumnInfo]]) -> SchemaSnapshot: tables = {} for name, cols in columns.items(): - tables[name] = TableInfo( + tables[name] = IntrospectionTableInfo( name=name, schema="dbo", table_type=TableType.BASE_TABLE, @@ -38,8 +38,8 @@ def _make_snapshot(self, dialect_class: str, columns: Dict[str, List[ColumnInfo] def _make_column(self, name: str, data_type: str, ordinal: int, nullable: bool = False, is_pk: bool = False, - parsed_dt=None) -> ColumnInfo: - return ColumnInfo( + parsed_dt=None) -> IntrospectionColumnInfo: + return IntrospectionColumnInfo( name=name, table_name="test", schema="dbo", diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_schema_support.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_schema_support.py index e03cfec..cab2cf0 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_schema_support.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_schema_support.py @@ -9,12 +9,12 @@ from rhosocial.activerecord.backend.dialect.protocols import NamespaceSupport from rhosocial.activerecord.backend.expression.objects import Schema, Table from rhosocial.activerecord.backend.expression.statements.ddl_schema import ( - CreateSchemaExpression, - DropSchemaExpression, + CreateSchemaStatement, + DropSchemaStatement, ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ( ColumnDefinition, - CreateTableExpression, + CreateTableStatement, ) from rhosocial.activerecord.backend.expression.types import IntegerType from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect @@ -92,7 +92,7 @@ def test_object_id_guard_in_create_table_is_unbracketed(self): d = self._dialect() table = Table(d, "users", catalog_name="app", schema_name="dbo") column = ColumnDefinition(d, "id", IntegerType(d)) - expr = CreateTableExpression(d, table, [column], if_not_exists=True) + expr = CreateTableStatement(d, table, [column], if_not_exists=True) sql, _ = expr.to_sql() guard = sql.splitlines()[0] @@ -146,18 +146,18 @@ def _dialect(self) -> SQLServerDialect: def test_create_schema(self): d = self._dialect() - sql, params = CreateSchemaExpression(d, Schema(d, "app")).to_sql() + sql, params = CreateSchemaStatement(d, Schema(d, "app")).to_sql() assert sql == "CREATE SCHEMA [app]" assert "IF NOT EXISTS" not in sql assert params == () def test_drop_schema(self): d = self._dialect() - sql, _ = DropSchemaExpression(d, Schema(d, "app")).to_sql() + sql, _ = DropSchemaStatement(d, Schema(d, "app")).to_sql() assert sql == "DROP SCHEMA [app]" def test_drop_schema_rejects_unsupported_if_exists(self): """The formatter must never emit IF EXISTS even if requested.""" d = self._dialect() - sql, _ = DropSchemaExpression(d, Schema(d, "app"), if_exists=True).to_sql() + sql, _ = DropSchemaStatement(d, Schema(d, "app"), if_exists=True).to_sql() assert "IF EXISTS" not in sql \ No newline at end of file diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sequence_probes_sqlserver.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sequence_probes_sqlserver.py index f0dffa6..fa8a899 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sequence_probes_sqlserver.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sequence_probes_sqlserver.py @@ -43,9 +43,9 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.objects import Sequence from rhosocial.activerecord.backend.expression.statements.ddl_sequence import ( - AlterSequenceExpression, - CreateSequenceExpression, - DropSequenceExpression, + AlterSequenceStatement, + CreateSequenceStatement, + DropSequenceStatement, ) from rhosocial.activerecord.backend.impl.sqlserver.dialect import ( SQL_SERVER_2022, @@ -85,97 +85,97 @@ class _ProbeCase(NamedTuple): #: dialect's; all three consult this dialect's probes. SEQUENCE_OPTION_PROBES = [ _ProbeCase( - "create-if-not-exists", "CREATE", CreateSequenceExpression, + "create-if-not-exists", "CREATE", CreateSequenceStatement, "if_not_exists", "CREATE SEQUENCE IF NOT EXISTS", "supports_sequence_if_not_exists", {"if_not_exists": True}, ), _ProbeCase( - "create-start", "CREATE", CreateSequenceExpression, + "create-start", "CREATE", CreateSequenceStatement, "start", "SEQUENCE START", "supports_sequence_start", {"start": 1}, ), _ProbeCase( - "create-increment", "CREATE", CreateSequenceExpression, + "create-increment", "CREATE", CreateSequenceStatement, "increment", "SEQUENCE INCREMENT", "supports_sequence_increment", {"increment": 1}, ), _ProbeCase( - "create-minvalue", "CREATE", CreateSequenceExpression, + "create-minvalue", "CREATE", CreateSequenceStatement, "minvalue", "SEQUENCE MINVALUE", "supports_sequence_minvalue", {"minvalue": 1}, ), _ProbeCase( - "create-maxvalue", "CREATE", CreateSequenceExpression, + "create-maxvalue", "CREATE", CreateSequenceStatement, "maxvalue", "SEQUENCE MAXVALUE", "supports_sequence_maxvalue", {"maxvalue": 1000}, ), _ProbeCase( - "create-cycle", "CREATE", CreateSequenceExpression, + "create-cycle", "CREATE", CreateSequenceStatement, "cycle", "SEQUENCE CYCLE", "supports_sequence_cycle", {"cycle": True}, ), _ProbeCase( - "create-cache", "CREATE", CreateSequenceExpression, + "create-cache", "CREATE", CreateSequenceStatement, "cache", "SEQUENCE CACHE", "supports_sequence_cache", {"cache": 10}, ), _ProbeCase( - "create-order", "CREATE", CreateSequenceExpression, + "create-order", "CREATE", CreateSequenceStatement, "order", "SEQUENCE ORDER", "supports_sequence_order", {"order": True}, ), _ProbeCase( - "create-owned-by", "CREATE", CreateSequenceExpression, + "create-owned-by", "CREATE", CreateSequenceStatement, "owned_by", "SEQUENCE OWNED BY", "supports_sequence_owned_by", {"owned_by": "dbo.orders.id"}, ), _ProbeCase( - "alter-start", "ALTER", AlterSequenceExpression, + "alter-start", "ALTER", AlterSequenceStatement, "start", "ALTER SEQUENCE START", "supports_alter_sequence_start", {"start": 1}, ), _ProbeCase( - "alter-increment", "ALTER", AlterSequenceExpression, + "alter-increment", "ALTER", AlterSequenceStatement, "increment", "ALTER SEQUENCE INCREMENT", "supports_sequence_increment", {"increment": 1}, ), _ProbeCase( - "alter-minvalue", "ALTER", AlterSequenceExpression, + "alter-minvalue", "ALTER", AlterSequenceStatement, "minvalue", "ALTER SEQUENCE MINVALUE", "supports_sequence_minvalue", {"minvalue": 1}, ), _ProbeCase( - "alter-maxvalue", "ALTER", AlterSequenceExpression, + "alter-maxvalue", "ALTER", AlterSequenceStatement, "maxvalue", "ALTER SEQUENCE MAXVALUE", "supports_sequence_maxvalue", {"maxvalue": 1000}, ), _ProbeCase( - "alter-cycle", "ALTER", AlterSequenceExpression, + "alter-cycle", "ALTER", AlterSequenceStatement, "cycle", "ALTER SEQUENCE CYCLE", "supports_sequence_cycle", {"cycle": True}, ), _ProbeCase( - "alter-cache", "ALTER", AlterSequenceExpression, + "alter-cache", "ALTER", AlterSequenceStatement, "cache", "ALTER SEQUENCE CACHE", "supports_sequence_cache", {"cache": 10}, ), _ProbeCase( - "alter-order", "ALTER", AlterSequenceExpression, + "alter-order", "ALTER", AlterSequenceStatement, "order", "ALTER SEQUENCE ORDER", "supports_sequence_order", {"order": True}, ), _ProbeCase( - "alter-no-order", "ALTER", AlterSequenceExpression, + "alter-no-order", "ALTER", AlterSequenceStatement, "NO ORDER", "ALTER SEQUENCE ORDER", "supports_sequence_order", {"no_order": True}, ), _ProbeCase( - "alter-owned-by", "ALTER", AlterSequenceExpression, + "alter-owned-by", "ALTER", AlterSequenceStatement, "owned_by", "ALTER SEQUENCE OWNED BY", "supports_sequence_owned_by", {"owned_by": "dbo.orders.id"}, ), _ProbeCase( - "drop-if-exists", "DROP", DropSequenceExpression, + "drop-if-exists", "DROP", DropSequenceStatement, "if_exists", "DROP SEQUENCE IF EXISTS", "supports_sequence_if_exists", {"if_exists": True}, ), @@ -183,9 +183,11 @@ class _ProbeCase(NamedTuple): #: Sequence probes that answer about the statement or the sequence object, not #: about one of its options. ``supports_sequence`` is the master switch every -#: sequence formatter consults; the other four are declaration-only answers -#: that no formatter in core or this backend reads, so no option behaviour can -#: follow them. They are named here rather than silently skipped. +#: sequence formatter consults; ``supports_sequence_introspection`` answers for +#: the ``introspect sequences`` family rather than for a statement. The other +#: four are declaration-only answers that no formatter in core or this backend +#: reads, so no option behaviour can follow them. They are named here rather +#: than silently skipped. NON_OPTION_PROBES = frozenset( { "supports_sequence", @@ -193,6 +195,7 @@ class _ProbeCase(NamedTuple): "supports_drop_sequence", "supports_alter_sequence", "supports_sequence_as_data_type", + "supports_sequence_introspection", } ) @@ -355,8 +358,8 @@ class TestTheDefaultPolarityAlsoFollowsTheProbe: @pytest.mark.parametrize( "statement,expression_cls", [ - ("CREATE", CreateSequenceExpression), - ("ALTER", AlterSequenceExpression), + ("CREATE", CreateSequenceStatement), + ("ALTER", AlterSequenceStatement), ], ids=["create", "alter"], ) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_set_type_backend.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_set_type_backend.py index a4d839e..247be7a 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_set_type_backend.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_set_type_backend.py @@ -108,7 +108,7 @@ def test_find_in_set_function(self, sqlserver_backend): sqlserver_backend.execute("DROP TABLE IF EXISTS #test_find_in_set") def test_format_set_literal_integration(self, sqlserver_backend): - """Test format_set_literal with database execution.""" + """Test format_set_literal_expression with database execution.""" sqlserver_backend.execute(""" CREATE TABLE #test_set_literal ( id INT IDENTITY(1,1) PRIMARY KEY, @@ -135,7 +135,7 @@ def test_format_set_literal_integration(self, sqlserver_backend): sqlserver_backend.execute("DROP TABLE IF EXISTS #test_set_literal") def test_format_find_in_set_integration(self, sqlserver_backend): - """Test format_find_in_set with database execution.""" + """Test format_find_in_set_expression with database execution.""" sqlserver_backend.execute(""" CREATE TABLE #test_find_format ( id INT IDENTITY(1,1) PRIMARY KEY, @@ -163,7 +163,7 @@ def test_format_find_in_set_integration(self, sqlserver_backend): sqlserver_backend.execute("DROP TABLE IF EXISTS #test_find_format") def test_format_set_contains_integration(self, sqlserver_backend): - """Test format_set_contains with database execution.""" + """Test format_set_contains_expression with database execution.""" sqlserver_backend.execute(""" CREATE TABLE #test_contains_format ( id INT IDENTITY(1,1) PRIMARY KEY, @@ -324,7 +324,7 @@ async def test_async_find_in_set_function(self, async_sqlserver_backend): @pytest.mark.asyncio async def test_async_format_set_literal_integration(self, async_sqlserver_backend): - """Test format_set_literal with database execution (async).""" + """Test format_set_literal_expression with database execution (async).""" await async_sqlserver_backend.execute(""" CREATE TABLE #test_async_set_literal ( id INT IDENTITY(1,1) PRIMARY KEY, @@ -352,7 +352,7 @@ async def test_async_format_set_literal_integration(self, async_sqlserver_backen @pytest.mark.asyncio async def test_async_format_find_in_set_integration(self, async_sqlserver_backend): - """Test format_find_in_set with database execution (async).""" + """Test format_find_in_set_expression with database execution (async).""" await async_sqlserver_backend.execute(""" CREATE TABLE #test_async_find_format ( id INT IDENTITY(1,1) PRIMARY KEY, @@ -378,7 +378,7 @@ async def test_async_format_find_in_set_integration(self, async_sqlserver_backen @pytest.mark.asyncio async def test_async_format_set_contains_integration(self, async_sqlserver_backend): - """Test format_set_contains with database execution (async).""" + """Test format_set_contains_expression with database execution (async).""" await async_sqlserver_backend.execute(""" CREATE TABLE #test_async_contains ( id INT IDENTITY(1,1) PRIMARY KEY, diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_spatial_types.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_spatial_types.py index ac8af92..eaa886b 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_spatial_types.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_spatial_types.py @@ -10,7 +10,7 @@ """ from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect from rhosocial.activerecord.backend.impl.sqlserver.expression.spatial import ( - SQLServerCreateSpatialIndexExpression, + SQLServerCreateSpatialIndexStatement, SQLServerSpatialLiteralExpression, SQLServerSTAsGeoJSONExpression, SQLServerSTAsTextExpression, @@ -158,7 +158,7 @@ class TestSpatialIndexFormatting: def test_format_create_spatial_index(self): """Test CREATE SPATIAL INDEX formatting.""" dialect = SQLServerDialect(version=(10, 3, 0)) - expr = SQLServerCreateSpatialIndexExpression( + expr = SQLServerCreateSpatialIndexStatement( dialect, "idx_spatial", "test_table", "geom_col" ) sql, params = expr.to_sql() @@ -171,7 +171,7 @@ def test_format_create_spatial_index_unsupported(self): """Test CREATE SPATIAL INDEX does not raise for supported versions.""" # SQL Server supports spatial indexes since 2008, so this should not raise dialect = SQLServerDialect(version=(10, 3, 0)) - expr = SQLServerCreateSpatialIndexExpression( + expr = SQLServerCreateSpatialIndexStatement( dialect, "idx_spatial", "test_table", "geom_col" ) sql, params = expr.to_sql() diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_spatial_types_backend.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_spatial_types_backend.py index 9b5fa18..cefe25e 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_spatial_types_backend.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_spatial_types_backend.py @@ -9,7 +9,7 @@ import pytest from rhosocial.activerecord.backend.impl.sqlserver.expression.spatial import ( - SQLServerCreateSpatialIndexExpression, + SQLServerCreateSpatialIndexStatement, SQLServerSpatialLiteralExpression, SQLServerSTContainsExpression, SQLServerSTDistanceExpression, @@ -48,7 +48,7 @@ def test_supports_geojson_detection(self, sqlserver_backend): assert not dialect.supports_geojson() def test_format_spatial_literal_without_srid(self, sqlserver_backend): - """Test format_spatial_literal generates correct SQL without SRID.""" + """Test format_spatial_literal_expression generates correct SQL without SRID.""" sqlserver_backend.execute(""" CREATE TABLE #test_spatial_literal ( id INT IDENTITY(1,1) PRIMARY KEY, @@ -74,7 +74,7 @@ def test_format_spatial_literal_without_srid(self, sqlserver_backend): sqlserver_backend.execute("DROP TABLE IF EXISTS #test_spatial_literal") def test_format_spatial_literal_with_srid(self, sqlserver_backend): - """Test format_spatial_literal generates correct SQL with SRID.""" + """Test format_spatial_literal_expression generates correct SQL with SRID.""" sqlserver_backend.execute(""" CREATE TABLE #test_spatial_srid ( id INT IDENTITY(1,1) PRIMARY KEY, @@ -103,7 +103,7 @@ def test_format_spatial_literal_with_srid(self, sqlserver_backend): sqlserver_backend.execute("DROP TABLE IF EXISTS #test_spatial_srid") def test_format_st_geom_from_text_without_srid(self, sqlserver_backend): - """Test format_st_geom_from_text generates correct SQL without SRID.""" + """Test format_st_geom_from_text_expression generates correct SQL without SRID.""" dialect = sqlserver_backend.dialect sql, params = SQLServerSTGeomFromTextExpression(dialect, 'POINT(3 4)').to_sql() @@ -116,7 +116,7 @@ def test_format_st_geom_from_text_without_srid(self, sqlserver_backend): assert '3 4' in result.data[0]['wkt'] def test_format_st_geom_from_text_with_srid(self, sqlserver_backend): - """Test format_st_geom_from_text generates correct SQL with SRID.""" + """Test format_st_geom_from_text_expression generates correct SQL with SRID.""" dialect = sqlserver_backend.dialect sql, params = SQLServerSTGeomFromTextExpression( dialect, 'POINT(1 1)', srid=4326 @@ -130,7 +130,7 @@ def test_format_st_geom_from_text_with_srid(self, sqlserver_backend): assert result.data[0]['srid'] == 4326 def test_format_st_as_text(self, sqlserver_backend): - """Test format_st_as_text generates correct SQL.""" + """Test format_st_as_text_expression generates correct SQL.""" sqlserver_backend.execute(""" CREATE TABLE #test_astext ( id INT IDENTITY(1,1) PRIMARY KEY, @@ -156,13 +156,13 @@ def test_format_st_as_text(self, sqlserver_backend): sqlserver_backend.execute("DROP TABLE IF EXISTS #test_astext") def test_format_st_as_geojson(self, sqlserver_backend): - """Test format_st_as_geojson generates correct SQL.""" + """Test format_st_as_geo_json_expression generates correct SQL.""" dialect = sqlserver_backend.dialect if hasattr(dialect, 'supports_geojson') and not dialect.supports_geojson(): pytest.skip("GeoJSON not supported by the SQL Server types assembly") def test_format_st_distance(self, sqlserver_backend): - """Test format_st_distance generates correct SQL.""" + """Test format_st_distance_expression generates correct SQL.""" dialect = sqlserver_backend.dialect point1_sql, point1_params = SQLServerSTGeomFromTextExpression( @@ -184,7 +184,7 @@ def test_format_st_distance(self, sqlserver_backend): assert abs(result.data[0]['distance'] - 5.0) < 0.001 def test_format_st_within(self, sqlserver_backend): - """Test format_st_within generates correct SQL.""" + """Test format_st_within_expression generates correct SQL.""" dialect = sqlserver_backend.dialect point_sql, point_params = SQLServerSTGeomFromTextExpression( @@ -206,7 +206,7 @@ def test_format_st_within(self, sqlserver_backend): assert result.data[0]['is_within'] == 1 def test_format_st_contains(self, sqlserver_backend): - """Test format_st_contains generates correct SQL.""" + """Test format_st_contains_expression generates correct SQL.""" dialect = sqlserver_backend.dialect polygon_sql, polygon_params = SQLServerSTGeomFromTextExpression( @@ -228,7 +228,7 @@ def test_format_st_contains(self, sqlserver_backend): assert result.data[0]['contains_point'] == 1 def test_format_create_spatial_index(self, sqlserver_backend): - """Test format_create_spatial_index generates valid SQL.""" + """Test format_create_spatial_index_statement generates valid SQL.""" dialect = sqlserver_backend.dialect if hasattr(dialect, 'supports_spatial_index') and not dialect.supports_spatial_index(): pytest.skip("SPATIAL index not supported in this SQLServer version") @@ -242,7 +242,7 @@ def test_format_create_spatial_index(self, sqlserver_backend): """) dialect = sqlserver_backend.dialect - index_sql, params = SQLServerCreateSpatialIndexExpression( + index_sql, params = SQLServerCreateSpatialIndexStatement( dialect, 'idx_location', '#test_spatial_idx', 'location' ).to_sql() @@ -281,7 +281,7 @@ async def test_async_supports_spatial_type_detection(self, async_sqlserver_backe @pytest.mark.asyncio async def test_async_format_spatial_literal(self, async_sqlserver_backend): - """Test format_spatial_literal generates correct SQL (async).""" + """Test format_spatial_literal_expression generates correct SQL (async).""" await async_sqlserver_backend.execute(""" CREATE TABLE #test_async_spatial ( id INT IDENTITY(1,1) PRIMARY KEY, @@ -308,7 +308,7 @@ async def test_async_format_spatial_literal(self, async_sqlserver_backend): @pytest.mark.asyncio async def test_async_format_st_geom_from_text(self, async_sqlserver_backend): - """Test format_st_geom_from_text generates correct SQL (async).""" + """Test format_st_geom_from_text_expression generates correct SQL (async).""" dialect = async_sqlserver_backend.dialect sql, params = SQLServerSTGeomFromTextExpression( dialect, 'POINT(10 20)', srid=4326 @@ -323,7 +323,7 @@ async def test_async_format_st_geom_from_text(self, async_sqlserver_backend): @pytest.mark.asyncio async def test_async_format_st_distance(self, async_sqlserver_backend): - """Test format_st_distance generates correct SQL (async).""" + """Test format_st_distance_expression generates correct SQL (async).""" dialect = async_sqlserver_backend.dialect point1_sql, point1_params = SQLServerSTGeomFromTextExpression( @@ -346,7 +346,7 @@ async def test_async_format_st_distance(self, async_sqlserver_backend): @pytest.mark.asyncio async def test_async_format_st_within(self, async_sqlserver_backend): - """Test format_st_within generates correct SQL (async).""" + """Test format_st_within_expression generates correct SQL (async).""" dialect = async_sqlserver_backend.dialect point_sql, point_params = SQLServerSTGeomFromTextExpression( @@ -369,14 +369,14 @@ async def test_async_format_st_within(self, async_sqlserver_backend): @pytest.mark.asyncio async def test_async_format_st_as_geojson(self, async_sqlserver_backend): - """Test format_st_as_geojson generates correct SQL (async).""" + """Test format_st_as_geo_json_expression generates correct SQL (async).""" dialect = async_sqlserver_backend.dialect if hasattr(dialect, 'supports_geojson') and not dialect.supports_geojson(): pytest.skip("GeoJSON not supported by the SQL Server types assembly") @pytest.mark.asyncio async def test_async_format_create_spatial_index(self, async_sqlserver_backend): - """Test format_create_spatial_index generates valid SQL (async).""" + """Test format_create_spatial_index_statement generates valid SQL (async).""" dialect = async_sqlserver_backend.dialect if hasattr(dialect, 'supports_spatial_index') and not dialect.supports_spatial_index(): pytest.skip("SPATIAL index not supported in this SQLServer version") @@ -389,7 +389,7 @@ async def test_async_format_create_spatial_index(self, async_sqlserver_backend): """) dialect = async_sqlserver_backend.dialect - index_sql, _ = SQLServerCreateSpatialIndexExpression( + index_sql, _ = SQLServerCreateSpatialIndexStatement( dialect, 'idx_loc', '#test_async_idx', 'location' ).to_sql() diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_constraint_capabilities.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_constraint_capabilities.py new file mode 100644 index 0000000..098afc3 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_constraint_capabilities.py @@ -0,0 +1,483 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_constraint_capabilities.py +"""SQL Server constraint capability switches, pinned to live measurements. + +Every value asserted here was measured against the instance +``tests/config/sqlserver_scenarios.yaml`` names ``sqlserver_2019``: +**SQL Server 2019 (RTM-CU32-GDR), 15.0.4465.1, Developer Edition**, on +``127.0.0.1:11433``, through pyodbc 5.3.0 + ``ODBC Driver 17 for SQL +Server``. The statements and the server's own message numbers are recorded in +the docstrings of +``rhosocial.activerecord.backend.impl.sqlserver.mixins.constraint``; this +module pins the values and checks that they are load-bearing. + +Four guards beyond the values themselves: + +* **declared here, not inherited.** Each switch must be declared by a class in + *this* package, so a change to a core default cannot silently move SQL + Server's contract (the same discipline the master-probe guard applies). +* **the signature is ``(self)``.** A probe answers one question -- is this + capability family present on this server -- and the kind is not part of it, + so a parameterised probe is refused here on the bound method. +* **the docstring still quotes its measurement.** Every switch records the + server's message number and the release it was measured on. +* **the answer decides the outcome.** For every switch core consults, the + render/refuse behaviour must follow the value: ``ADD`` / ``DROP + CONSTRAINT`` render, ``VALIDATE`` / ``ALTER CONSTRAINT`` / ``NOT VALID`` are + refused by name, and the standard clause traits (``ENFORCED`` / ``NOT + ENFORCED`` / ``DEFERRABLE`` / ``INITIALLY``) are refused at both the column + and the table seam -- including on the PRIMARY KEY / UNIQUE column kinds that + ``format_column_definition`` spells itself. + +The trait walk is driven by the same pairs as +``test_clause_pair_guard_sqlserver.py`` but through the two public seams, so a +seam that stopped consulting the shared helper fails here. The per-spelling +walk there also asserts the two refusals stay distinguishable; this file +asserts the seam, that one asserts the wording. + +Not covered here, on purpose: the SQL-standard clauses SQL Server cannot +spell at all were already driven through the two-spelling pair walk in +``test_clause_pair_guard_sqlserver.py``; duplicating the assertions would +let the two files disagree. +""" + +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.core import Column +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + AddTableConstraintAction, + AlterConstraintAction, + DropTableConstraintAction, + ValidateConstraintAction, +) +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + ColumnConstraint, + ColumnDefinition, + ColumnForeignKeyConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, + ConstraintValidation, + TableCheckConstraint, + TableConstraint, + TableForeignKeyConstraint, + TablePrimaryKeyConstraint, +) +from rhosocial.activerecord.backend.expression.types import IntegerType +from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect + +SQL_SERVER_2019 = (15, 0, 0) + +#: Every capability this package declares must come from this package. +BACKEND_PACKAGE = "rhosocial.activerecord.backend.impl.sqlserver." + +#: (switch name, measured value) pairs. The value is hard-coded on purpose: +#: reading it back from the dialect would make the test unable to see a +#: moved default. +MEASURED_SWITCHES = ( + # SQL-standard clauses: none of them are T-SQL (Msg 102 / 156 syntax errors). + ("supports_constraint_enforced", False), + ("supports_deferrable_constraint", False), + ("supports_fk_match", False), + ("supports_constraint_novalidate", False), + ("supports_validate_constraint", False), + ("supports_alter_constraint_enforced", False), + ("supports_enable_disable_constraint", False), + ("supports_rename_constraint", False), + # ALTER TABLE ... ADD/DROP CONSTRAINT themselves: all four kinds accepted. + ("supports_add_constraint", True), + ("supports_drop_constraint", True), +) + + +def _dialect(): + return SQLServerDialect(SQL_SERVER_2019) + + +def _declaring_class(switch_name): + """The first class in the dialect's MRO that declares ``switch_name``.""" + for cls in SQLServerDialect.__mro__: + if switch_name in cls.__dict__: + return cls + raise AssertionError(f"no class in the MRO declares {switch_name}()") + + +def _predicate(d): + return ComparisonPredicate(d, "=", Column(d, "a"), Column(d, "b")) + + +def _check(d, **kwargs): + return TableConstraint(d, branch=TableCheckConstraint(d, _predicate(d)), name="ck", **kwargs) + + +#: The SQL spelling each switch's docstring must quote as the evidence for its +#: value: the server's own message number or accepted verdict. A verdict that +#: cannot be traced to an observation is a guess with a name, so the check is +#: mechanical -- the substring must still be in the docstring. +DOCSTRING_EVIDENCE = { + "supports_constraint_enforced": "156", + "supports_deferrable_constraint": "102", + "supports_fk_match": "102", + "supports_constraint_novalidate": "156", + "supports_validate_constraint": "102", + "supports_alter_constraint_enforced": "156", + "supports_enable_disable_constraint": "156", + "supports_rename_constraint": "15248", + "supports_add_constraint": "1779", + "supports_drop_constraint": "3728", +} + + +class TestMeasuredConstraintSwitchValues: + @pytest.mark.parametrize( + "switch_name,expected", MEASURED_SWITCHES, ids=[s for s, _ in MEASURED_SWITCHES] + ) + def test_matches_measurement(self, switch_name, expected): + dialect = _dialect() + assert getattr(dialect, switch_name)() is expected, ( + f"{switch_name}() no longer matches the 2019 measurement; see the " + f"docstring in mixins/constraint.py for the statement that decided it." + ) + + @pytest.mark.parametrize( + "switch_name,_expected", MEASURED_SWITCHES, ids=[s for s, _ in MEASURED_SWITCHES] + ) + def test_declared_by_this_backend(self, switch_name, _expected): + """A moved core default must not move SQL Server's contract silently.""" + declaring = _declaring_class(switch_name) + assert declaring.__module__.startswith(BACKEND_PACKAGE), ( + f"{switch_name} is inherited from {declaring.__module__}; declare it " + f"in this backend's mixins so the measured value is explicit." + ) + + @pytest.mark.parametrize( + "switch_name,_expected", MEASURED_SWITCHES, ids=[s for s, _ in MEASURED_SWITCHES] + ) + def test_declaring_class_precedes_the_core_constraint_mixin( + self, switch_name, _expected + ): + """The backend's declaration reaches the MRO before core's. + + The locality check above says a class in this package answers; this says + the *order* makes that class the one that answers. A probe declared in + both places, with core's mixin ordered first, would satisfy the first + check and still have its value decided in another repository. + """ + from rhosocial.activerecord.backend.dialect.mixins.ddl_table import ( + ConstraintMixin, + ) + + mro = SQLServerDialect.__mro__ + declaring_index = mro.index(_declaring_class(switch_name)) + assert declaring_index < mro.index(ConstraintMixin), ( + f"{switch_name} is declared by {mro[declaring_index].__name__}, which " + f"follows core's ConstraintMixin in the MRO; core's default answers." + ) + + @pytest.mark.parametrize( + "switch_name,_expected", MEASURED_SWITCHES, ids=[s for s, _ in MEASURED_SWITCHES] + ) + def test_probe_takes_no_argument(self, switch_name, _expected): + """``(self)`` and nothing else. + + Checked on the bound method so a defaulted parameter cannot slip back in: + a probe answers one question -- is this capability family present on this + server -- and the kind is not part of it. The per-kind answer belongs to + the formatter, which can see the branch class. + """ + import inspect + + bound = getattr(_dialect(), switch_name) + parameters = list(inspect.signature(bound).parameters) + assert parameters == [], ( + f"{switch_name} takes {parameters}; a probe carries no kind and must " + f"be (self)." + ) + + @pytest.mark.parametrize( + "switch_name,_expected", MEASURED_SWITCHES, ids=[s for s, _ in MEASURED_SWITCHES] + ) + def test_docstring_quotes_the_measurement(self, switch_name, _expected): + """The evidence for the value is still in the docstring. + + Pattern 6 of the reconciliation rules: every switch records the server + version and the exact answer its value came from. A docstring that stops + quoting its measurement fails here rather than quietly becoming a guess. + """ + import inspect + + docstring = inspect.getdoc(getattr(SQLServerDialect, switch_name)) or "" + evidence = DOCSTRING_EVIDENCE[switch_name] + assert evidence in docstring, ( + f"{switch_name}'s docstring no longer quotes {evidence!r}; the value " + f"cannot be traced back to a measurement." + ) + assert "2019" in docstring, ( + f"{switch_name}'s docstring does not name the server version the " + f"value was measured on." + ) + + def test_version_does_not_move_the_values(self): + """The values are statement-level facts, not version gates. + + Measured on 2019 only; asserting they hold at 2022/2025 is a claim + this backend has not made, so the check is just that no version + branch exists for them. + """ + for version in ((15, 0, 0), (16, 0, 0), (17, 0, 0)): + dialect = SQLServerDialect(version) + for switch_name, expected in MEASURED_SWITCHES: + assert getattr(dialect, switch_name)() is expected, ( + f"{switch_name} moved with version {version}" + ) + + +class TestAddAndDropConstraintRender: + """The two True switches decide these renders.""" + + def test_add_table_constraint_renders(self): + d = _dialect() + sql, params = AddTableConstraintAction(d, _check(d)).to_sql() + assert sql == "ADD CONSTRAINT [ck] CHECK ([a] = [b])" + assert params == () + + def test_add_primary_key_renders(self): + d = _dialect() + action = AddTableConstraintAction( + d, + TableConstraint(d, branch=TablePrimaryKeyConstraint(d, ["id"]), name="pk"), + ) + sql, params = action.to_sql() + assert sql == "ADD CONSTRAINT [pk] PRIMARY KEY ([id])" + assert params == () + + def test_drop_constraint_renders(self): + d = _dialect() + sql, params = DropTableConstraintAction(d, "ck").to_sql() + assert sql == "DROP CONSTRAINT [ck]" + assert params == () + + def test_drop_constraint_if_exists_renders(self): + """``IF EXISTS`` is the idempotent path: the plain form is Msg 3728.""" + d = _dialect() + sql, params = DropTableConstraintAction(d, "ck", if_exists=True).to_sql() + assert sql == "DROP CONSTRAINT IF EXISTS [ck]" + assert params == () + + +class TestAlterSideRefusals: + """The False switches decide these refusals; none is T-SQL.""" + + def test_validate_constraint_refused(self): + d = _dialect() + with pytest.raises(UnsupportedFeatureError): + ValidateConstraintAction(d, "ck").to_sql() + + def test_alter_constraint_enforced_refused(self): + d = _dialect() + action = AlterConstraintAction( + d, "ck", enforced=True, constraint_kind=TableCheckConstraint + ) + with pytest.raises(UnsupportedFeatureError): + action.to_sql() + + def test_alter_constraint_not_enforced_refused(self): + d = _dialect() + action = AlterConstraintAction( + d, + "ck", + not_enforced=True, + constraint_kind=TableForeignKeyConstraint, + ) + with pytest.raises(UnsupportedFeatureError): + action.to_sql() + + def test_add_constraint_not_valid_refused_on_the_add_path(self): + d = _dialect() + action = AddTableConstraintAction( + d, _check(d, validation=ConstraintValidation.NOVALIDATE) + ) + with pytest.raises(UnsupportedFeatureError): + action.to_sql() + + +class TestColumnLevelNarrowing: + """The column-level refusal, and the coupling it works around. + + ``format_column_definition`` is this backend's override, so a column's + constraints do not go through the standalone ``ColumnConstraint.to_sql()`` + dispatch -- they go through this path. Each assertion below therefore + renders a whole ``ColumnDefinition``, which is where a user's column lands. + """ + + def test_column_references_is_refused(self): + """T-SQL has no column-level ``REFERENCES``; the table form is the only one. + + The coupling: core gates the column grammar on + ``supports_foreign_key_constraint()``, the *table-level* probe, and SQL + Server needs that probe True. So the refusal lives in this backend's + ``format_column_fk_constraint`` override rather than behind a probe. + """ + d = _dialect() + column = ColumnDefinition( + d, + "a", + IntegerType(d), + constraints=[ + ColumnConstraint( + d, + branch=ColumnForeignKeyConstraint( + d, (Table(d, "other"), ["id"]) + ), + ) + ], + ) + with pytest.raises(UnsupportedFeatureError, match="column-level REFERENCES"): + column.to_sql() + + def test_table_references_from_a_column_still_reachable(self): + """The table-level probe stays True, which is why the override is needed.""" + assert _dialect().supports_foreign_key_constraint() is True + + @pytest.mark.parametrize( + "kwargs,spelling", + ( + ({"enforced": True}, "ENFORCED"), + ({"not_enforced": True}, "NOT ENFORCED"), + ({"deferrable": True}, "DEFERRABLE"), + ({"not_deferrable": True}, "NOT DEFERRABLE"), + ({"initially_deferred": True}, "INITIALLY DEFERRED"), + ({"initially_immediate": True}, "INITIALLY IMMEDIATE"), + ), + ids=( + "enforced", + "not-enforced", + "deferrable", + "not-deferrable", + "initially-deferred", + "initially-immediate", + ), + ) + def test_primary_key_traits_are_refused_not_dropped(self, kwargs, spelling): + """A trait on a PRIMARY KEY column is refused by name, never dropped. + + PRIMARY KEY and UNIQUE are spelled inside ``format_column_definition`` + rather than through the production formatter, so this is the path that + would otherwise drop the request without a word -- which the old + tag-switch formatter refused with a ``ValueError``. The same helper + backs both, so the two cannot drift apart. + """ + d = _dialect() + column = ColumnDefinition( + d, + "a", + IntegerType(d), + constraints=[ + ColumnConstraint(d, branch=ColumnPrimaryKeyConstraint(d), **kwargs) + ], + ) + with pytest.raises(UnsupportedFeatureError, match=spelling): + column.to_sql() + + @pytest.mark.parametrize( + "kwargs", + ( + {"enforced": True}, + {"deferrable": True}, + {"initially_deferred": True}, + ), + ids=("enforced", "deferrable", "initially-deferred"), + ) + def test_unique_traits_are_refused_not_dropped(self, kwargs): + """The same for UNIQUE, the other kind the shortcut spells.""" + d = _dialect() + column = ColumnDefinition( + d, + "a", + IntegerType(d), + constraints=[ + ColumnConstraint(d, branch=ColumnUniqueConstraint(d), **kwargs) + ], + ) + with pytest.raises(UnsupportedFeatureError): + column.to_sql() + + def test_primary_key_renders_when_no_trait_is_asked_for(self): + """The refusal must not swallow the plain form: IDENTITY rides here.""" + d = _dialect() + column = ColumnDefinition( + d, + "id", + IntegerType(d), + constraints=[ + ColumnConstraint( + d, branch=ColumnPrimaryKeyConstraint(d, is_auto_increment=True) + ) + ], + ) + sql, params = column.to_sql() + assert sql == "[id] INT PRIMARY KEY IDENTITY(1,1)" + assert params == () + + +class TestTableLevelTraitRefusals: + """The table seam refuses the same six spellings, by name. + + A table PRIMARY KEY is the branch used here because it is one of the kinds + that can carry **both** traits (the enforcement one and the deferral one), + so every spelling reaches the dialect's refusal rather than being turned + away by the node's own construction-time check. + """ + + @pytest.mark.parametrize( + "kwargs,spelling", + ( + ({"enforced": True}, "ENFORCED"), + ({"not_enforced": True}, "NOT ENFORCED"), + ({"deferrable": True}, "DEFERRABLE"), + ({"not_deferrable": True}, "NOT DEFERRABLE"), + ({"initially_deferred": True}, "INITIALLY DEFERRED"), + ({"initially_immediate": True}, "INITIALLY IMMEDIATE"), + ), + ids=( + "enforced", + "not-enforced", + "deferrable", + "not-deferrable", + "initially-deferred", + "initially-immediate", + ), + ) + def test_table_traits_are_refused(self, kwargs, spelling): + d = _dialect() + constraint = TableConstraint( + d, branch=TablePrimaryKeyConstraint(d, ["a"]), name="pk", **kwargs + ) + with pytest.raises(UnsupportedFeatureError, match=spelling): + constraint.to_sql() + + def test_a_table_check_without_a_trait_renders(self): + d = _dialect() + sql, params = _check(d).to_sql() + assert sql == "CONSTRAINT [ck] CHECK ([a] = [b])" + assert params == () + + def test_a_table_key_without_a_trait_renders(self): + d = _dialect() + sql, params = TableConstraint( + d, branch=TablePrimaryKeyConstraint(d, ["a"]), name="pk" + ).to_sql() + assert sql == "CONSTRAINT [pk] PRIMARY KEY ([a])" + assert params == () + + def test_the_node_refuses_a_trait_the_kind_cannot_carry(self): + """The deferral trait on a CHECK is the node's refusal, not the dialect's. + + Recorded here so the two layers stay distinguishable: this is a + ``ValueError`` raised at construction by ``TableConstraint``, before any + dialect code sees it, and it is why the walk above uses a PRIMARY KEY. + """ + d = _dialect() + with pytest.raises(ValueError, match="DEFERRABLE / INITIALLY"): + _check(d, deferrable=True) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_database_ddl.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_database_ddl.py index 35c0353..70971ce 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_database_ddl.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_database_ddl.py @@ -3,8 +3,8 @@ from rhosocial.activerecord.backend.expression.objects import Database from rhosocial.activerecord.backend.expression.statements.ddl_database import ( - CreateDatabaseExpression, - DropDatabaseExpression, + CreateDatabaseStatement, + DropDatabaseStatement, ) from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect @@ -21,12 +21,12 @@ def test_database_capabilities(): def test_create_database_renders(): dialect = _dialect() - sql, params = CreateDatabaseExpression(dialect, Database(dialect, "app")).to_sql() + sql, params = CreateDatabaseStatement(dialect, Database(dialect, "app")).to_sql() assert "CREATE DATABASE" in sql assert params == () def test_drop_database_renders(): dialect = _dialect() - sql, _ = DropDatabaseExpression(dialect, Database(dialect, "app")).to_sql() + sql, _ = DropDatabaseStatement(dialect, Database(dialect, "app")).to_sql() assert "DROP DATABASE" in sql diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_ddl_improvements.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_ddl_improvements.py index d1d2dc5..aa0af20 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 @@ -6,9 +6,9 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression import ( Column, - CreateTableExpression, - CreateViewExpression, - QueryExpression, + CreateTableStatement, + CreateViewStatement, + QueryStatement, ) from rhosocial.activerecord.backend.expression.objects import Table, View from rhosocial.activerecord.backend.expression.statements import ( @@ -23,7 +23,7 @@ class TestSQLServerTableCapabilityGating: def test_table_declaration_defaults_are_absent(self): dialect = SQLServerDialect(version=(16, 0, 0)) - expression = CreateTableExpression( + expression = CreateTableStatement( dialect, Table(dialect, "plain_table_defaults"), [ColumnDefinition(dialect, "id", IntegerType(dialect))], @@ -37,7 +37,7 @@ def test_table_declaration_defaults_are_absent(self): def test_table_inherits_declaration_is_propagated_and_fails_fast(self): dialect = SQLServerDialect(version=(16, 0, 0)) - expression = CreateTableExpression( + expression = CreateTableStatement( dialect, Table(dialect, "inheriting_table"), [ColumnDefinition(dialect, "id", IntegerType(dialect))], @@ -50,7 +50,7 @@ def test_table_inherits_declaration_is_propagated_and_fails_fast(self): def test_table_tablespace_declaration_is_propagated_and_fails_fast(self): dialect = SQLServerDialect(version=(16, 0, 0)) - expression = CreateTableExpression( + expression = CreateTableStatement( dialect, Table(dialect, "tablespaced_table"), [ColumnDefinition(dialect, "id", IntegerType(dialect))], @@ -68,10 +68,10 @@ class TestSQLServerViewCapabilityGating: def test_create_view_check_option_gated(self): """WITH CHECK OPTION must fail fast when the capability is off.""" dialect = SQLServerDialect() - query = QueryExpression( + query = QueryStatement( dialect, select=[Column(dialect, "id")], from_=Table(dialect, "t") ) - expr = CreateViewExpression( + expr = CreateViewStatement( dialect, view=View(dialect, "v"), query=query, diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_dialect_security.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_dialect_security.py index a6d3a4e..0c107e0 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_dialect_security.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_dialect_security.py @@ -75,9 +75,9 @@ def test_format_identifier_empty_string(dialect): assert dialect.format_identifier("") == "[]" -def test_escape_sql_string_inherited(dialect): - """Test SQL Server inherits _escape_sql_string from base dialect.""" - result = dialect._escape_sql_string("test's value") +def testescape_sql_string_inherited(dialect): + """Test SQL Server inherits escape_sql_string from base dialect.""" + result = dialect.escape_sql_string("test's value") assert result == "test''s value" 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 86d939e..4a8ed16 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 @@ -7,7 +7,7 @@ from rhosocial.activerecord.backend.dialect import protocols as dialect_protocols from rhosocial.activerecord.backend.expression import ( Column, - CreateTableExpression, + CreateTableStatement, ColumnDefinition, ) from rhosocial.activerecord.backend.expression.objects import Table @@ -15,7 +15,7 @@ PartitionClause, PartitionStrategy, ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import DateType, IntegerType, VarCharType from rhosocial.activerecord.backend.impl.sqlserver.expression.partition import ( @@ -223,7 +223,7 @@ class TestSQLServerCreateTableWithPartition: def test_create_table_with_partition(self): d = SQLServerDialect(SQL_SERVER_2022) - pk = ColumnConstraint(d, ColumnConstraintType.PRIMARY_KEY) + pk = ColumnConstraint(d, branch=ColumnPrimaryKeyConstraint(d)) col_def = ColumnDefinition(d, "id", IntegerType(d), constraints=[pk]) col_def2 = ColumnDefinition(d, "name", VarCharType(d, length=100)) col_def3 = ColumnDefinition(d, "create_date", DateType(d)) @@ -234,7 +234,7 @@ def test_create_table_with_partition(self): partition_scheme="ps_sales", ) - stmt = CreateTableExpression( + stmt = CreateTableStatement( d, Table(d, "orders"), columns=[col_def, col_def2, col_def3], partition=partition, @@ -247,9 +247,9 @@ def test_create_table_with_partition(self): def test_create_table_without_partition(self): """Unchanged behavior when no partition is specified.""" d = SQLServerDialect(SQL_SERVER_2022) - pk = ColumnConstraint(d, ColumnConstraintType.PRIMARY_KEY) + pk = ColumnConstraint(d, branch=ColumnPrimaryKeyConstraint(d)) col_def = ColumnDefinition(d, "id", IntegerType(d), constraints=[pk]) - stmt = CreateTableExpression( + stmt = CreateTableStatement( d, Table(d, "users"), columns=[col_def], ) @@ -261,11 +261,11 @@ def test_create_table_without_partition(self): def test_create_table_with_partition_on_old_version(self): """Should fail on SQL Server 2005 which doesn't support partitioning.""" d = SQLServerDialect(SQL_SERVER_2005) - pk = ColumnConstraint(d, ColumnConstraintType.PRIMARY_KEY) + pk = ColumnConstraint(d, branch=ColumnPrimaryKeyConstraint(d)) col_def = ColumnDefinition(d, "id", IntegerType(d), constraints=[pk]) col = Column(d, "id") partition = SQLServerPartitionByRangeClause(d, [col], "ps_test") - stmt = CreateTableExpression( + stmt = CreateTableStatement( d, Table(d, "test"), columns=[col_def], partition=partition, diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_protocol_conformance.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_protocol_conformance.py index 588ea42..668603a 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 @@ -233,4 +233,44 @@ def test_positive_and_negative_lists_partition_all_protocols(self): assert not unclassified, ( f"Generic protocols not classified for SQL Server: {sorted(unclassified)}. " f"Add each to SQLSERVER_PROTOCOLS or SQLSERVER_NOT_IMPLEMENTED." - ) \ No newline at end of file + ) + + +class TestTheBackendOwnAnswersAreTheEffectiveOnes: + """A backend mixin's method must resolve to a backend class. + + The other checks in this file are about the *declaration*: the mixin + declares the method, and the protocol declares it too. This one is + about the *resolution*. A core class that wins the MRO makes the + backend's implementation dead code, and the answer a caller gets is + core's rather than the backend's -- so the backend never says what it + means about itself, and nothing else in the suite notices: the + declaration is there either way. + + A protocol class is a declaration rather than an implementation (its + body is ``...``), so a core class may legitimately precede it; only + the mixins are held to this. + """ + + def test_no_backend_method_is_shadowed_by_a_core_class(self): + mro = type(SQLServerDialect(SQLSERVER_VERSION)).__mro__ + own = "rhosocial.activerecord.backend.impl.sqlserver" + shadowed = [] + for klass in mro: + if not klass.__module__.startswith(own): + continue + if not klass.__name__.endswith("Mixin"): + continue + for name in vars(klass): + if not name.startswith(("format_", "supports_", "get_")): + continue + if not callable(vars(klass)[name]): + continue + winner = next((k for k in mro if name in k.__dict__), None) + if winner is None or winner.__module__.startswith(own): + continue + shadowed.append(f"{klass.__name__}.{name} -> {winner.__name__}") + assert not shadowed, ( + "a core class wins these backend methods, so the backend's " + f"implementation is dead code: {shadowed}" + ) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_type_ddl.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_type_ddl.py index dcb69a1..eec84ee 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_type_ddl.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_type_ddl.py @@ -11,7 +11,7 @@ DropTypeSupport, ) from rhosocial.activerecord.backend.expression.serialization import ( - ExpressionRegistry, + default_registry, deserialize, deserialize_json, deserialize_xml, @@ -20,14 +20,21 @@ serialize_xml, ) from rhosocial.activerecord.backend.expression.statements import ( - AlterTypeExpression, + AlterTypeStatement, + ColumnCheckConstraint, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, ColumnDefinition, - CreateTypeExpression, - DropTypeExpression, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, + CreateTypeStatement, + DropTypeStatement, + TableCheckConstraint, TableConstraint, - TableConstraintType, + TableForeignKeyConstraint, + TablePrimaryKeyConstraint, + TableUniqueConstraint, ) from rhosocial.activerecord.backend.expression.objects import Table, Type from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType @@ -39,7 +46,7 @@ SQLServerAliasTypeDefinition, SQLServerClrTypeDefinition, SQLServerDropTypeExpression, - SQLServerRenameTypeExpression, + SQLServerRenameTypeStatement, SQLServerTableTypeDefinition, ) from rhosocial.activerecord.backend.impl.sqlserver.expression.index import ( @@ -96,7 +103,7 @@ def test_type_capability_version_boundaries(): memory_optimized=True, ) with pytest.raises(UnsupportedFeatureError): - CreateTypeExpression(sql2012, Type(sql2012, "too_old"), memory_definition).to_sql() + CreateTypeStatement(sql2012, Type(sql2012, "too_old"), memory_definition).to_sql() assert sql2012.supports_drop_type_if_exists() is False assert sql2016.supports_drop_type_if_exists() is True assert sql2016.supports_create_type_if_not_exists() is False @@ -108,7 +115,7 @@ def test_type_capability_version_boundaries(): columns=[ColumnDefinition(sql2005, "id", IntegerType(sql2005))], ) with pytest.raises(UnsupportedFeatureError): - CreateTypeExpression(sql2005, Type(sql2005, "too_old"), table_definition).to_sql() + CreateTypeStatement(sql2005, Type(sql2005, "too_old"), table_definition).to_sql() def test_clr_definition_is_platform_gated(): @@ -130,7 +137,7 @@ def test_clr_definition_is_platform_gated(): assert fabric.supports_clr_type_definition() is False assert fabric.supports_type_definition(SQLServerClrTypeDefinition) is False with pytest.raises(UnsupportedFeatureError): - CreateTypeExpression(azure, Type(azure, "clr_type"), definition).to_sql() + CreateTypeStatement(azure, Type(azure, "clr_type"), definition).to_sql() def test_backend_passes_deployment_target_from_connection_config(): @@ -173,7 +180,7 @@ def test_alias_type_schema_and_identifier_escaping(): VarCharType(dialect, length=50), nullability="NOT NULL", ) - expression = CreateTypeExpression( + expression = CreateTypeStatement( dialect, Type(dialect, "name]x", schema_name="app]schema"), definition, @@ -188,7 +195,7 @@ def test_alias_type_schema_and_identifier_escaping(): def test_type_name_dot_is_a_single_identifier(): dialect = SQLServerDialect(SQL_SERVER_2022) definition = SQLServerAliasTypeDefinition(dialect, IntegerType(dialect)) - expression = CreateTypeExpression( + expression = CreateTypeStatement( dialect, Type(dialect, "type.with.dot", schema_name="app"), definition, @@ -207,7 +214,7 @@ def test_clr_type_sql(): "sample_assembly", "Sample.Namespace.UserType", ) - expression = CreateTypeExpression( + expression = CreateTypeStatement( dialect, Type(dialect, "clr_type", schema_name="app"), definition ) @@ -225,16 +232,12 @@ def test_table_type_columns_constraints_indexes_and_memory(): dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))], ), ColumnDefinition(dialect, "name", VarCharType(dialect, length=100)), ] constraints = [ - TableConstraint( - dialect, - TableConstraintType.PRIMARY_KEY, - columns=["id"], - ) + TableConstraint(dialect, branch=TablePrimaryKeyConstraint(dialect, ["id"])) ] indexes = [SQLServerIndexDefinition(dialect, "ix_name", ["name"], type="NONCLUSTERED")] definition = SQLServerTableTypeDefinition( @@ -244,7 +247,7 @@ def test_table_type_columns_constraints_indexes_and_memory(): indexes=indexes, memory_optimized=True, ) - expression = CreateTypeExpression(dialect, Type(dialect, "inventory_type"), definition) + expression = CreateTypeStatement(dialect, Type(dialect, "inventory_type"), definition) sql, params = expression.to_sql() assert sql.startswith("CREATE TYPE [inventory_type] AS TABLE (") @@ -264,7 +267,7 @@ def test_memory_table_type_requires_an_index(): ) with pytest.raises(ValueError, match="at least one index"): - CreateTypeExpression(dialect, Type(dialect, "missing_index_type"), definition).to_sql() + CreateTypeStatement(dialect, Type(dialect, "missing_index_type"), definition).to_sql() def test_non_memory_table_type_keeps_regular_constraints(): @@ -276,20 +279,12 @@ def test_non_memory_table_type_keeps_regular_constraints(): ColumnDefinition(dialect, "name", VarCharType(dialect, length=20)), ], constraints=[ - TableConstraint( - dialect, - TableConstraintType.PRIMARY_KEY, - columns=["id"], - ), - TableConstraint( - dialect, - TableConstraintType.UNIQUE, - columns=["name"], - ), + TableConstraint(dialect, branch=TablePrimaryKeyConstraint(dialect, ["id"])), + TableConstraint(dialect, branch=TableUniqueConstraint(dialect, ["name"])), ], ) - sql, params = CreateTypeExpression(dialect, Type(dialect, "regular_type"), definition).to_sql() + sql, params = CreateTypeStatement(dialect, Type(dialect, "regular_type"), definition).to_sql() assert "PRIMARY KEY ([id])" in sql assert "UNIQUE ([name])" in sql assert "MEMORY_OPTIMIZED" not in sql @@ -306,20 +301,20 @@ def test_memory_table_type_column_key_constraints_use_nonclustered(): "id", IntegerType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), ], ), ColumnDefinition( dialect, "code", VarCharType(dialect, length=20), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.UNIQUE)], + constraints=[ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect))], ), ], memory_optimized=True, ) - sql, params = CreateTypeExpression(dialect, Type(dialect, "column_keys_type"), definition).to_sql() + sql, params = CreateTypeStatement(dialect, Type(dialect, "column_keys_type"), definition).to_sql() assert "PRIMARY KEY NONCLUSTERED" in sql assert "UNIQUE NONCLUSTERED" in sql assert params == () @@ -337,11 +332,7 @@ def test_memory_table_type_rejects_unsupported_constraints(): "id", IntegerType(dialect), constraints=[ - ColumnConstraint( - dialect, - ColumnConstraintType.CHECK, - check_condition=check, - ) + ColumnConstraint(dialect, branch=ColumnCheckConstraint(dialect, check)) ], ) ], @@ -352,11 +343,7 @@ def test_memory_table_type_rejects_unsupported_constraints(): dialect, columns=[ColumnDefinition(dialect, "id", IntegerType(dialect))], constraints=[ - TableConstraint( - dialect, - TableConstraintType.CHECK, - check_condition=check, - ) + TableConstraint(dialect, branch=TableCheckConstraint(dialect, check)) ], indexes=[SQLServerIndexDefinition(dialect, "ix_id", ["id"])], memory_optimized=True, @@ -367,10 +354,9 @@ def test_memory_table_type_rejects_unsupported_constraints(): constraints=[ TableConstraint( dialect, - TableConstraintType.FOREIGN_KEY, - columns=["id"], - foreign_key_table=Table(dialect, "other"), - foreign_key_columns=["id"], + branch=TableForeignKeyConstraint( + dialect, ["id"], (Table(dialect, "other"), ["id"]) + ), ) ], indexes=[SQLServerIndexDefinition(dialect, "ix_id", ["id"])], @@ -380,7 +366,7 @@ def test_memory_table_type_rejects_unsupported_constraints(): for definition in definitions: with pytest.raises(UnsupportedFeatureError): - CreateTypeExpression(dialect, Type(dialect, "invalid_memory_type"), definition).to_sql() + CreateTypeStatement(dialect, Type(dialect, "invalid_memory_type"), definition).to_sql() def test_memory_table_type_default_is_supported_from_2014(): @@ -393,11 +379,7 @@ def test_memory_table_type_default_is_supported_from_2014(): "id", IntegerType(dialect), constraints=[ - ColumnConstraint( - dialect, - ColumnConstraintType.DEFAULT, - default_value=0, - ) + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0)) ], ) ], @@ -405,7 +387,7 @@ def test_memory_table_type_default_is_supported_from_2014(): memory_optimized=True, ) - sql, params = CreateTypeExpression(dialect, Type(dialect, "default_type"), definition).to_sql() + sql, params = CreateTypeStatement(dialect, Type(dialect, "default_type"), definition).to_sql() assert "DEFAULT 0" in sql assert params == () @@ -420,11 +402,7 @@ def test_memory_table_type_check_and_unique_version_gates(): "id", IntegerType(sql2014), constraints=[ - ColumnConstraint( - sql2014, - ColumnConstraintType.CHECK, - check_condition=RawSQLPredicate(sql2014, "1 = 1"), - ) + ColumnConstraint(sql2014, branch=ColumnCheckConstraint(sql2014, RawSQLPredicate(sql2014, "1 = 1"))) ], ) ], @@ -432,7 +410,7 @@ def test_memory_table_type_check_and_unique_version_gates(): memory_optimized=True, ) with pytest.raises(UnsupportedFeatureError, match="2016\\+"): - CreateTypeExpression(sql2014, Type(sql2014, "check_2014"), check_2014).to_sql() + CreateTypeStatement(sql2014, Type(sql2014, "check_2014"), check_2014).to_sql() unique_2014 = SQLServerTableTypeDefinition( sql2014, @@ -442,7 +420,7 @@ def test_memory_table_type_check_and_unique_version_gates(): "code", IntegerType(sql2014), constraints=[ - ColumnConstraint(sql2014, ColumnConstraintType.UNIQUE), + ColumnConstraint(sql2014, branch=ColumnUniqueConstraint(sql2014)), ], ) ], @@ -450,7 +428,7 @@ def test_memory_table_type_check_and_unique_version_gates(): memory_optimized=True, ) with pytest.raises(UnsupportedFeatureError, match="2016\\+"): - CreateTypeExpression(sql2014, Type(sql2014, "unique_2014"), unique_2014).to_sql() + CreateTypeStatement(sql2014, Type(sql2014, "unique_2014"), unique_2014).to_sql() sql2016 = SQLServerDialect(SQL_SERVER_2016) allowed = SQLServerTableTypeDefinition( @@ -461,11 +439,7 @@ def test_memory_table_type_check_and_unique_version_gates(): "id", IntegerType(sql2016), constraints=[ - ColumnConstraint( - sql2016, - ColumnConstraintType.CHECK, - check_condition=RawSQLPredicate(sql2016, "1 = 1"), - ) + ColumnConstraint(sql2016, branch=ColumnCheckConstraint(sql2016, RawSQLPredicate(sql2016, "1 = 1"))) ], ), ColumnDefinition( @@ -473,14 +447,14 @@ def test_memory_table_type_check_and_unique_version_gates(): "code", IntegerType(sql2016), constraints=[ - ColumnConstraint(sql2016, ColumnConstraintType.UNIQUE), + ColumnConstraint(sql2016, branch=ColumnUniqueConstraint(sql2016)), ], ), ], indexes=[SQLServerIndexDefinition(sql2016, "ix_id", ["id"])], memory_optimized=True, ) - sql, params = CreateTypeExpression(sql2016, Type(sql2016, "keys_2016"), allowed).to_sql() + sql, params = CreateTypeStatement(sql2016, Type(sql2016, "keys_2016"), allowed).to_sql() assert "CHECK (1 = 1)" in sql assert "UNIQUE NONCLUSTERED" in sql assert params == () @@ -514,14 +488,14 @@ def test_memory_table_type_index_limit_before_2017(version): definition = _memory_type_with_indexes(dialect, 9) with pytest.raises(UnsupportedFeatureError, match="8 indexes"): - CreateTypeExpression(dialect, Type(dialect, "too_many_indexes"), definition).to_sql() + CreateTypeStatement(dialect, Type(dialect, "too_many_indexes"), definition).to_sql() def test_memory_table_type_index_limit_removed_in_2017(): dialect = SQLServerDialect(SQL_SERVER_2017) definition = _memory_type_with_indexes(dialect, 9) - sql, params = CreateTypeExpression(dialect, Type(dialect, "many_indexes"), definition).to_sql() + sql, params = CreateTypeStatement(dialect, Type(dialect, "many_indexes"), definition).to_sql() assert sql.count("INDEX ") == 9 assert params == () @@ -547,7 +521,7 @@ def test_memory_table_type_include_requires_2019(version): ) with pytest.raises(UnsupportedFeatureError, match="2019\\+"): - CreateTypeExpression(dialect, Type(dialect, "include_type"), definition).to_sql() + CreateTypeStatement(dialect, Type(dialect, "include_type"), definition).to_sql() def test_memory_table_type_include_is_supported_from_2019(): @@ -569,7 +543,7 @@ def test_memory_table_type_include_is_supported_from_2019(): memory_optimized=True, ) - sql, params = CreateTypeExpression(dialect, Type(dialect, "include_type"), definition).to_sql() + sql, params = CreateTypeStatement(dialect, Type(dialect, "include_type"), definition).to_sql() assert "INCLUDE ([payload])" in sql assert params == () @@ -590,7 +564,7 @@ def test_table_type_hash_index_sql(): ], memory_optimized=True, ) - sql, params = CreateTypeExpression(dialect, Type(dialect, "hash_type"), definition).to_sql() + sql, params = CreateTypeStatement(dialect, Type(dialect, "hash_type"), definition).to_sql() assert "NONCLUSTERED HASH ([id]) WITH (BUCKET_COUNT = 1024)" in sql assert params == () @@ -602,9 +576,9 @@ def test_create_and_drop_type_fail_fast_options(): status = Type(dialect, "status") with pytest.raises(UnsupportedFeatureError): - CreateTypeExpression(dialect, status, definition, if_not_exists=True).to_sql() + CreateTypeStatement(dialect, status, definition, if_not_exists=True).to_sql() with pytest.raises(UnsupportedFeatureError): - CreateTypeExpression(dialect, status, definition, or_replace=True).to_sql() + CreateTypeStatement(dialect, status, definition, or_replace=True).to_sql() with pytest.raises(ValueError): SQLServerDropTypeExpression(dialect, status, cascade=True, restrict=True) with pytest.raises(UnsupportedFeatureError): @@ -615,8 +589,8 @@ def test_create_and_drop_type_fail_fast_options(): app_status = Type(dialect, "status", schema_name="app") pre_2016 = SQLServerDialect(SQL_SERVER_2012) with pytest.raises(UnsupportedFeatureError): - DropTypeExpression(pre_2016, app_status, if_exists=True).to_sql() - assert DropTypeExpression( + DropTypeStatement(pre_2016, app_status, if_exists=True).to_sql() + assert DropTypeStatement( dialect, app_status, if_exists=True, @@ -626,7 +600,7 @@ def test_create_and_drop_type_fail_fast_options(): def test_alter_type_is_explicitly_unsupported(): dialect = SQLServerDialect(SQL_SERVER_2022) action = _DummyTypeAlterAction(dialect, "renamed") - expression = AlterTypeExpression(dialect, Type(dialect, "status"), [action]) + expression = AlterTypeStatement(dialect, Type(dialect, "status"), [action]) with pytest.raises(UnsupportedFeatureError, match="ALTER TYPE"): expression.to_sql() @@ -634,7 +608,7 @@ def test_alter_type_is_explicitly_unsupported(): def test_rename_type_uses_sp_rename(): dialect = SQLServerDialect(SQL_SERVER_2022) - expression = SQLServerRenameTypeExpression( + expression = SQLServerRenameTypeStatement( dialect, "old]name", "new]name", @@ -649,6 +623,14 @@ def test_rename_type_uses_sp_rename(): def test_type_expressions_are_explicitly_registered(): + """The backend's type nodes assemble into the framework kind registry. + + Writing an artifact needs no registry: the node reports its own kind. + Reading one resolves the kind through the framework's default registry, + which this backend's expression module fills at import -- so the three + codec channels round-trip these backend-owned kinds through the same + default path the core's own classes use. + """ dialect = SQLServerDialect(SQL_SERVER_2022) expressions = ( SQLServerAliasTypeDefinition(dialect, IntegerType(dialect)), @@ -658,11 +640,13 @@ def test_type_expressions_are_explicitly_registered(): columns=[ColumnDefinition(dialect, "id", IntegerType(dialect))], ), SQLServerDropTypeExpression(dialect, Type(dialect, "type_name")), - SQLServerRenameTypeExpression(dialect, "old_name", "new_name"), + SQLServerRenameTypeStatement(dialect, "old_name", "new_name"), ) for expression in expressions: - fqn = f"{type(expression).__module__}.{type(expression).__name__}" - assert ExpressionRegistry.lookup(fqn) is type(expression) + # The kind whitelist is keyed by the kind derived from the class + # name; this backend fills the default registry at import. + kind = type(expression).kind() + assert default_registry().resolve_node(kind) is type(expression) for encoder, decoder in ( (serialize, deserialize), (serialize_json, deserialize_json), @@ -681,12 +665,11 @@ def test_table_type_rejects_foreign_key_constraint(): constraints=[ TableConstraint( dialect, - TableConstraintType.FOREIGN_KEY, - columns=["id"], - foreign_key_table=Table(dialect, "other"), - foreign_key_columns=["id"], + branch=TableForeignKeyConstraint( + dialect, ["id"], (Table(dialect, "other"), ["id"]) + ), ) ], ) with pytest.raises(UnsupportedFeatureError): - CreateTypeExpression(dialect, Type(dialect, "invalid_type"), definition).to_sql() + CreateTypeStatement(dialect, Type(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 87ddd8a..5b578e5 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 @@ -174,7 +174,7 @@ def test_every_key_names_the_class_behind_it(self, dialect): def test_backend_types_are_advertised_without_a_prior_import(self): """``supports_data_types()`` must not depend on load order. - ``_type_class_for(name)`` resolves a dispatch key by walking the live + ``type_class_for(name)`` resolves a dispatch key by walking the live subclass tree of ``DataType``, so a name whose class nobody imported resolves to ``None`` and silently drops out of the mapping. It used to drop out: every test that caught this had imported a ``SQLServer*`` @@ -1607,7 +1607,7 @@ def registry(self): import importlib.util from pathlib import Path - path = Path(__file__).with_name("test_expression_roundtrip_all.py") + path = Path(__file__).parent / "expression" / "test_expression_roundtrip_all.py" spec = importlib.util.spec_from_file_location("sqlserver_rt_registry", path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) @@ -1616,9 +1616,25 @@ def registry(self): return registry def test_every_rendered_type_parses_back_coherently(self, registry): - from rhosocial.activerecord.testsuite.utils.parse_contract import ( - parse_roundtrip_failures, + # The testsuite dropped utils/expression in e05edb0 ("round-trip + # coverage moves to the owning repository") and parse_contract died + # with it -- it imported make_instance from the deleted harness. The + # sweep is vendored beside this repository's own harness for the same + # reason the matrix's is: a dead import is a dead test. It lives in the + # expression area now, so it is loaded by path the way the registry + # above is. + import importlib.util + from pathlib import Path + + contract_path = ( + Path(__file__).parent / "expression" / "expression_parse_contract.py" + ) + spec = importlib.util.spec_from_file_location( + "sqlserver_parse_contract", contract_path ) + contract = importlib.util.module_from_spec(spec) + spec.loader.exec_module(contract) + parse_roundtrip_failures = contract.parse_roundtrip_failures dialect = SQLServerDialect(SQL_SERVER_2022) failures = parse_roundtrip_failures( diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlxml_support.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlxml_support.py index 5ead7ce..ba33cf1 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlxml_support.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlxml_support.py @@ -19,4 +19,4 @@ def test_standard_sqlxml_formatters_are_not_exposed(self): dialect = SQLServerDialect((16, 0, 0)) assert not hasattr(dialect, "supports_xmlparse") - assert not hasattr(dialect, "format_xmlparse_expression") + assert not hasattr(dialect, "format_xml_parse_expression") diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_table_type_definition_roundtrip.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_table_type_definition_roundtrip.py index 5401e73..b2cd7b3 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_table_type_definition_roundtrip.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_table_type_definition_roundtrip.py @@ -22,6 +22,10 @@ class used to override ``get_params()`` to always emit the list under import pytest from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.predicates import ( + ComparisonOperator, + ComparisonPredicate, +) from rhosocial.activerecord.backend.expression.serialization import ( deserialize, deserialize_json, @@ -30,10 +34,11 @@ class used to override ``get_params()`` to always emit the list under ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ( ColumnConstraint, - ColumnConstraintType, ColumnDefinition, + ColumnNotNullConstraint, + TableCheckConstraint, TableConstraint, - TableConstraintType, + TablePrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect @@ -43,7 +48,20 @@ class used to override ``get_params()`` to always emit the list under from rhosocial.activerecord.backend.impl.sqlserver.expression.index import ( SQLServerIndexDefinition, ) -from rhosocial.activerecord.testsuite.utils.expression import assert_params_equal +# The harness now lives in the expression area; loaded by path, the way this +# file already loads the round-trip module, because a module imported by name +# would depend on a sys.path entry this run did not promise. +import importlib.util as _importlib_util +from pathlib import Path as _Path + +_harness_path = _Path(__file__).parent / "expression" / "expression_roundtrip_harness.py" +_spec = _importlib_util.spec_from_file_location( + "sqlserver_roundtrip_harness", _harness_path +) +_harness = _importlib_util.module_from_spec(_spec) +_spec.loader.exec_module(_harness) + +assert_params_equal = _harness.assert_params_equal SQL_SERVER_2014 = (12, 0, 0) @@ -59,14 +77,14 @@ def _columns(dialect): dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))], ), ColumnDefinition(dialect, "name", VarCharType(dialect, length=100)), ] def _constraints(dialect): - return [TableConstraint(dialect, TableConstraintType.PRIMARY_KEY, columns=["id"])] + return [TableConstraint(dialect, branch=TablePrimaryKeyConstraint(dialect, ["id"]))] def _round_trip(expr, dialect): @@ -180,15 +198,15 @@ def test_memory_optimized_key_constraint_path_uses_merged_list(self, dialect): columns=[ColumnDefinition(dialect, "id", IntegerType(dialect))], memory_optimized=True, table_constraints=[ - TableConstraint(dialect, TableConstraintType.PRIMARY_KEY, columns=["id"]) + TableConstraint(dialect, branch=TablePrimaryKeyConstraint(dialect, ["id"])) ], ) params = _round_trip(expr, dialect) assert params["constraints"] is None - assert [c.constraint_type for c in params["table_constraints"]] == [ - TableConstraintType.PRIMARY_KEY + assert [type(c.branch) for c in params["table_constraints"]] == [ + TablePrimaryKeyConstraint ] assert expr.to_sql() == ( "AS TABLE ([id] INT, PRIMARY KEY NONCLUSTERED ([id])) " @@ -206,14 +224,18 @@ def test_both_spellings_at_once_still_rejected(self, dialect): ) def test_check_constraint_condition_survives(self, dialect): - condition = Literal(dialect, 1, inline_literals=True) > Literal( - dialect, 0, inline_literals=True - ) - check = TableConstraint( + # A CHECK's condition is a DDL position: no bind parameters, so each + # value is written as text by its own Literal -- the per-position + # decision the class makes, not a class-wide strategy any more. The + # comparison is its own node, not an operator off the value: a value + # node carries no comparison surface. + condition = ComparisonPredicate( dialect, - TableConstraintType.CHECK, - check_condition=condition, + ComparisonOperator.GT, + Literal(dialect, 1, inline=True), + Literal(dialect, 0, inline=True), ) + check = TableConstraint(dialect, branch=TableCheckConstraint(dialect, condition)) expr = SQLServerTableTypeDefinition( dialect, columns=_columns(dialect), diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/basic/transaction/test_async_owner_task.py b/tests/rhosocial/activerecord_sqlserver_test/feature/basic/transaction/test_async_owner_task.py index f50b5a2..a3f97ab 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/basic/transaction/test_async_owner_task.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/basic/transaction/test_async_owner_task.py @@ -1,6 +1,10 @@ # tests/rhosocial/activerecord_sqlserver_test/feature/basic/transaction/test_async_owner_task.py """ -Async contracts for AsyncTransactionManager._owner_task safety warning (SQL Server backend). +Async contract for AsyncTransactionManager._owner_task (SQL Server backend). + +Imports the shared tests: a second asyncio task entering ``transaction()`` +while another task owns an active outermost transaction is refused with +``TransactionError``, and ownership is released when the outermost one ends. """ from rhosocial.activerecord.testsuite.feature.basic.transaction.test_async_owner_task import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/query/test_collation_expression.py b/tests/rhosocial/activerecord_sqlserver_test/feature/query/test_collation_expression.py index 7bd0a0f..4d9eaa5 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/query/test_collation_expression.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/query/test_collation_expression.py @@ -6,6 +6,7 @@ import pytest from rhosocial.activerecord.backend.expression import Column, Literal +from rhosocial.activerecord.backend.expression.collation import collate from rhosocial.activerecord.backend.impl.sqlserver.collation import SQLServerCollation from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect @@ -44,7 +45,11 @@ def test_column_collate_generates_sql(self, dialect): assert params == () def test_literal_collate_preserves_parameter_binding(self, dialect): - expr = Literal(dialect, "Alice").collate(SQLServerCollation.LATIN1_GENERAL_CI_AS) + # A value node carries no operation surface any more (the value waves + # moved collate onto its own node), so the factory is the way in: the + # operand still reaches the server as a bind parameter, which is the + # whole point of the case. + expr = collate(Literal(dialect, "Alice"), SQLServerCollation.LATIN1_GENERAL_CI_AS) sql, params = expr.to_sql() @@ -52,7 +57,10 @@ def test_literal_collate_preserves_parameter_binding(self, dialect): assert params == ("Alice",) def test_database_default_keyword_generates_sql(self, dialect): - expr = Column(dialect, "name").collate("DATABASE_DEFAULT", keyword=True) + # ``DATABASE_DEFAULT`` is a collation name the whitelist carries; the + # keyword flag the constructor used to take is gone with the options + # bag, so it is spelled as the plain string it always was. + expr = Column(dialect, "name").collate("DATABASE_DEFAULT") sql, params = expr.to_sql() @@ -67,10 +75,13 @@ def test_rejects_schema_qualified_collation(self, dialect): with pytest.raises(Exception, match="schema-qualified COLLATE"): expr.to_sql() - def test_rejects_unsupported_keyword(self, dialect): - expr = Column(dialect, "name").collate("CATALOG_DEFAULT", keyword=True) + def test_rejects_unsupported_collation_name(self, dialect): + # What used to be the *keyword* refusal: the options bag is gone, so a + # name outside the whitelist is refused by name -- the same answer the + # plain-name case below gives, through the same validator. + expr = Column(dialect, "name").collate("CATALOG_DEFAULT") - with pytest.raises(ValueError, match="Unsupported SQL Server collation keyword"): + with pytest.raises(ValueError, match="Unsupported SQL Server collation"): expr.to_sql() def test_rejects_unsupported_collation(self, dialect):