diff --git a/.claude/code_style.md b/.claude/code_style.md index 5e9ac4d..231e58f 100644 --- a/.claude/code_style.md +++ b/.claude/code_style.md @@ -630,7 +630,7 @@ class DefaultCustomTimestampMixin(CustomTimestampMixin): 1. **Proper Inheritance**: - All expression classes must inherit from `BaseExpression` - - Value expressions inherit from `SQLValueExpression` + - Value expressions inherit from `SQLValue` - Predicate expressions inherit from `SQLPredicate` - Use appropriate mixins for functionality (e.g., `ArithmeticMixin`, `ComparisonMixin`) @@ -691,7 +691,7 @@ from . import bases, mixins if TYPE_CHECKING: # pragma: no cover from ..dialect import SQLDialectBase -class MyExpression(mixins.ArithmeticMixin, mixins.ComparisonMixin, bases.SQLValueExpression): +class MyExpression(mixins.ArithmeticMixin, mixins.ComparisonMixin, bases.SQLValue): """Represents a custom SQL expression.""" def __init__(self, dialect: "SQLDialectBase", value: str): super().__init__(dialect) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 02d0990..e51b682 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,14 +34,21 @@ jobs: pip install -r requirements.txt pip install -r requirements-dev.txt pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30#egg=rhosocial-activerecord - pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17#egg=rhosocial-activerecord-testsuite + pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates#egg=rhosocial-activerecord-testsuite - name: Install package run: | pip install -e . --no-deps - name: Run tests with coverage (unit only, no Snowflake instance) + # PYTHONPATH=src:tests puts the repository-local plugin + # (``-p plugins.expression_xdist``, registered in pyproject.toml) and + # the scenario providers on the path. pytest resolves ``-p`` before the + # ``pythonpath`` ini option is applied on older 8.x releases, so the + # environment variable -- which is how the sibling backends do it -- + # is the reliable spelling rather than a second path mechanism. run: | + export PYTHONPATH=src:tests pytest tests/ -k "not integration" --ignore=tests/snowflake_fakesnow --tb=short coverage run --source=src -m pytest tests/ -k "not integration" --ignore=tests/snowflake_fakesnow --tb=short coverage xml @@ -78,7 +85,7 @@ jobs: pip install -r requirements.txt pip install -r requirements-dev.txt pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30#egg=rhosocial-activerecord - pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17#egg=rhosocial-activerecord-testsuite + pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates#egg=rhosocial-activerecord-testsuite - name: Install package run: | @@ -86,6 +93,7 @@ jobs: - name: Run tests (unit only, no Snowflake instance) run: | + export PYTHONPATH=src:tests pytest tests/ -k "not integration" --ignore=tests/snowflake_fakesnow --tb=short test-free-threaded: @@ -110,7 +118,7 @@ jobs: pip install -r requirements.txt pip install -r requirements-dev.txt pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30#egg=rhosocial-activerecord - pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17#egg=rhosocial-activerecord-testsuite + pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates#egg=rhosocial-activerecord-testsuite - name: Install package run: | @@ -118,4 +126,5 @@ jobs: - name: Run tests (unit only, no Snowflake instance) run: | + export PYTHONPATH=src:tests pytest tests/ -k "not integration" --ignore=tests/snowflake_fakesnow --tb=short diff --git a/.gitignore b/.gitignore index cb05a12..4bdf6a1 100644 --- a/.gitignore +++ b/.gitignore @@ -157,3 +157,8 @@ tests/config/snowflake_scenarios.yml # Integration tests (contain local development paths and credentials) tests/rhosocial/activerecord_snowflake_test/feature/backend/test_backend_integration.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/CLAUDE.md b/CLAUDE.md index dfd6afe..2bb9ef8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,7 @@ This project is under active development. Key features implemented: - **pyformat placeholders**: `%s` style (snowflake-connector-python uses pyformat) - **PAT authentication**: Programmatic Access Tokens (JWT) for CI/automation - **Warehouse lifecycle**: Virtual warehouses auto-suspend; consider `client_session_keep_alive=True` +- **No `status` subcommand**: this backend has no status introspector (no `AbstractStatusIntrospector` implementation under `introspection/`), so the CLI command list has no `status` — that is an accurate reflection of the library, not a missing wire-up. `info` covers the version and the protocol/capability matrix. If the backends are ever aligned on `status`, the library implementation comes first. ## Version Control and Changelog diff --git a/README.md b/README.md index a103bb9..30ac0bc 100644 --- a/README.md +++ b/README.md @@ -52,12 +52,12 @@ from typing import ClassVar, Optional from rhosocial.activerecord.base import FieldProxy from rhosocial.activerecord.backend.impl.snowflake.backend import SnowflakeBackend from rhosocial.activerecord.backend.impl.snowflake.config import SnowflakeConnectionConfig -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 ( ColumnConstraint, - ColumnConstraintType, ColumnDefinition, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import VarCharType from rhosocial.activerecord.model import ActiveRecord @@ -87,13 +87,15 @@ User.configure( ) dialect = User.__backend__.dialect -User.__backend__.execute(*CreateTableExpression( +User.__backend__.execute(*CreateTableStatement( dialect, Table(dialect, "users"), columns=[ ColumnDefinition( dialect, "id", VarCharType(dialect, 36), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)], + constraints=[ColumnConstraint( + dialect, branch=ColumnPrimaryKeyConstraint(dialect), + )], ), ColumnDefinition(dialect, "name", VarCharType(dialect, 100)), ColumnDefinition(dialect, "email", VarCharType(dialect, 255)), diff --git a/changelog.d/7.added.md b/changelog.d/7.added.md new file mode 100644 index 0000000..f678320 --- /dev/null +++ b/changelog.d/7.added.md @@ -0,0 +1,3 @@ +`table-storage-metrics` on the introspect CLI, with `views NAME`; the type list derives from `_TYPE_CAPABILITIES`. Account objects (databases, schemas, warehouses) are declared as expression statements. + +Account-object introspection for the Snowflake backend: `list_databases()`, `list_schemas(database=None)`, `list_warehouses()` and `get_table_storage_metrics(table_name, schema=None)` on both the sync and async introspectors, with `databases` / `schemas` / `warehouses` added to the `introspect` CLI subcommand. Every statement is built as an expression tree (`SnowflakeDatabaseListQueryStatement`, `SnowflakeSchemaListQueryStatement`, `SnowflakeWarehouseListQueryStatement`, `SnowflakeTableStorageMetricsQueryStatement`) rendered by the dialect's `format_*_query_statement` methods, so no SQL text is assembled by concatenation. `SHOW WAREHOUSES` is used because no `INFORMATION_SCHEMA.WAREHOUSES` view exists. See `examples/introspection_account_objects.py` for a runnable walkthrough. diff --git a/changelog.d/7.changed.md b/changelog.d/7.changed.md new file mode 100644 index 0000000..7babd82 --- /dev/null +++ b/changelog.d/7.changed.md @@ -0,0 +1 @@ +The table kind is the one the catalogue named, `include_views` no longer hides managed tables, and the catalogue's spelling is preserved rather than normalised. diff --git a/changelog.d/7.fixed.md b/changelog.d/7.fixed.md new file mode 100644 index 0000000..bab16d1 --- /dev/null +++ b/changelog.d/7.fixed.md @@ -0,0 +1 @@ +A managed table is no longer dropped from a `views` read, and the storage metrics distinguish "no row for this table" from "this role cannot see the view". diff --git a/changelog.d/7.internal.md b/changelog.d/7.internal.md new file mode 100644 index 0000000..8bd72c5 --- /dev/null +++ b/changelog.d/7.internal.md @@ -0,0 +1 @@ +The CLI tests pin every declared type and serialise each output, 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 f0dff6b..ab6a5f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,7 +78,12 @@ namespaces = true [tool.pytest.ini_options] minversion = "7.0" -addopts = "-ra -q" +addopts = "-ra -q -p plugins.expression_xdist" +# The plugin is addressed as ``plugins.expression_xdist`` because CI puts +# ``tests`` itself on ``PYTHONPATH`` (``export PYTHONPATH=src:tests``), which is +# also what makes the package layout under ``tests/`` importable. Adding +# ``tests`` here as well would arrange a *different* root, and the plugin would +# not resolve at all -- which is what Python 3.8's unit-only leg reported. asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" testpaths = [ diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/backend/async_backend.py b/src/rhosocial/activerecord/backend/impl/snowflake/backend/async_backend.py index 7f6c377..a5d3dd6 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/backend/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/backend/async_backend.py @@ -240,7 +240,7 @@ async def _process_result_set(self, cursor, is_select, column_adapters=None, col ``cursor.description`` returns them as such. Python model field names are lowercase by convention, so we normalise column names to lowercase before building row dicts so that - ``_map_columns_to_fields()`` and ``_remap_row_columns()`` can + ``_map_columns_to_fields()`` and ``remap_row_columns()`` can match them correctly. """ if not is_select: @@ -255,8 +255,8 @@ async def _process_result_set(self, cursor, is_select, column_adapters=None, col mapping = column_mapping or {} 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) final_results.append(final_row) return final_results except Exception as e: diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/backend/backend.py b/src/rhosocial/activerecord/backend/impl/snowflake/backend/backend.py index 19608df..da4be60 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/backend/backend.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/backend/backend.py @@ -276,7 +276,7 @@ def _process_result_set(self, cursor, is_select, column_adapters=None, column_ma ``cursor.description`` returns them as such. Python model field names are lowercase by convention, so we normalise column names to lowercase before building row dicts so that - ``_map_columns_to_fields()`` and ``_remap_row_columns()`` can + ``_map_columns_to_fields()`` and ``remap_row_columns()`` can match them correctly. """ if not is_select: @@ -291,8 +291,8 @@ def _process_result_set(self, cursor, is_select, column_adapters=None, column_ma mapping = column_mapping or {} 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) final_results.append(final_row) return final_results except Exception as e: diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/cli/README.md b/src/rhosocial/activerecord/backend/impl/snowflake/cli/README.md new file mode 100644 index 0000000..01eaf67 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/snowflake/cli/README.md @@ -0,0 +1,105 @@ +# `introspect` — Snowflake metadata, from the command line + +The `introspect` subcommand asks a live Snowflake account 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.snowflake introspect [NAME] [OPTIONS] +``` + +## What it can ask about + +| Type | `NAME` | Reads | Notes | +| :--- | :--- | :--- | :--- | +| `tables` | — | `information_schema.tables` | Tables, with the kind the catalogue reports and their comment. | +| `views` | view, optional | `information_schema.views` | Views, with their definitions; one view when named. | +| `columns` | table, required | `information_schema.columns` | One table's columns: name, type, nullability, ordinal position, default. | +| `indexes` | table, required | `information_schema.table_constraints`, `key_column_usage` | Constraints and the columns each side names. | +| `foreign-keys` | table, required | `information_schema.referential_constraints` | Referential constraints. | +| `table-storage-metrics` | table, required | `information_schema.table_storage_metrics`, `information_schema.tables` | One table's billed storage: active, time travel, failsafe and clone-retained bytes, plus the row count, clustering key and kind `tables` reports. | +| `databases` | — | `information_schema.databases` | The databases in the account, with owner and transient flag. | +| `schemas` | — | `information_schema.schemata` | The schemas in a database — Snowflake's namespace level above a table. `--database` names the database to enumerate. | +| `warehouses` | — | `SHOW WAREHOUSES` | Warehouses with size, state and auto-resume. This is `SHOW`, not an `information_schema` view, because no such view exists. | +| `database` | — | `CURRENT_DATABASE()`, `CURRENT_VERSION()` | The database this session is in and the account's version. | + +`NAME` is required for `columns`, `indexes`, `foreign-keys` and +`table-storage-metrics`. For `views` it is optional: a name asks for that one +view, and a name the catalogue does not have exits 1. The list types — +`tables`, `database`, `databases`, `schemas` and `warehouses` — take no name, +and a stray one is refused (exit 2) rather than ignored: this backend has no +`get_*_info` method that could answer for a single database, schema or +warehouse. `schemas --database analytics` is unaffected — `--database` says +which database's schemas to enumerate, not which schema to fetch. + +Exit codes follow that split: **1** is a runtime failure (connection, query, +unexpected) and also "the named view or table is not there"; **2** is a request +this backend cannot serve — a type whose capability switch is off, or a `NAME` +on a list type. + +Two absences worth naming: + +* **No `triggers` type.** Snowflake has no triggers, so there is no object to + ask about. That is an engine fact, not a gap in the CLI. +* **No `partition-metadata` type.** The dialect carries a formatter for it, + but no introspector method reads it, so there is no answer behind the type. + +`table-storage-metrics` is a type of its own rather than a column of `tables` +for two reasons. Its statement joins two views, so folding it into the listing +would charge every list for that join. And `TABLE_STORAGE_METRICS` returns no +rows for roles other than `ACCOUNTADMIN`, so an empty answer means "no metrics +for this role" — a `None` the command can state out loud, which a column on the +listing could not. An exit of 1 on this type is therefore **not** "the table +does not exist". + +## Options + +| Option | Default | Description | +| :--- | :--- | :--- | +| `-o, --output` | `table` | `table` (rich), `json`, `csv`, `tsv`. | +| `--account` | `SNOWFLAKE_ACCOUNT` | Account identifier. | +| `--host`, `--port` | _None_ | Connection endpoint, when not using the account locator. | +| `--database` | `SNOWFLAKE_DATABASE` | Database to connect to. | +| `--schema` | `SNOWFLAKE_SCHEMA` | Schema to connect to; introspection reads it. Defaults to `PUBLIC`. | +| `--warehouse` | `SNOWFLAKE_WAREHOUSE` | Warehouse to run queries on. | +| `--role` | `SNOWFLAKE_ROLE` | Role to assume. | +| `--user`, `--password` | env | Credentials. | +| `--authenticator` | _None_ | How the user is authenticated. | +| `--use-async` | off | Drive the asynchronous backend. | + +## Notes + +* **Read-only.** `introspect` never writes. It does not create, drop, or + alter anything, so it is safe to run against an account whose schema + matters. +* **The warehouse matters.** Snowflake needs one to run a query. If the role's + default warehouse is not set, pass `--warehouse`; a missing warehouse is a + connection failure, not an empty answer. +* **`SHOW WAREHOUSES` has no `information_schema` counterpart.** It also + caps its output, and its column names come back lower case — both are + recorded next to the query in the source rather than assumed here. +* **Nothing here was run against a live account.** The statements are written + from the official documentation (cited beside each one in the source). + Verify the first run against your account before relying on the shapes. +* **One question per invocation.** A CLI process is one connection that + opens, answers, and closes — there is no cache carried between runs. +* **No `status` subcommand.** This backend implements no status + introspector — there is no `AbstractStatusIntrospector` implementation + under `introspection/` — so the CLI has no `status` command to wire up. + That is a missing library capability, not a forgotten command: `info` + reports the version and the protocol/capability matrix instead. Aligning + the backends on `status` starts in the library, not here. + +## Examples + +```bash +# Account-level objects, which Snowflake has and most engines do not +python -m rhosocial.activerecord.backend.impl.snowflake introspect databases +python -m rhosocial.activerecord.backend.impl.snowflake introspect schemas --database analytics +python -m rhosocial.activerecord.backend.impl.snowflake introspect warehouses -o json + +# One table's storage facts +python -m rhosocial.activerecord.backend.impl.snowflake introspect table-storage-metrics orders --schema public --database analytics +``` diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/cli/introspect.py b/src/rhosocial/activerecord/backend/impl/snowflake/cli/introspect.py index b341482..f27ae81 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/cli/introspect.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/cli/introspect.py @@ -8,27 +8,111 @@ from enum import Enum from typing import Any -from rhosocial.activerecord.backend.impl.snowflake.backend import SnowflakeBackend -from rhosocial.activerecord.backend.impl.snowflake.backend.async_backend import AsyncSnowflakeBackend from rhosocial.activerecord.backend.errors import ConnectionError, QueryError +from rhosocial.activerecord.backend.impl.snowflake.backend import SnowflakeBackend +from rhosocial.activerecord.backend.impl.snowflake.backend.async_backend import ( + AsyncSnowflakeBackend, +) + 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", "database" -] -# Note: "triggers" is intentionally excluded because Snowflake does not support triggers. +OUTPUT_CHOICES = ["table", "json", "csv", "tsv"] + +#: CLI type -> the dialect capability switch that answers for it. +#: +#: The advertised list is derived from this mapping, so a family cannot be +#: answerable and unaskable at the same time -- adding a type here without +#: naming its switch is the only way to get a type, and the tests refuse one +#: whose switch the dialect does not have. Every switch here is a constant on +#: this dialect (Snowflake's introspection answers do not vary by server +#: version), so the mapping constrains what can be asked rather than refusing +#: at runtime; it is still consulted, so that a dialect declaring a family +#: unsupported is answered with "not supported here" instead of an empty list. +_TYPE_CAPABILITIES = { + "tables": "supports_table_introspection", + "views": "supports_view_introspection", + "columns": "supports_column_introspection", + "indexes": "supports_index_introspection", + "foreign-keys": "supports_foreign_key_introspection", + "database": "supports_database_info", + "databases": "supports_database_introspection", + "schemas": "supports_schema_introspection", + "warehouses": "supports_warehouse_introspection", + "table-storage-metrics": "supports_table_storage_metrics", +} + +INTROSPECT_TYPES = list(_TYPE_CAPABILITIES) + +# Note: "triggers" is intentionally excluded because Snowflake does not +# support triggers -- the engine has no such object, so there is no question +# to ask. "partition-metadata" is excluded for a different reason: the dialect +# has a formatter for it, but no introspector method reads it, so there is no +# answer behind the type. + +#: Types that answer for a whole family (or for the session's context), not +#: for one named object. A NAME given to one of them is refused rather than +#: ignored: the parser accepts the positional for the types that need it, so +#: silently dropping it would claim a filtering the command never performed. +_NO_NAME_TYPES = ("tables", "database", "databases", "schemas", "warehouses") + + +def _require_capability(backend, args) -> None: + """Refuse a type the connected dialect says it cannot answer. + + Exit code 2, not 1: the request is well formed and the connection is + fine -- this server simply has no answer to it. Every Snowflake switch + is a constant today, so this refuses nothing in practice; it is the + runtime half of :data:`_TYPE_CAPABILITIES`, and what keeps a future + switch that starts answering ``False`` from being ignored. + """ + switch = _TYPE_CAPABILITIES.get(args.type) + 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: {args.type} introspection is not supported by this server", + file=sys.stderr, + ) + sys.exit(2) + + +def _refuse_name(args) -> None: + """Refuse a NAME on a type that does not take one. + + Exit code 2: the type exists, the name exists, but the pair does not -- + ``introspect databases analytics`` is not a narrower ``databases``, it + is a request this backend has no method for (there is no + ``get_database_info`` by name, no ``get_schema_info``, no + ``get_warehouse_info``). Refusing is the only answer that does not + pretend the name changed the output. + + ``schemas --database analytics`` is not this check: ``--database`` says + *which database's* schemas to enumerate, which is a different question + from naming one schema, and it stays untouched. + """ + if args.type not in _NO_NAME_TYPES or args.name is None: + return + print( + f"Error: '{args.type}' introspection takes no name (got '{args.name}')", + file=sys.stderr, + ) + sys.exit(2) def create_parser(subparsers): """Create the introspect subcommand parser.""" parser = subparsers.add_parser( - 'introspect', - help='Database introspection', + "introspect", + help="Database introspection", epilog="""Examples: # List all tables in schema %(prog)s tables --account myaccount --database mydb @@ -36,11 +120,23 @@ def create_parser(subparsers): # List all views %(prog)s views --account myaccount --database mydb - # Get detailed table info (columns, indexes, foreign keys) - %(prog)s table users --account myaccount --database mydb - - # Get column details for a table + # Get column details, indexes and foreign keys for a table %(prog)s columns users --account myaccount --database mydb + %(prog)s indexes users --account myaccount --database mydb + %(prog)s foreign-keys users --account myaccount --database mydb + + # One table's billed storage: active, time travel, failsafe and + # clone-retained bytes, with its row count and clustering key + %(prog)s table-storage-metrics users --account myaccount --database mydb + + # One view, by name + %(prog)s views v_users --account myaccount --database mydb + + # List the databases visible to the role, the schemas of a database, + # and the virtual warehouses + %(prog)s databases --account myaccount + %(prog)s schemas --account myaccount --database mydb + %(prog)s warehouses --account myaccount # Output as JSON %(prog)s tables --account myaccount --database mydb -o json @@ -48,41 +144,46 @@ def create_parser(subparsers): # Using environment variables for connection export SNOWFLAKE_ACCOUNT=myaccount SNOWFLAKE_DATABASE=mydb SNOWFLAKE_USER=admin SNOWFLAKE_PASSWORD=secret %(prog)s tables + +An empty table-storage-metrics answer is not a missing table. The view behind +it returns no rows for roles other than ACCOUNTADMIN, and the introspector +cannot tell "the table is gone" from "your role cannot see its metrics", so +both exit 1 with the same message. """, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( - '-o', '--output', + "-o", + "--output", choices=OUTPUT_CHOICES, - default='table', - help='Output format (default: table)', + default="table", + help="Output format (default: table)", ) add_connection_args(parser) parser.add_argument( - '--rich-ascii', - action='store_true', - help='Use ASCII characters for rich table borders.', + "--rich-ascii", + action="store_true", + help="Use ASCII characters for rich table borders.", ) parser.add_argument( "type", choices=INTROSPECT_TYPES, - help="Introspection type: tables, views, table, columns, indexes, foreign-keys, database", + help="Introspection type: " + ", ".join(INTROSPECT_TYPES), ) parser.add_argument( "name", nargs="?", default=None, - help="Table/view name (required for some types)", - ) - parser.add_argument( - "--introspect-schema", - dest="introspect_schema", - default=None, - help="Schema name for introspection (default: PUBLIC)", + help=( + "Table name for columns, indexes, foreign-keys and " + "table-storage-metrics (required); view name for views " + "(optional); refused for tables, database, databases, schemas " + "and warehouses" + ), ) parser.add_argument( "--include-system", @@ -101,6 +202,8 @@ def handle(args): print("Error: --account and/or --database is required for introspection", file=sys.stderr) sys.exit(1) + _refuse_name(args) + config = resolve_connection_config_from_args(args) if args.use_async: @@ -115,13 +218,14 @@ def handle(args): # Internal helper functions # --------------------------------------------------------------------------- + def _serialize_for_output(obj: Any) -> Any: """Serialize object for JSON output, handling non-serializable types.""" if obj is None: return None - if hasattr(obj, 'model_dump'): + if hasattr(obj, "model_dump"): try: - result = obj.model_dump(mode='json') + result = obj.model_dump(mode="json") return _serialize_for_output(result) except TypeError: result = obj.model_dump() @@ -144,41 +248,32 @@ def _handle_introspect_sync(args, backend: SnowflakeBackend, provider): try: backend.connect() + _require_capability(backend, args) + introspector = backend.introspector if args.type == "tables": - tables = introspector.list_tables( - schema=args.introspect_schema, - include_system=args.include_system - ) + tables = introspector.list_tables(schema=None, include_system=args.include_system) data = _serialize_for_output(tables) provider.display_results(data, title="Tables") elif args.type == "views": - views = introspector.list_views(schema=args.introspect_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) - info = introspector.get_table_info(args.name, schema=args.introspect_schema) - if info: - provider.display_results(_serialize_for_output(info.columns), title=f"Columns of {args.name}") - if info.indexes: - provider.display_results(_serialize_for_output(info.indexes), title=f"Indexes of {args.name}") - if info.foreign_keys: - provider.display_results(_serialize_for_output(info.foreign_keys), title=f"Foreign Keys of {args.name}") + if args.name: + view = introspector.get_view_info(args.name, schema=None) + if view is None: + print(f"Error: View '{args.name}' not found", file=sys.stderr) + sys.exit(1) + provider.display_results(_serialize_for_output([view]), title=f"View {args.name}") else: - print(f"Error: Table '{args.name}' not found", file=sys.stderr) - sys.exit(1) + views = introspector.list_views(schema=None) + data = _serialize_for_output(views) + provider.display_results(data, title="Views") 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.list_columns(args.name, schema=args.introspect_schema) + columns = introspector.list_columns(args.name, schema=None) data = _serialize_for_output(columns) provider.display_results(data, title=f"Columns of {args.name}") @@ -186,23 +281,65 @@ def _handle_introspect_sync(args, backend: SnowflakeBackend, provider): if not args.name: print("Error: Table name is required for 'indexes' introspection", file=sys.stderr) sys.exit(1) - indexes = introspector.list_indexes(args.name, schema=args.introspect_schema) + indexes = introspector.list_indexes(args.name, schema=None) 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) + print( + "Error: Table name is required for 'foreign-keys' introspection", + file=sys.stderr, + ) sys.exit(1) - fks = introspector.list_foreign_keys(args.name, schema=args.introspect_schema) + fks = introspector.list_foreign_keys(args.name, schema=None) data = _serialize_for_output(fks) provider.display_results(data, title=f"Foreign Keys of {args.name}") + elif args.type == "table-storage-metrics": + if not args.name: + print( + "Error: Table name is required for 'table-storage-metrics' introspection", + file=sys.stderr, + ) + sys.exit(1) + metrics = introspector.get_table_storage_metrics(args.name, schema=None) + if metrics is None: + # None means "no metrics row", which is not "no table": the + # view returns no rows for roles other than ACCOUNTADMIN, so + # an absent answer cannot be read as a missing object. + print( + f"Error: Table '{args.name}' has no storage metrics", + file=sys.stderr, + ) + sys.exit(1) + provider.display_results( + _serialize_for_output([metrics]), + title=f"Storage Metrics of {args.name}", + ) + elif args.type == "database": info = introspector.get_database_info() data = _serialize_for_output(info) provider.display_results([data], title="Database Info") + elif args.type == "databases": + databases = introspector.list_databases() + data = _serialize_for_output(databases) + provider.display_results(data, title="Databases") + + elif args.type == "schemas": + # SCHEMATA is a database-level view: --database names the + # database to enumerate, or None for the session's current one. + schemas = introspector.list_schemas(database=args.database) + data = _serialize_for_output(schemas) + provider.display_results(data, title="Schemas") + + elif args.type == "warehouses": + warehouses = introspector.list_warehouses() + data = _serialize_for_output(warehouses) + provider.display_results(data, title="Warehouses") + except ConnectionError as e: provider.display_connection_error(e) sys.exit(1) @@ -225,38 +362,27 @@ async def _handle_introspect_async(args, backend: AsyncSnowflakeBackend, provide introspector = backend.introspector if args.type == "tables": - tables = await introspector.list_tables( - schema=args.introspect_schema, - include_system=args.include_system - ) + tables = await introspector.list_tables(schema=None, include_system=args.include_system) data = _serialize_for_output(tables) provider.display_results(data, title="Tables") elif args.type == "views": - views = await introspector.list_views(schema=args.introspect_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) - info = await introspector.get_table_info(args.name, schema=args.introspect_schema) - if info: - provider.display_results(_serialize_for_output(info.columns), title=f"Columns of {args.name}") - if info.indexes: - provider.display_results(_serialize_for_output(info.indexes), title=f"Indexes of {args.name}") - if info.foreign_keys: - provider.display_results(_serialize_for_output(info.foreign_keys), title=f"Foreign Keys of {args.name}") + if args.name: + view = await introspector.get_view_info(args.name, schema=None) + if view is None: + print(f"Error: View '{args.name}' not found", file=sys.stderr) + sys.exit(1) + provider.display_results(_serialize_for_output([view]), title=f"View {args.name}") else: - print(f"Error: Table '{args.name}' not found", file=sys.stderr) - sys.exit(1) + views = await introspector.list_views(schema=None) + data = _serialize_for_output(views) + provider.display_results(data, title="Views") elif args.type == "columns": if not args.name: print("Error: Table name is required for 'columns' introspection", file=sys.stderr) sys.exit(1) - columns = await introspector.list_columns(args.name, schema=args.introspect_schema) + columns = await introspector.list_columns(args.name, schema=None) data = _serialize_for_output(columns) provider.display_results(data, title=f"Columns of {args.name}") @@ -264,23 +390,64 @@ async def _handle_introspect_async(args, backend: AsyncSnowflakeBackend, provide if not args.name: print("Error: Table name is required for 'indexes' introspection", file=sys.stderr) sys.exit(1) - indexes = await introspector.list_indexes(args.name, schema=args.introspect_schema) + indexes = await introspector.list_indexes(args.name, schema=None) 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) + print( + "Error: Table name is required for 'foreign-keys' introspection", + file=sys.stderr, + ) sys.exit(1) - fks = await introspector.list_foreign_keys(args.name, schema=args.introspect_schema) + fks = await introspector.list_foreign_keys(args.name, schema=None) data = _serialize_for_output(fks) provider.display_results(data, title=f"Foreign Keys of {args.name}") + elif args.type == "table-storage-metrics": + if not args.name: + print( + "Error: Table name is required for 'table-storage-metrics' introspection", + file=sys.stderr, + ) + sys.exit(1) + metrics = await introspector.get_table_storage_metrics(args.name, schema=None) + if metrics is None: + # Same reading as the sync handler: an absent row is "no + # metrics for this role", not "no such table". + print( + f"Error: Table '{args.name}' has no storage metrics", + file=sys.stderr, + ) + sys.exit(1) + provider.display_results( + _serialize_for_output([metrics]), + title=f"Storage Metrics of {args.name}", + ) + elif args.type == "database": info = await introspector.get_database_info() data = _serialize_for_output(info) provider.display_results([data], title="Database Info") + elif args.type == "databases": + databases = await introspector.list_databases() + data = _serialize_for_output(databases) + provider.display_results(data, title="Databases") + + elif args.type == "schemas": + # SCHEMATA is a database-level view: --database names the + # database to enumerate, or None for the session's current one. + schemas = await introspector.list_schemas(database=args.database) + data = _serialize_for_output(schemas) + provider.display_results(data, title="Schemas") + + elif args.type == "warehouses": + warehouses = await introspector.list_warehouses() + data = _serialize_for_output(warehouses) + provider.display_results(data, title="Warehouses") + except ConnectionError as e: provider.display_connection_error(e) sys.exit(1) diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/collation.py b/src/rhosocial/activerecord/backend/impl/snowflake/collation.py index 9a78809..0f96fca 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/collation.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/collation.py @@ -7,8 +7,14 @@ from typing import Optional, Tuple -class SnowflakeCollation(Enum): +class SnowflakeCollation(str, Enum): """Common Snowflake collation specs for expression-level COLLATE.""" + #: A member *is* the word it carries: ``str()`` and f-string + #: interpolation must yield the value, not ``ClassName.MEMBER``, or the + #: rendered SQL and the artifact would carry the Python spelling. + #: ``(str, Enum)`` alone does not give that -- ``Enum.__str__`` wins -- + #: so it is stated here once. + __str__ = str.__str__ EN = "en" EN_CI = "en-ci" diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/dialect.py b/src/rhosocial/activerecord/backend/impl/snowflake/dialect.py index 51d16c9..a47fad3 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/dialect.py @@ -15,8 +15,8 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.transaction import ( - BeginTransactionExpression, - SetTransactionExpression, + BeginTransactionStatement, + SetTransactionStatement, ) from rhosocial.activerecord.backend.dialect.base import SQLDialectBase @@ -174,15 +174,34 @@ SnowflakeSetOperationMixin, SnowflakeDQLMixin, SnowflakeCapabilityMixin, + SnowflakeConstraintMixin, SnowflakeILIKEMixin, SnowflakeTrimMixin, SnowflakeGeneratedColumnMixin, SnowflakeOrderedSetAggregationMixin, SnowflakeTruncateMixin, + SnowflakeWildcardMixin, + SnowflakeLiteralValueMixin, ) class SnowflakeDialect( + # 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). + # Ahead of ``SQLDialectBase``, unlike every other mixin here. Those add + # methods the base does not have, so their position is a readability + # choice; this one overrides ``format_literal``, and a mixin placed after + # the class it overrides never wins. It resolves Snowflake's two tier-2 + # spellings -- binary and UUID -- and leaves the base's portable forms and + # the literal-construction protocol to it. INTERVAL has no branch there on + # purpose, so the base's explicit failure is the answer. + SnowflakeLiteralValueMixin, SQLDialectBase, RelationSourceMixin, # The whole naming side, stated once: which namespace levels a name may @@ -221,9 +240,18 @@ class SnowflakeDialect( SnowflakeSetOperationMixin, SnowflakeDQLMixin, SnowflakeCapabilityMixin, + # Constraint capabilities: every answer core's constraint probes and + # renderers consult, declared in this package. Placed well before the core + # ``ConstraintMixin`` / ``DDLColumnMixin`` (below) so the local answers win + # over the shared defaults. + SnowflakeConstraintMixin, SnowflakeILIKEMixin, SnowflakeGeneratedColumnMixin, SnowflakeOrderedSetAggregationMixin, + # The wildcard split: core gives each asterisk position its own formatter, + # and Snowflake spells both of them with an optional filter. Placed before + # the core ``ExpressionMixin`` (below) so its overrides win. + SnowflakeWildcardMixin, # TRIM/LPAD/RPAD/REPEAT are nodes with default formatters. LPAD, RPAD and # REPEAT are the shared spellings verbatim on Snowflake, so the generic # mixins are the answer for those three; TRIM differs and is overridden by @@ -406,12 +434,12 @@ def supports_dynamic_identifier(self) -> bool: """Snowflake supports IDENTIFIER() dynamic binding.""" return True - def format_identifier_dynamic(self, identifier: str) -> str: + def format_snowflake_identifier_expression(self, identifier: str) -> str: """Format an IDENTIFIER(placeholder) dynamic object reference.""" placeholder = self.get_parameter_placeholder() return f"IDENTIFIER({placeholder})" - def format_begin_transaction(self, expr: "BeginTransactionExpression") -> Tuple[str, tuple]: + def format_begin_transaction_statement(self, expr: "BeginTransactionStatement") -> Tuple[str, tuple]: """Format BEGIN TRANSACTION for Snowflake, consuming its mode pairs. Snowflake's BEGIN takes no transaction characteristics; in particular @@ -443,9 +471,9 @@ def format_begin_transaction(self, expr: "BeginTransactionExpression") -> Tuple[ "BEGIN TRANSACTION NOT DEFERRABLE", suggestion="Snowflake does not support DEFERRABLE transactions.", ) - return super().format_begin_transaction(expr) + return super().format_begin_transaction_statement(expr) - def format_set_transaction(self, expr: "SetTransactionExpression") -> Tuple[str, tuple]: + def format_set_transaction_statement(self, expr: "SetTransactionStatement") -> Tuple[str, tuple]: """Format SET TRANSACTION statement for Snowflake. Snowflake only supports READ COMMITTED isolation level, so diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/examples/introspection_account_objects.py b/src/rhosocial/activerecord/backend/impl/snowflake/examples/introspection_account_objects.py new file mode 100644 index 0000000..1fee0ea --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/snowflake/examples/introspection_account_objects.py @@ -0,0 +1,284 @@ +# src/rhosocial/activerecord/backend/impl/snowflake/examples/introspection_account_objects.py +"""Introspect Snowflake account objects: databases, schemas, warehouses. + +Run against a real account:: + + python -m rhosocial.activerecord.backend.impl.snowflake.examples.introspection_account_objects + python -m rhosocial.activerecord.backend.impl.snowflake.examples.introspection_account_objects ORDERS + +The optional argument is a table name for the storage-metrics section; when +it is omitted the example picks the first table in the configured schema. + +Credentials come from the same environment variables the test suite reads +(``tests/integration/conftest.py`` and ``tests/providers/scenarios.py``):: + + SNOWFLAKE_ACCOUNT required + SNOWFLAKE_USER required + SNOWFLAKE_PASSWORD required unless SNOWFLAKE_PRIVATE_KEY_PATH is set + SNOWFLAKE_PRIVATE_KEY_PATH alternative to a password + SNOWFLAKE_DATABASE required (SCHEMATA and TABLE_STORAGE_METRICS are + database-level views) + SNOWFLAKE_WAREHOUSE required (Information Schema views need a running, + in-use warehouse) + SNOWFLAKE_SCHEMA optional, default PUBLIC + SNOWFLAKE_ROLE optional, passed only when set + +With no credentials the example prints exactly which variables are missing, +exits with status 2, and contacts nothing: no connection is opened, no +statement is built, and no row is invented. A partial environment -- an +account and a user but no password and no key, say -- is refused the same way +rather than half-run. + +**Nothing in this file was run against a Snowflake account.** The repo has +none. The example is documentation of the intended usage; the mocked tests +cover the SQL, the parsing and this refusal path. + +The cache demonstration +----------------------- +Every section runs the same question twice and reports how many statements +actually reached the executor on the cold and the warm call. The cache is +part of the introspector, so the only honest way for an example to observe +it is to wrap the executor: a warm call must ask the account **zero** +questions. ``available`` and other fields are not cached separately -- +each method's answer is one cache entry, so "cold=1, warm=0" is the whole +claim. +""" + +import os +import sys +from typing import Any, Callable, List, Optional, Tuple, TypeVar + +from ..backend import SnowflakeBackend +from ..config import SnowflakeConnectionConfig + +_T = TypeVar("_T") + +#: Environment variables that have no default and cannot be guessed. +_REQUIRED_SETTINGS = ( + "SNOWFLAKE_ACCOUNT", + "SNOWFLAKE_USER", + "SNOWFLAKE_DATABASE", + "SNOWFLAKE_WAREHOUSE", +) + +_SETUP_HELP = """\ +No Snowflake credentials found in this environment; nothing was queried. + +Set these environment variables (the same ones the test suite reads): + SNOWFLAKE_ACCOUNT=your-account + SNOWFLAKE_USER=your-user + SNOWFLAKE_PASSWORD=your-password # or SNOWFLAKE_PRIVATE_KEY_PATH=/path/to/key.p8 + SNOWFLAKE_DATABASE=your-database + SNOWFLAKE_WAREHOUSE=your-warehouse + +Optional: + SNOWFLAKE_SCHEMA=PUBLIC # default: PUBLIC + SNOWFLAKE_ROLE=SYSADMIN # passed only when set + +The database is required because SCHEMATA and TABLE_STORAGE_METRICS are +database-level views; the warehouse is required because Information Schema +views must be queried through a running, in-use warehouse. +""" + + +class _CountingExecutor: + """The introspector's executor, with a statement counter in front of it. + + Deliberately minimal: it delegates the one method the introspector calls + and counts it. A warm cache hit must leave :attr:`questions` unchanged, + which is the property the example prints. + """ + + def __init__(self, inner: Any) -> None: + self._inner = inner + self.questions = 0 + + def execute(self, sql: str, params: Tuple = ()) -> List[dict]: + self.questions += 1 + return self._inner.execute(sql, params) + + +def _missing_settings() -> List[str]: + """Return the required settings this environment does not provide.""" + missing = [name for name in _REQUIRED_SETTINGS if not os.getenv(name)] + if not os.getenv("SNOWFLAKE_PASSWORD") and not os.getenv( + "SNOWFLAKE_PRIVATE_KEY_PATH" + ): + missing.append("SNOWFLAKE_PASSWORD (or SNOWFLAKE_PRIVATE_KEY_PATH)") + return missing + + +def _config_from_env() -> Optional[SnowflakeConnectionConfig]: + """Build a connection config from the test suite's environment variables.""" + missing = _missing_settings() + if missing: + print(_SETUP_HELP, file=sys.stderr) + print("Missing here: " + ", ".join(missing), file=sys.stderr) + return None + + return SnowflakeConnectionConfig( + account=os.environ["SNOWFLAKE_ACCOUNT"], + username=os.environ["SNOWFLAKE_USER"], + password=os.getenv("SNOWFLAKE_PASSWORD", ""), + private_key_path=os.getenv("SNOWFLAKE_PRIVATE_KEY_PATH"), + database=os.environ["SNOWFLAKE_DATABASE"], + schema=os.getenv("SNOWFLAKE_SCHEMA", "PUBLIC"), + warehouse=os.environ["SNOWFLAKE_WAREHOUSE"], + role=os.getenv("SNOWFLAKE_ROLE"), + ) + + +def _wrap(executor: Any) -> Tuple["_CountingExecutor", Optional[Any]]: + """Put a counter in front of *executor*, keeping the original. + + Returns the counting wrapper and the executor it stands in for, so the + caller can restore the real one afterwards. Both are ``None`` only if + *executor* is missing, which would mean there is no driver to count. + """ + counter = _CountingExecutor(executor) + return counter, executor + + +def _ask(counter: _CountingExecutor, call: Callable[[], _T]) -> Tuple[int, _T]: + """Run *call* and return how many SQL statements it cost.""" + before = counter.questions + value = call() + return counter.questions - before, value + + +def _print_databases(databases: List[Any]) -> None: + for database in databases: + print( + f" {database.name}" + f" type={database.database_type or '-'}" + f" owner={database.owner or '-'}" + f" transient={'yes' if database.is_transient else 'no'}" + ) + + +def _print_schemas(schemas: List[Any]) -> None: + for schema in schemas: + print( + f" {schema.name}" + f" owner={schema.owner or '-'}" + f" managed_access={'yes' if schema.is_managed_access else 'no'}" + f" transient={'yes' if schema.is_transient else 'no'}" + ) + + +def _print_warehouses(warehouses: List[Any]) -> None: + for warehouse in warehouses: + current = " <- current" if warehouse.is_current else "" + print( + f" {warehouse.name}" + f" state={warehouse.state or '-'}" + f" size={warehouse.size or '-'}" + f" clusters={warehouse.started_clusters or 0}/" + f"{warehouse.max_cluster_count or 0}{current}" + ) + + +def _print_storage_metrics(table_name: str, metrics: Any) -> None: + if metrics is None: + print( + f" {table_name}: no metrics row. The view is documented to return no\n" + " rows for roles other than ACCOUNTADMIN, so this can mean either\n" + " 'no such table' or 'this role may not see the metrics'." + ) + return + print(f" {metrics.table_name} ({metrics.table_schema or '?'})") + print(f" table type : {metrics.table_type or '-'}") + print(f" row count : {metrics.row_count if metrics.row_count is not None else 'not reported'}") + print(f" active bytes : {metrics.active_bytes}") + print(f" time travel bytes: {metrics.time_travel_bytes}") + print(f" failsafe bytes : {metrics.failsafe_bytes}") + print(f" clone-retained : {metrics.retained_for_clone_bytes}") + print(f" total billed : {metrics.total_billed_bytes}") + print(f" clustering key : {metrics.clustering_key or 'none'}") + print( + " auto clustering : " + + ("on" if metrics.auto_clustering_on else "off") + ) + + +def main(argv: Optional[List[str]] = None) -> int: + """Run the example. Returns a process exit code.""" + arguments = list(sys.argv[1:] if argv is None else argv) + config = _config_from_env() + if config is None: + return 2 + + requested_table = arguments[0] if arguments else None + backend = SnowflakeBackend(connection_config=config) + + introspector = None + inner: Optional[Any] = None + try: + backend.connect() + introspector = backend.introspector + counter, inner = _wrap(introspector._executor) + # Replacing the executor is the example's observation hook; the cache + # itself is not instrumented, so a warm call reaching it must be + # visible as one fewer statement. The real executor is put back before + # the backend is disconnected, so a failed run leaves no observer + # between the introspector and the driver. + introspector._executor = counter + introspector.clear_cache() + + print("Databases") + cold, databases = _ask(counter, introspector.list_databases) + warm, _ = _ask(counter, introspector.list_databases) + _print_databases(databases) + print(f" ({len(databases)} row(s); executor questions cold={cold} warm={warm})") + + print("\nSchemas") + cold, schemas = _ask(counter, introspector.list_schemas) + warm, _ = _ask(counter, introspector.list_schemas) + _print_schemas(schemas) + print(f" ({len(schemas)} row(s); executor questions cold={cold} warm={warm})") + + print("\nWarehouses") + cold, warehouses = _ask(counter, introspector.list_warehouses) + warm, _ = _ask(counter, introspector.list_warehouses) + _print_warehouses(warehouses) + print(f" ({len(warehouses)} row(s); executor questions cold={cold} warm={warm})") + + table_name = requested_table + if table_name is None: + tables = introspector.list_tables() + table_name = tables[0].name if tables else None + + print("\nTable storage metrics") + if table_name is None: + print( + " No table to measure: the configured schema has no tables.\n" + " Pass a table name as the first argument to measure one." + ) + else: + cold, metrics = _ask( + counter, + lambda: introspector.get_table_storage_metrics(table_name), + ) + warm, _ = _ask( + counter, + lambda: introspector.get_table_storage_metrics(table_name), + ) + _print_storage_metrics(table_name, metrics) + print(f" (executor questions cold={cold} warm={warm})") + + except Exception as exc: # noqa: BLE001 -- a runnable example reports, not raises + print(f"Introspection failed: {type(exc).__name__}: {exc}", file=sys.stderr) + return 1 + finally: + # Restore only what was actually replaced. + if introspector is not None and inner is not None: + introspector._executor = inner + # The backend may never have connected; disconnect only when it did. + if getattr(backend, "_connection", None): + backend.disconnect() + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/examples/named_procedures/etl_workflow.py b/src/rhosocial/activerecord/backend/impl/snowflake/examples/named_procedures/etl_workflow.py index 9e0ab75..9ffa829 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/examples/named_procedures/etl_workflow.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/examples/named_procedures/etl_workflow.py @@ -8,7 +8,7 @@ Usage: from rhosocial.activerecord.backend.impl.snowflake.examples.named_procedures.etl_workflow import DataLoadProcedure from rhosocial.activerecord.backend.impl.snowflake.backend import SnowflakeBackend - from rhosocial.activerecord.backend.named_query.procedure import ProcedureRunner + from rhosocial.activerecord.backend.named_expression import ProcedureRunner backend = SnowflakeBackend(connection_config=config) backend.connect() @@ -22,7 +22,7 @@ """ from typing import Dict, Any -from rhosocial.activerecord.backend.named_query.procedure import Procedure, ProcedureContext +from rhosocial.activerecord.backend.named_expression import Procedure, ProcedureContext class DataLoadProcedure(Procedure): diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/examples/named_queries/snow_queries.py b/src/rhosocial/activerecord/backend/impl/snowflake/examples/named_queries/snow_queries.py index b9c307b..9cd4ac7 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/examples/named_queries/snow_queries.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/examples/named_queries/snow_queries.py @@ -5,23 +5,23 @@ VARIANT path access, and MERGE. Usage: - from rhosocial.activerecord.backend.named_query import resolve_named_query + from rhosocial.activerecord.backend.named_expression import resolve_named_expression from rhosocial.activerecord.backend.impl.snowflake.dialect import SnowflakeDialect dialect = SnowflakeDialect(version=(8, 0, 0)) - expr, sql, params = resolve_named_query( + expr = resolve_named_expression( "rhosocial.activerecord.backend.impl.snowflake.examples.named_queries.snow_queries.daily_sales_report", dialect=dialect, user_params={"report_date": "2024-01-15"}, ) - print(sql, params) + print(expr.to_sql()) """ from typing import Optional from rhosocial.activerecord.backend.expression.core import Column, Literal from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate -from rhosocial.activerecord.backend.expression.statements.dql import QueryExpression -from rhosocial.activerecord.backend.expression.statements.dml import MergeExpression, MergeAction, MergeActionType +from rhosocial.activerecord.backend.expression.statements.dql import QueryStatement +from rhosocial.activerecord.backend.expression.statements.dml import MergeStatement, MergeAction, MergeActionType def daily_sales_report(dialect, report_date: str): @@ -35,7 +35,7 @@ def daily_sales_report(dialect, report_date: str): dialect: SnowflakeDialect instance. report_date: Date string for the report (YYYY-MM-DD). """ - return QueryExpression( + return QueryStatement( dialect=dialect, select=[ Column(dialect, 'store_name'), @@ -59,7 +59,7 @@ def time_travel_query(dialect, table: str, timestamp: str): table: Table name to query. timestamp: ISO 8601 timestamp string for the historical point. """ - return QueryExpression( + return QueryStatement( dialect=dialect, select=[Column(dialect, '*')], from_=table, @@ -76,7 +76,7 @@ def variant_data_query(dialect, table: str): dialect: SnowflakeDialect instance. table: Table name containing VARIANT columns. """ - return QueryExpression( + return QueryStatement( dialect=dialect, select=[Column(dialect, 'id'), Column(dialect, 'raw_data')], from_=table, @@ -95,7 +95,7 @@ def merge_upsert(dialect, source: str, target: str, key_column: str = "id"): target: Target table name. key_column: Column used for match condition. """ - return MergeExpression( + return MergeStatement( dialect=dialect, target_table=target, source=source, @@ -132,7 +132,7 @@ def three_part_query(dialect, database: str, schema: str, table: str): table: Table name. """ qualified_name = f'"{database}"."{schema}"."{table}"' - return QueryExpression( + return QueryStatement( dialect=dialect, select=[Column(dialect, '*')], from_=qualified_name, diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/__init__.py index 63ec2c8..7ca14e0 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/__init__.py @@ -22,6 +22,10 @@ from .table_options import SnowflakeCreateTableOptions from .database import SnowflakeCreateDatabaseExpression from .dml import SnowflakeInsertExpression +from .wildcard import ( + SnowflakeAllColumnsWildcard, + SnowflakeRowCountWildcard, +) from .partition import ( SnowflakeClusterByClause, SnowflakeExternalPartitionClause, @@ -37,50 +41,56 @@ SnowflakeUnpivotExpression, ) from .show import ( - SnowflakeShowExpression, + SnowflakeShowStatement, SnowflakeShowObjectType, SnowflakeShowScope, ) +from .introspection import ( + SnowflakeDatabaseListQueryStatement, + SnowflakeSchemaListQueryStatement, + SnowflakeTableStorageMetricsQueryStatement, + SnowflakeWarehouseListQueryStatement, +) from .variant import ( SnowflakeVariantPathAccessExpression, SnowflakeVariantCastExpression, ) from .ddl import ( SnowflakeAlterWarehouseMode, - SnowflakeCreateWarehouseExpression, - SnowflakeAlterWarehouseExpression, - SnowflakeDropWarehouseExpression, + SnowflakeCreateWarehouseStatement, + SnowflakeAlterWarehouseStatement, + SnowflakeDropWarehouseStatement, SnowflakeWarehouseOptionsExpression, SnowflakeCopyIntoMode, - SnowflakeCreateStageExpression, - SnowflakeAlterStageExpression, - SnowflakeDropStageExpression, - SnowflakeCopyIntoExpression, + SnowflakeCreateStageStatement, + SnowflakeAlterStageStatement, + SnowflakeDropStageStatement, + SnowflakeCopyIntoStatement, SnowflakeStreamObjectType, - SnowflakeCreateStreamExpression, - SnowflakeDropStreamExpression, + SnowflakeCreateStreamStatement, + SnowflakeDropStreamStatement, SnowflakeAlterTaskMode, - SnowflakeCreateTaskExpression, - SnowflakeAlterTaskExpression, - SnowflakeExecuteTaskExpression, - SnowflakeDropTaskExpression, + SnowflakeCreateTaskStatement, + SnowflakeAlterTaskStatement, + SnowflakeExecuteTaskStatement, + SnowflakeDropTaskStatement, SnowflakeAlterPipeMode, - SnowflakeCreatePipeExpression, - SnowflakeAlterPipeExpression, - SnowflakeDropPipeExpression, + SnowflakeCreatePipeStatement, + SnowflakeAlterPipeStatement, + SnowflakeDropPipeStatement, SnowflakeFileFormatType, - SnowflakeCreateFileFormatExpression, - SnowflakeAlterFileFormatExpression, - SnowflakeDropFileFormatExpression, + SnowflakeCreateFileFormatStatement, + SnowflakeAlterFileFormatStatement, + SnowflakeDropFileFormatStatement, SnowflakeRoutineLanguage, SnowflakeRoutineExecuteAs, SnowflakeRoutineType, - SnowflakeCreateProcedureExpression, - SnowflakeCreateFunctionExpression, - SnowflakeDropRoutineExpression, + SnowflakeCreateProcedureStatement, + SnowflakeCreateFunctionStatement, + SnowflakeDropRoutineStatement, SnowflakeUndropObjectType, - SnowflakeUndropExpression, - SnowflakeCreateMaterializedViewExpression, + SnowflakeUndropStatement, + SnowflakeCreateMaterializedViewStatement, SnowflakeTypeField, SnowflakeScalarTypeDefinition, SnowflakeObjectTypeDefinition, @@ -111,6 +121,8 @@ "SnowflakeCreateTableOptions", "SnowflakeCreateDatabaseExpression", "SnowflakeInsertExpression", + "SnowflakeAllColumnsWildcard", + "SnowflakeRowCountWildcard", "SnowflakeClusterByClause", "SnowflakeExternalPartitionClause", "SnowflakeIdentifierExpression", @@ -119,46 +131,50 @@ "SnowflakeSamplingMethod", "SnowflakePivotExpression", "SnowflakeUnpivotExpression", - "SnowflakeShowExpression", + "SnowflakeShowStatement", "SnowflakeShowObjectType", "SnowflakeShowScope", + "SnowflakeDatabaseListQueryStatement", + "SnowflakeSchemaListQueryStatement", + "SnowflakeTableStorageMetricsQueryStatement", + "SnowflakeWarehouseListQueryStatement", "SnowflakeVariantPathAccessExpression", "SnowflakeVariantCastExpression", "SnowflakeAlterWarehouseMode", - "SnowflakeCreateWarehouseExpression", - "SnowflakeAlterWarehouseExpression", - "SnowflakeDropWarehouseExpression", + "SnowflakeCreateWarehouseStatement", + "SnowflakeAlterWarehouseStatement", + "SnowflakeDropWarehouseStatement", "SnowflakeWarehouseOptionsExpression", "SnowflakeCopyIntoMode", - "SnowflakeCreateStageExpression", - "SnowflakeAlterStageExpression", - "SnowflakeDropStageExpression", - "SnowflakeCopyIntoExpression", + "SnowflakeCreateStageStatement", + "SnowflakeAlterStageStatement", + "SnowflakeDropStageStatement", + "SnowflakeCopyIntoStatement", "SnowflakeStreamObjectType", - "SnowflakeCreateStreamExpression", - "SnowflakeDropStreamExpression", + "SnowflakeCreateStreamStatement", + "SnowflakeDropStreamStatement", "SnowflakeAlterTaskMode", - "SnowflakeCreateTaskExpression", - "SnowflakeAlterTaskExpression", - "SnowflakeExecuteTaskExpression", - "SnowflakeDropTaskExpression", + "SnowflakeCreateTaskStatement", + "SnowflakeAlterTaskStatement", + "SnowflakeExecuteTaskStatement", + "SnowflakeDropTaskStatement", "SnowflakeAlterPipeMode", - "SnowflakeCreatePipeExpression", - "SnowflakeAlterPipeExpression", - "SnowflakeDropPipeExpression", + "SnowflakeCreatePipeStatement", + "SnowflakeAlterPipeStatement", + "SnowflakeDropPipeStatement", "SnowflakeFileFormatType", - "SnowflakeCreateFileFormatExpression", - "SnowflakeAlterFileFormatExpression", - "SnowflakeDropFileFormatExpression", + "SnowflakeCreateFileFormatStatement", + "SnowflakeAlterFileFormatStatement", + "SnowflakeDropFileFormatStatement", "SnowflakeRoutineLanguage", "SnowflakeRoutineExecuteAs", "SnowflakeRoutineType", - "SnowflakeCreateProcedureExpression", - "SnowflakeCreateFunctionExpression", - "SnowflakeDropRoutineExpression", + "SnowflakeCreateProcedureStatement", + "SnowflakeCreateFunctionStatement", + "SnowflakeDropRoutineStatement", "SnowflakeUndropObjectType", - "SnowflakeUndropExpression", - "SnowflakeCreateMaterializedViewExpression", + "SnowflakeUndropStatement", + "SnowflakeCreateMaterializedViewStatement", "SnowflakeTypeField", "SnowflakeScalarTypeDefinition", "SnowflakeObjectTypeDefinition", diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/coercion.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/coercion.py new file mode 100644 index 0000000..2abd05b --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/coercion.py @@ -0,0 +1,69 @@ +# src/rhosocial/activerecord/backend/impl/snowflake/expression/coercion.py +"""One place where a closed-set parameter becomes its enum member. + +Every enum this package declares inherits ``str`` (org rules §11.3), so a +member *is* the word it carries and an artifact can hold that word. Reading the +artifact back is the mirror of that: the constructor receives the word and must +put the member back, because the parameter's host type is the member and the +formatters read ``.value`` off it. + +Rules §10 puts that conversion in the constructor -- "wide in, normalised out" +-- and not in the reader, which only passes parameters by name. The +alternatives both cost something: + +* **refusing the word** (``isinstance`` check, ``TypeError``) makes the + constructor stricter than its own declared type and leaves the node's artifact + unreadable by the node that produced it. That is a real defect in core's + datetime and graph enums, which is why the matrix pins those rows; a backend + enum has no such excuse, because the backend owns both ends. +* **storing the word** lets the value survive and the host type not, so a + round-trip compares a ``str`` against a member and reports a type mismatch -- + a second spelling of the same problem. + +A word outside the set is still refused, by the enum itself and by name. +""" + +from enum import Enum +from typing import Any, Optional, Type, TypeVar + +_Member = TypeVar("_Member", bound=Enum) + + +def as_member( + enum_type: Type[_Member], value: Any, *, parameter: str +) -> Optional[_Member]: + """*value* as a member of *enum_type*, or ``None`` when it is ``None``. + + Args: + enum_type: The closed set the parameter declares. + value: A member, or the word a member carries. + parameter: The parameter's own name, for the refusal message -- a + caller reading the error needs to know which argument was wrong, + and the message cannot recover that from the value alone. + + Returns: + The member, or ``None`` when *value* is ``None``. + + Raises: + TypeError: *value* is neither a member nor a string. + ValueError: *value* is a string the set does not contain. + """ + if value is None: + return None + if isinstance(value, enum_type): + return value + if not isinstance(value, str): + raise TypeError( + f"{parameter} must be a {enum_type.__name__} or the word it carries, " + f"got {type(value).__name__}" + ) + try: + return enum_type(value) + except ValueError: + valid = ", ".join(repr(member.value) for member in enum_type) + raise ValueError( + f"{parameter} must be one of {valid}, got {value!r}" + ) from None + + +__all__ = ["as_member"] diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/database.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/database.py index f90888d..4b977fc 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/database.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/database.py @@ -5,14 +5,14 @@ from rhosocial.activerecord.backend.expression.objects import Database from rhosocial.activerecord.backend.expression.statements.ddl_database import ( - CreateDatabaseExpression, + CreateDatabaseStatement, ) if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.dialect import SQLDialectBase -class SnowflakeCreateDatabaseExpression(CreateDatabaseExpression): +class SnowflakeCreateDatabaseExpression(CreateDatabaseStatement): """A Snowflake CREATE DATABASE statement extending the generic one. Adds the Snowflake-only ``TRANSIENT`` qualifier and ``CLONE `` diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/__init__.py index cadf190..69b1825 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/__init__.py @@ -14,58 +14,58 @@ """ from .warehouse import ( SnowflakeAlterWarehouseMode, - SnowflakeCreateWarehouseExpression, - SnowflakeAlterWarehouseExpression, - SnowflakeDropWarehouseExpression, + SnowflakeCreateWarehouseStatement, + SnowflakeAlterWarehouseStatement, + SnowflakeDropWarehouseStatement, ) from .warehouse_options import ( SnowflakeWarehouseOptionsExpression, ) from .stage import ( SnowflakeCopyIntoMode, - SnowflakeCreateStageExpression, - SnowflakeAlterStageExpression, - SnowflakeDropStageExpression, - SnowflakeCopyIntoExpression, + SnowflakeCreateStageStatement, + SnowflakeAlterStageStatement, + SnowflakeDropStageStatement, + SnowflakeCopyIntoStatement, ) from .stream import ( SnowflakeStreamObjectType, - SnowflakeCreateStreamExpression, - SnowflakeDropStreamExpression, + SnowflakeCreateStreamStatement, + SnowflakeDropStreamStatement, ) from .task import ( SnowflakeAlterTaskMode, - SnowflakeCreateTaskExpression, - SnowflakeAlterTaskExpression, - SnowflakeExecuteTaskExpression, - SnowflakeDropTaskExpression, + SnowflakeCreateTaskStatement, + SnowflakeAlterTaskStatement, + SnowflakeExecuteTaskStatement, + SnowflakeDropTaskStatement, ) from .pipe import ( SnowflakeAlterPipeMode, - SnowflakeCreatePipeExpression, - SnowflakeAlterPipeExpression, - SnowflakeDropPipeExpression, + SnowflakeCreatePipeStatement, + SnowflakeAlterPipeStatement, + SnowflakeDropPipeStatement, ) from .file_format import ( SnowflakeFileFormatType, - SnowflakeCreateFileFormatExpression, - SnowflakeAlterFileFormatExpression, - SnowflakeDropFileFormatExpression, + SnowflakeCreateFileFormatStatement, + SnowflakeAlterFileFormatStatement, + SnowflakeDropFileFormatStatement, ) from .routine import ( SnowflakeRoutineLanguage, SnowflakeRoutineExecuteAs, SnowflakeRoutineType, - SnowflakeCreateProcedureExpression, - SnowflakeCreateFunctionExpression, - SnowflakeDropRoutineExpression, + SnowflakeCreateProcedureStatement, + SnowflakeCreateFunctionStatement, + SnowflakeDropRoutineStatement, ) from .undrop import ( SnowflakeUndropObjectType, - SnowflakeUndropExpression, + SnowflakeUndropStatement, ) from .materialized_view import ( - SnowflakeCreateMaterializedViewExpression, + SnowflakeCreateMaterializedViewStatement, ) from .type import ( SnowflakeTypeField, @@ -80,40 +80,40 @@ __all__ = [ "SnowflakeAlterWarehouseMode", - "SnowflakeCreateWarehouseExpression", - "SnowflakeAlterWarehouseExpression", - "SnowflakeDropWarehouseExpression", + "SnowflakeCreateWarehouseStatement", + "SnowflakeAlterWarehouseStatement", + "SnowflakeDropWarehouseStatement", "SnowflakeWarehouseOptionsExpression", "SnowflakeCopyIntoMode", - "SnowflakeCreateStageExpression", - "SnowflakeAlterStageExpression", - "SnowflakeDropStageExpression", - "SnowflakeCopyIntoExpression", + "SnowflakeCreateStageStatement", + "SnowflakeAlterStageStatement", + "SnowflakeDropStageStatement", + "SnowflakeCopyIntoStatement", "SnowflakeStreamObjectType", - "SnowflakeCreateStreamExpression", - "SnowflakeDropStreamExpression", + "SnowflakeCreateStreamStatement", + "SnowflakeDropStreamStatement", "SnowflakeAlterTaskMode", - "SnowflakeCreateTaskExpression", - "SnowflakeAlterTaskExpression", - "SnowflakeExecuteTaskExpression", - "SnowflakeDropTaskExpression", + "SnowflakeCreateTaskStatement", + "SnowflakeAlterTaskStatement", + "SnowflakeExecuteTaskStatement", + "SnowflakeDropTaskStatement", "SnowflakeAlterPipeMode", - "SnowflakeCreatePipeExpression", - "SnowflakeAlterPipeExpression", - "SnowflakeDropPipeExpression", + "SnowflakeCreatePipeStatement", + "SnowflakeAlterPipeStatement", + "SnowflakeDropPipeStatement", "SnowflakeFileFormatType", - "SnowflakeCreateFileFormatExpression", - "SnowflakeAlterFileFormatExpression", - "SnowflakeDropFileFormatExpression", + "SnowflakeCreateFileFormatStatement", + "SnowflakeAlterFileFormatStatement", + "SnowflakeDropFileFormatStatement", "SnowflakeRoutineLanguage", "SnowflakeRoutineExecuteAs", "SnowflakeRoutineType", - "SnowflakeCreateProcedureExpression", - "SnowflakeCreateFunctionExpression", - "SnowflakeDropRoutineExpression", + "SnowflakeCreateProcedureStatement", + "SnowflakeCreateFunctionStatement", + "SnowflakeDropRoutineStatement", "SnowflakeUndropObjectType", - "SnowflakeUndropExpression", - "SnowflakeCreateMaterializedViewExpression", + "SnowflakeUndropStatement", + "SnowflakeCreateMaterializedViewStatement", "SnowflakeTypeField", "SnowflakeScalarTypeDefinition", "SnowflakeObjectTypeDefinition", diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/file_format.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/file_format.py index 2c7bf26..6771081 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/file_format.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/file_format.py @@ -24,14 +24,20 @@ __all__ = [ "SnowflakeFileFormatType", - "SnowflakeCreateFileFormatExpression", - "SnowflakeAlterFileFormatExpression", - "SnowflakeDropFileFormatExpression", + "SnowflakeCreateFileFormatStatement", + "SnowflakeAlterFileFormatStatement", + "SnowflakeDropFileFormatStatement", ] -class SnowflakeFileFormatType(Enum): +class SnowflakeFileFormatType(str, Enum): """Supported FILE FORMAT type values.""" + #: A member *is* the word it carries: ``str()`` and f-string + #: interpolation must yield the value, not ``ClassName.MEMBER``, or the + #: rendered SQL and the artifact would carry the Python spelling. + #: ``(str, Enum)`` alone does not give that -- ``Enum.__str__`` wins -- + #: so it is stated here once. + __str__ = str.__str__ CSV = "CSV" JSON = "JSON" @@ -41,7 +47,7 @@ class SnowflakeFileFormatType(Enum): XML = "XML" -class SnowflakeCreateFileFormatExpression(BaseExpression): +class SnowflakeCreateFileFormatStatement(BaseExpression): """Snowflake CREATE [OR REPLACE] FILE FORMAT statement expression. Attributes: @@ -84,7 +90,7 @@ def to_sql(self) -> "Tuple[str, tuple]": return self.dialect.format_create_file_format_statement(self) -class SnowflakeAlterFileFormatExpression(BaseExpression): +class SnowflakeAlterFileFormatStatement(BaseExpression): """Snowflake ALTER FILE FORMAT statement expression. Attributes: @@ -122,7 +128,7 @@ def to_sql(self) -> "Tuple[str, tuple]": return self.dialect.format_alter_file_format_statement(self) -class SnowflakeDropFileFormatExpression(BaseExpression): +class SnowflakeDropFileFormatStatement(BaseExpression): """Snowflake DROP FILE FORMAT statement expression. Attributes: diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/materialized_view.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/materialized_view.py index 1faa8be..32993b2 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/materialized_view.py @@ -7,7 +7,7 @@ aliases, CLUSTER BY keys and COMMENT. The expression extends the core -:class:`~rhosocial.activerecord.backend.expression.statements.ddl_view.CreateMaterializedViewExpression` +:class:`~rhosocial.activerecord.backend.expression.statements.ddl_view.CreateMaterializedViewStatement` so the generic MV API works on Snowflake, and layers the Snowflake-specific options on top. @@ -25,11 +25,11 @@ Official Documentation: - CREATE MATERIALIZED VIEW: https://docs.snowflake.com/en/sql-reference/sql/create-materialized-view """ -from typing import Any, List, Optional, Tuple, Union, TYPE_CHECKING +from typing import Any, Dict, List, Optional, Tuple, Union, TYPE_CHECKING from rhosocial.activerecord.backend.expression.objects import MaterializedView from rhosocial.activerecord.backend.expression.statements.ddl_view import ( - CreateMaterializedViewExpression, + CreateMaterializedViewStatement, ) if TYPE_CHECKING: @@ -37,11 +37,11 @@ __all__ = [ - "SnowflakeCreateMaterializedViewExpression", + "SnowflakeCreateMaterializedViewStatement", ] -class SnowflakeCreateMaterializedViewExpression(CreateMaterializedViewExpression): +class SnowflakeCreateMaterializedViewStatement(CreateMaterializedViewStatement): """Snowflake CREATE [OR REPLACE] MATERIALIZED VIEW statement expression. Accepts both the Snowflake field names (``name`` / ``as_query`` / @@ -53,7 +53,7 @@ class SnowflakeCreateMaterializedViewExpression(CreateMaterializedViewExpression Attributes: view: The ``MaterializedView`` object being created; a bare name is accepted and wrapped in one. - query: Defining query — a ``QueryExpression`` or a raw SQL string. + query: Defining query — a ``QueryStatement`` or a raw SQL string. column_aliases: Optional column alias list. or_replace: Emit ``OR REPLACE`` (mutually exclusive with ``if_not_exists``). if_not_exists: Emit ``IF NOT EXISTS``. @@ -76,7 +76,18 @@ def __init__( comment: Optional[str] = None, or_replace: bool = False, if_not_exists: bool = False, - **core_kwargs: Any, + # The base statement's own options, named rather than caught. The + # artifact reader matches parameters by name (spec 4.11.4) and refuses + # a ``**kwargs`` catch-all outright -- a parameter whose identity + # depends on how many extras arrived cannot be validated on read -- + # so the four options the base declares are forwarded here by the + # names it knows. Snowflake has none of these clauses and the + # formatter refuses them; they exist to reach that refusal, not to + # be spelled. + tablespace: Optional[str] = None, + with_data: bool = False, + no_data: bool = False, + storage_options: Optional[Dict[str, Any]] = None, ): resolved_view = view if view is not None else view_name if resolved_view is None: @@ -96,7 +107,10 @@ def __init__( view=resolved_view, query=resolved_query, column_aliases=resolved_columns, - **core_kwargs, + tablespace=tablespace, + with_data=with_data, + no_data=no_data, + storage_options=storage_options, ) if or_replace and if_not_exists: raise ValueError( @@ -128,8 +142,7 @@ def get_params(self) -> dict: The default introspection reads this class's ``__init__`` signature, which lists *both* spellings of every aliased field: ``name`` and ``view``, ``as_query`` and ``query``, ``column_list`` and - ``column_aliases``, plus the ``core_kwargs`` catch-all that has no - attribute to read. Reconstruction therefore handed the constructor both + ``column_aliases``. Reconstruction therefore handed the constructor both halves of each pair, and ``column_aliases is not None and column_list is not None`` is exactly the disagreement this class refuses -- so serialising any instance of it raised and it could not be deserialised at diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/pipe.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/pipe.py index e7937cf..715e30b 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/pipe.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/pipe.py @@ -17,19 +17,21 @@ from rhosocial.activerecord.backend.expression.bases import BaseExpression +from ..coercion import as_member + if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase __all__ = [ "SnowflakeAlterPipeMode", - "SnowflakeCreatePipeExpression", - "SnowflakeAlterPipeExpression", - "SnowflakeDropPipeExpression", + "SnowflakeCreatePipeStatement", + "SnowflakeAlterPipeStatement", + "SnowflakeDropPipeStatement", ] -class SnowflakeAlterPipeMode(Enum): +class SnowflakeAlterPipeMode(str, Enum): """ALTER PIPE action modes. REFRESH: manually refresh the pipe to load staged files. @@ -37,6 +39,12 @@ class SnowflakeAlterPipeMode(Enum): PAUSE: set ``PIPE_EXECUTION_PAUSED = TRUE``. RESUME: set ``PIPE_EXECUTION_PAUSED = FALSE``. """ + #: A member *is* the word it carries: ``str()`` and f-string + #: interpolation must yield the value, not ``ClassName.MEMBER``, or the + #: rendered SQL and the artifact would carry the Python spelling. + #: ``(str, Enum)`` alone does not give that -- ``Enum.__str__`` wins -- + #: so it is stated here once. + __str__ = str.__str__ REFRESH = "REFRESH" SET = "SET" @@ -44,7 +52,7 @@ class SnowflakeAlterPipeMode(Enum): RESUME = "RESUME" -class SnowflakeCreatePipeExpression(BaseExpression): +class SnowflakeCreatePipeStatement(BaseExpression): """Snowflake CREATE [OR REPLACE] PIPE statement expression. Attributes: @@ -94,7 +102,7 @@ def to_sql(self) -> "Tuple[str, tuple]": return self.dialect.format_create_pipe_statement(self) -class SnowflakeAlterPipeExpression(BaseExpression): +class SnowflakeAlterPipeStatement(BaseExpression): """Snowflake ALTER PIPE statement expression. The ``mode`` selects one of the ALTER PIPE forms: ``REFRESH``, @@ -125,7 +133,7 @@ def __init__( ): super().__init__(dialect) self.name = name - self.mode = mode + self.mode = as_member(SnowflakeAlterPipeMode, mode, parameter="mode") self.if_exists = if_exists self.modified_after = modified_after self.pipe_execution_paused = pipe_execution_paused @@ -141,7 +149,7 @@ def to_sql(self) -> "Tuple[str, tuple]": return self.dialect.format_alter_pipe_statement(self) -class SnowflakeDropPipeExpression(BaseExpression): +class SnowflakeDropPipeStatement(BaseExpression): """Snowflake DROP PIPE statement expression. Attributes: diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/routine.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/routine.py index 3fbcef8..9c9aeb0 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/routine.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/routine.py @@ -20,6 +20,8 @@ from rhosocial.activerecord.backend.expression.bases import BaseExpression +from ..coercion import as_member + if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase @@ -28,14 +30,20 @@ "SnowflakeRoutineLanguage", "SnowflakeRoutineExecuteAs", "SnowflakeRoutineType", - "SnowflakeCreateProcedureExpression", - "SnowflakeCreateFunctionExpression", - "SnowflakeDropRoutineExpression", + "SnowflakeCreateProcedureStatement", + "SnowflakeCreateFunctionStatement", + "SnowflakeDropRoutineStatement", ] -class SnowflakeRoutineLanguage(Enum): +class SnowflakeRoutineLanguage(str, Enum): """Supported routine implementation languages.""" + #: A member *is* the word it carries: ``str()`` and f-string + #: interpolation must yield the value, not ``ClassName.MEMBER``, or the + #: rendered SQL and the artifact would carry the Python spelling. + #: ``(str, Enum)`` alone does not give that -- ``Enum.__str__`` wins -- + #: so it is stated here once. + __str__ = str.__str__ SQL = "SQL" JAVASCRIPT = "JAVASCRIPT" @@ -44,21 +52,33 @@ class SnowflakeRoutineLanguage(Enum): SCALA = "SCALA" -class SnowflakeRoutineExecuteAs(Enum): +class SnowflakeRoutineExecuteAs(str, Enum): """EXECUTE AS privilege context for routines.""" + #: A member *is* the word it carries: ``str()`` and f-string + #: interpolation must yield the value, not ``ClassName.MEMBER``, or the + #: rendered SQL and the artifact would carry the Python spelling. + #: ``(str, Enum)`` alone does not give that -- ``Enum.__str__`` wins -- + #: so it is stated here once. + __str__ = str.__str__ CALLER = "CALLER" OWNER = "OWNER" -class SnowflakeRoutineType(Enum): +class SnowflakeRoutineType(str, Enum): """Routine kinds accepted by DROP PROCEDURE / FUNCTION.""" + #: A member *is* the word it carries: ``str()`` and f-string + #: interpolation must yield the value, not ``ClassName.MEMBER``, or the + #: rendered SQL and the artifact would carry the Python spelling. + #: ``(str, Enum)`` alone does not give that -- ``Enum.__str__`` wins -- + #: so it is stated here once. + __str__ = str.__str__ PROCEDURE = "PROCEDURE" FUNCTION = "FUNCTION" -class SnowflakeCreateProcedureExpression(BaseExpression): +class SnowflakeCreateProcedureStatement(BaseExpression): """Snowflake CREATE [OR REPLACE] PROCEDURE statement expression. Attributes: @@ -90,8 +110,8 @@ def __init__( self.or_replace = or_replace self.args = args or [] self.returns = returns - self.language = language - self.execute_as = execute_as + self.language = as_member(SnowflakeRoutineLanguage, language, parameter="language") + self.execute_as = as_member(SnowflakeRoutineExecuteAs, execute_as, parameter="execute_as") self.body = body self.comment = comment @@ -105,7 +125,7 @@ def to_sql(self) -> "Tuple[str, tuple]": return self.dialect.format_create_procedure_statement(self) -class SnowflakeCreateFunctionExpression(BaseExpression): +class SnowflakeCreateFunctionStatement(BaseExpression): """Snowflake CREATE [OR REPLACE] FUNCTION statement expression. Attributes: @@ -149,10 +169,12 @@ def __init__( self.or_replace = or_replace self.args = args or [] self.returns = returns - self.language = language + self.language = as_member(SnowflakeRoutineLanguage, language, parameter="language") self.immutable = immutable self.volatile = volatile - self.execute_as = execute_as + self.execute_as = as_member( + SnowflakeRoutineExecuteAs, execute_as, parameter="execute_as" + ) self.body = body self.comment = comment @@ -166,7 +188,7 @@ def to_sql(self) -> "Tuple[str, tuple]": return self.dialect.format_create_function_statement(self) -class SnowflakeDropRoutineExpression(BaseExpression): +class SnowflakeDropRoutineStatement(BaseExpression): """Snowflake DROP PROCEDURE / DROP FUNCTION statement expression. Attributes: @@ -185,7 +207,7 @@ def __init__( ): super().__init__(dialect) self.name = name - self.routine_type = routine_type + self.routine_type = as_member(SnowflakeRoutineType, routine_type, parameter="routine_type") self.if_exists = if_exists def to_sql(self) -> "Tuple[str, tuple]": diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/stage.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/stage.py index 351014c..9b4729d 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/stage.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/stage.py @@ -19,31 +19,39 @@ from rhosocial.activerecord.backend.expression.bases import BaseExpression +from ..coercion import as_member + if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase __all__ = [ "SnowflakeCopyIntoMode", - "SnowflakeCreateStageExpression", - "SnowflakeAlterStageExpression", - "SnowflakeDropStageExpression", - "SnowflakeCopyIntoExpression", + "SnowflakeCreateStageStatement", + "SnowflakeAlterStageStatement", + "SnowflakeDropStageStatement", + "SnowflakeCopyIntoStatement", ] -class SnowflakeCopyIntoMode(Enum): +class SnowflakeCopyIntoMode(str, Enum): """COPY INTO direction modes. LOAD: COPY INTO FROM @ — load data into a table. UNLOAD: COPY INTO @ FROM
— unload data from a table. """ + #: A member *is* the word it carries: ``str()`` and f-string + #: interpolation must yield the value, not ``ClassName.MEMBER``, or the + #: rendered SQL and the artifact would carry the Python spelling. + #: ``(str, Enum)`` alone does not give that -- ``Enum.__str__`` wins -- + #: so it is stated here once. + __str__ = str.__str__ LOAD = "LOAD" UNLOAD = "UNLOAD" -class SnowflakeCreateStageExpression(BaseExpression): +class SnowflakeCreateStageStatement(BaseExpression): """Snowflake CREATE STAGE statement expression. Attributes: @@ -91,7 +99,7 @@ def to_sql(self) -> "Tuple[str, tuple]": return self.dialect.format_create_stage_statement(self) -class SnowflakeAlterStageExpression(BaseExpression): +class SnowflakeAlterStageStatement(BaseExpression): """Snowflake ALTER STAGE statement expression. Only the ``SET`` form is emitted; properties not specified are left @@ -135,7 +143,7 @@ def to_sql(self) -> "Tuple[str, tuple]": return self.dialect.format_alter_stage_statement(self) -class SnowflakeDropStageExpression(BaseExpression): +class SnowflakeDropStageStatement(BaseExpression): """Snowflake DROP STAGE statement expression. Attributes: @@ -164,7 +172,7 @@ def to_sql(self) -> "Tuple[str, tuple]": return self.dialect.format_drop_stage_statement(self) -class SnowflakeCopyIntoExpression(BaseExpression): +class SnowflakeCopyIntoStatement(BaseExpression): """Snowflake COPY INTO statement expression (load or unload). ``mode`` selects the direction: @@ -215,7 +223,7 @@ def __init__( single: Optional[bool] = None, ): super().__init__(dialect) - self.mode = mode + self.mode = as_member(SnowflakeCopyIntoMode, mode, parameter="mode") self.table = table self.stage = stage self.files = files diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/stream.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/stream.py index e870f6b..00e91f3 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/stream.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/stream.py @@ -17,18 +17,20 @@ from rhosocial.activerecord.backend.expression.bases import BaseExpression +from ..coercion import as_member + if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase __all__ = [ "SnowflakeStreamObjectType", - "SnowflakeCreateStreamExpression", - "SnowflakeDropStreamExpression", + "SnowflakeCreateStreamStatement", + "SnowflakeDropStreamStatement", ] -class SnowflakeStreamObjectType(Enum): +class SnowflakeStreamObjectType(str, Enum): """Source object kinds supported by CREATE STREAM. TABLE: table stream (default). @@ -36,6 +38,12 @@ class SnowflakeStreamObjectType(Enum): EXTERNAL_TABLE: external table stream. STAGE: stage stream. """ + #: A member *is* the word it carries: ``str()`` and f-string + #: interpolation must yield the value, not ``ClassName.MEMBER``, or the + #: rendered SQL and the artifact would carry the Python spelling. + #: ``(str, Enum)`` alone does not give that -- ``Enum.__str__`` wins -- + #: so it is stated here once. + __str__ = str.__str__ TABLE = "TABLE" VIEW = "VIEW" @@ -43,7 +51,7 @@ class SnowflakeStreamObjectType(Enum): STAGE = "STAGE" -class SnowflakeCreateStreamExpression(BaseExpression): +class SnowflakeCreateStreamStatement(BaseExpression): """Snowflake CREATE [OR REPLACE] STREAM statement expression. Attributes: @@ -83,7 +91,7 @@ def __init__( self.name = name self.or_replace = or_replace self.if_not_exists = if_not_exists - self.object_type = object_type + self.object_type = as_member(SnowflakeStreamObjectType, object_type, parameter="object_type") self.object_name = object_name self.append_only = append_only self.insert_only = insert_only @@ -103,7 +111,7 @@ def to_sql(self) -> "Tuple[str, tuple]": return self.dialect.format_create_stream_statement(self) -class SnowflakeDropStreamExpression(BaseExpression): +class SnowflakeDropStreamStatement(BaseExpression): """Snowflake DROP STREAM statement expression. Attributes: diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/task.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/task.py index c248fde..b410be5 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/task.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/task.py @@ -20,20 +20,22 @@ from rhosocial.activerecord.backend.expression.bases import BaseExpression +from ..coercion import as_member + if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase __all__ = [ "SnowflakeAlterTaskMode", - "SnowflakeCreateTaskExpression", - "SnowflakeAlterTaskExpression", - "SnowflakeExecuteTaskExpression", - "SnowflakeDropTaskExpression", + "SnowflakeCreateTaskStatement", + "SnowflakeAlterTaskStatement", + "SnowflakeExecuteTaskStatement", + "SnowflakeDropTaskStatement", ] -class SnowflakeAlterTaskMode(Enum): +class SnowflakeAlterTaskMode(str, Enum): """ALTER TASK action modes. RESUME: resume a suspended task. @@ -42,6 +44,12 @@ class SnowflakeAlterTaskMode(Enum): REMOVE_AFTER: remove predecessor task dependencies. SET: change task properties (warehouse, schedule). """ + #: A member *is* the word it carries: ``str()`` and f-string + #: interpolation must yield the value, not ``ClassName.MEMBER``, or the + #: rendered SQL and the artifact would carry the Python spelling. + #: ``(str, Enum)`` alone does not give that -- ``Enum.__str__`` wins -- + #: so it is stated here once. + __str__ = str.__str__ RESUME = "RESUME" SUSPEND = "SUSPEND" @@ -50,7 +58,7 @@ class SnowflakeAlterTaskMode(Enum): SET = "SET" -class SnowflakeCreateTaskExpression(BaseExpression): +class SnowflakeCreateTaskStatement(BaseExpression): """Snowflake CREATE [OR REPLACE] TASK statement expression. Attributes: @@ -115,7 +123,7 @@ def to_sql(self) -> "Tuple[str, tuple]": return self.dialect.format_create_task_statement(self) -class SnowflakeAlterTaskExpression(BaseExpression): +class SnowflakeAlterTaskStatement(BaseExpression): """Snowflake ALTER TASK statement expression. The ``mode`` selects one of the ALTER TASK forms: ``RESUME``, @@ -150,7 +158,7 @@ def __init__( ): super().__init__(dialect) self.name = name - self.mode = mode + self.mode = as_member(SnowflakeAlterTaskMode, mode, parameter="mode") self.if_exists = if_exists self.after = after self.warehouse = warehouse @@ -168,7 +176,7 @@ def to_sql(self) -> "Tuple[str, tuple]": return self.dialect.format_alter_task_statement(self) -class SnowflakeExecuteTaskExpression(BaseExpression): +class SnowflakeExecuteTaskStatement(BaseExpression): """Snowflake EXECUTE TASK statement expression. Attributes: @@ -197,7 +205,7 @@ def to_sql(self) -> "Tuple[str, tuple]": return self.dialect.format_execute_task_statement(self) -class SnowflakeDropTaskExpression(BaseExpression): +class SnowflakeDropTaskStatement(BaseExpression): """Snowflake DROP TASK statement expression. Attributes: diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/type.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/type.py index 054c746..e98c9fa 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/type.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/type.py @@ -8,11 +8,10 @@ # ``Type`` is the schema object naming a user-defined type; aliased because the # TYPE statements name their parameter ``type`` for the core's sake. from rhosocial.activerecord.backend.expression.objects import Type as TypeObject -from rhosocial.activerecord.backend.expression.serialization import ExpressionRegistry from rhosocial.activerecord.backend.expression.statements.ddl_type import ( - AlterTypeExpression, - CreateTypeExpression, - DropTypeExpression, + AlterTypeStatement, + CreateTypeStatement, + DropTypeStatement, TypeAlterAction, TypeDefinition, ) @@ -149,7 +148,7 @@ class SnowflakeUnsetTypeCommentAction(TypeAlterAction): action_kind = "snowflake.unset_type_comment" -class SnowflakeCreateTypeExpression(CreateTypeExpression): +class SnowflakeCreateTypeExpression(CreateTypeStatement): """CREATE TYPE expression with Snowflake qualification and comment syntax.""" def __init__( @@ -174,7 +173,7 @@ def __init__( self.comment = comment -class SnowflakeAlterTypeExpression(AlterTypeExpression): +class SnowflakeAlterTypeExpression(AlterTypeStatement): """ALTER TYPE expression over the Snowflake TYPE object.""" def __init__( @@ -193,7 +192,7 @@ def __init__( ) -class SnowflakeDropTypeExpression(DropTypeExpression): +class SnowflakeDropTypeExpression(DropTypeStatement): """DROP TYPE expression over the Snowflake TYPE object.""" def __init__( @@ -230,18 +229,10 @@ def __init__( self.restrict = False -_EXPRESSION_TYPES = ( - SnowflakeTypeField, - SnowflakeScalarTypeDefinition, - SnowflakeObjectTypeDefinition, - SnowflakeSetTypeCommentAction, - SnowflakeUnsetTypeCommentAction, - SnowflakeCreateTypeExpression, - SnowflakeAlterTypeExpression, - SnowflakeDropTypeExpression, -) -for _expression_type in _EXPRESSION_TYPES: - ExpressionRegistry.register(_expression_type) +# No registration loop, for the reason ``expression/types.py`` gives at its +# tail: serializing needs no registry and reading is the reader's assembly. +# The classes stay exported below; a reader that wants their kinds in a +# registry registers them itself. __all__ = [ diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/undrop.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/undrop.py index 51a88d5..12642d5 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/undrop.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/undrop.py @@ -15,18 +15,26 @@ from rhosocial.activerecord.backend.expression.bases import BaseExpression +from ..coercion import as_member + if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase __all__ = [ "SnowflakeUndropObjectType", - "SnowflakeUndropExpression", + "SnowflakeUndropStatement", ] -class SnowflakeUndropObjectType(Enum): +class SnowflakeUndropObjectType(str, Enum): """Object kinds supported by UNDROP.""" + #: A member *is* the word it carries: ``str()`` and f-string + #: interpolation must yield the value, not ``ClassName.MEMBER``, or the + #: rendered SQL and the artifact would carry the Python spelling. + #: ``(str, Enum)`` alone does not give that -- ``Enum.__str__`` wins -- + #: so it is stated here once. + __str__ = str.__str__ TABLE = "TABLE" SCHEMA = "SCHEMA" @@ -34,7 +42,7 @@ class SnowflakeUndropObjectType(Enum): TAG = "TAG" -class SnowflakeUndropExpression(BaseExpression): +class SnowflakeUndropStatement(BaseExpression): """Snowflake UNDROP statement expression. Attributes: @@ -51,7 +59,7 @@ def __init__( ): super().__init__(dialect) self.name = name - self.object_type = object_type + self.object_type = as_member(SnowflakeUndropObjectType, object_type, parameter="object_type") def to_sql(self) -> "Tuple[str, tuple]": """Generate UNDROP SQL statement. diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/warehouse.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/warehouse.py index fe78210..2497c21 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/warehouse.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/warehouse.py @@ -17,19 +17,21 @@ from rhosocial.activerecord.backend.expression.bases import BaseExpression +from ..coercion import as_member + if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase __all__ = [ "SnowflakeAlterWarehouseMode", - "SnowflakeCreateWarehouseExpression", - "SnowflakeAlterWarehouseExpression", - "SnowflakeDropWarehouseExpression", + "SnowflakeCreateWarehouseStatement", + "SnowflakeAlterWarehouseStatement", + "SnowflakeDropWarehouseStatement", ] -class SnowflakeAlterWarehouseMode(Enum): +class SnowflakeAlterWarehouseMode(str, Enum): """ALTER WAREHOUSE action modes. SUSPEND: shut down (suspend) the warehouse. @@ -37,6 +39,12 @@ class SnowflakeAlterWarehouseMode(Enum): SET: change one or more warehouse properties. RENAME: rename the warehouse. """ + #: A member *is* the word it carries: ``str()`` and f-string + #: interpolation must yield the value, not ``ClassName.MEMBER``, or the + #: rendered SQL and the artifact would carry the Python spelling. + #: ``(str, Enum)`` alone does not give that -- ``Enum.__str__`` wins -- + #: so it is stated here once. + __str__ = str.__str__ SUSPEND = "SUSPEND" RESUME = "RESUME" @@ -44,7 +52,7 @@ class SnowflakeAlterWarehouseMode(Enum): RENAME = "RENAME" -class SnowflakeCreateWarehouseExpression(BaseExpression): +class SnowflakeCreateWarehouseStatement(BaseExpression): """Snowflake CREATE WAREHOUSE statement expression. Attributes: @@ -97,7 +105,7 @@ def to_sql(self) -> "Tuple[str, tuple]": return self.dialect.format_create_warehouse_statement(self) -class SnowflakeAlterWarehouseExpression(BaseExpression): +class SnowflakeAlterWarehouseStatement(BaseExpression): """Snowflake ALTER WAREHOUSE statement expression. The ``mode`` selects one of the four ALTER WAREHOUSE forms: @@ -137,7 +145,7 @@ def __init__( ): super().__init__(dialect) self.name = name - self.mode = mode + self.mode = as_member(SnowflakeAlterWarehouseMode, mode, parameter="mode") self.new_name = new_name self.warehouse_size = warehouse_size self.max_cluster_count = max_cluster_count @@ -157,7 +165,7 @@ def to_sql(self) -> "Tuple[str, tuple]": return self.dialect.format_alter_warehouse_statement(self) -class SnowflakeDropWarehouseExpression(BaseExpression): +class SnowflakeDropWarehouseStatement(BaseExpression): """Snowflake DROP WAREHOUSE statement expression. Attributes: diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/warehouse_options.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/warehouse_options.py index 7f0ae1a..c10b5f4 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/warehouse_options.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/warehouse_options.py @@ -10,7 +10,7 @@ - CREATE WAREHOUSE: https://docs.snowflake.com/en/sql-reference/sql/create-warehouse - ALTER WAREHOUSE: https://docs.snowflake.com/en/sql-reference/sql/alter-warehouse """ -from typing import Any, Optional, Tuple, TYPE_CHECKING +from typing import Any, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -64,44 +64,8 @@ def __init__( self.initially_suspended = initially_suspended self.comment = comment - def to_sql(self) -> "Tuple[str, tuple]": - """Generate warehouse options SQL tokens. + @property + def format_method(self) -> str: + """The dialect formatting method that renders these options.""" + return "format_warehouse_options_expression" - Returns: - Tuple of (options SQL string, empty params tuple). - - """ - parts: list[str] = [] - if self.warehouse_size is not None: - parts.append( - f"WAREHOUSE_SIZE = " - f"'{self.dialect._escape_sql_string(self.warehouse_size)}'" - ) - if self.max_cluster_count is not None: - parts.append(f"MAX_CLUSTER_COUNT = {int(self.max_cluster_count)}") - if self.min_cluster_count is not None: - parts.append(f"MIN_CLUSTER_COUNT = {int(self.min_cluster_count)}") - if self.scaling_policy is not None: - parts.append( - f"SCALING_POLICY = " - f"'{self.dialect._escape_sql_string(self.scaling_policy)}'" - ) - if self.auto_suspend is not None: - if isinstance(self.auto_suspend, bool): - parts.append(f"AUTO_SUSPEND = {str(self.auto_suspend).upper()}") - else: - parts.append(f"AUTO_SUSPEND = {int(self.auto_suspend)}") - if self.auto_resume is not None: - parts.append( - f"AUTO_RESUME = {str(bool(self.auto_resume)).upper()}" - ) - if self.initially_suspended is not None: - parts.append( - f"INITIALLY_SUSPENDED = " - f"{str(bool(self.initially_suspended)).upper()}" - ) - if self.comment is not None: - parts.append( - f"COMMENT = '{self.dialect._escape_sql_string(self.comment)}'" - ) - return " ".join(parts), () diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/dml.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/dml.py index 20b0efd..7c82f44 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/dml.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/dml.py @@ -3,13 +3,13 @@ from typing import TYPE_CHECKING -from rhosocial.activerecord.backend.expression.statements import InsertExpression +from rhosocial.activerecord.backend.expression.statements import InsertStatement if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.dialect import SQLDialectBase -class SnowflakeInsertExpression(InsertExpression): +class SnowflakeInsertExpression(InsertStatement): """A Snowflake INSERT statement extending the generic one. Snowflake supports ``INSERT OVERWRITE INTO`` which truncates the target diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/identifier.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/identifier.py index 14dd02c..b0336fd 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/identifier.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/identifier.py @@ -50,5 +50,5 @@ def to_sql(self) -> "Tuple[str, tuple]": Tuple of (SQL string, identifier parameter tuple). """ - sql = self.dialect.format_identifier_dynamic(self.identifier) + sql = self.dialect.format_snowflake_identifier_expression(self.identifier) return sql, (self.identifier,) diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/introspection.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/introspection.py new file mode 100644 index 0000000..84f71ae --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/introspection.py @@ -0,0 +1,213 @@ +# src/rhosocial/activerecord/backend/impl/snowflake/expression/introspection.py +"""Account-object introspection statements for Snowflake. + +Four statements live here, one per question this backend asks the catalog that +core's introspection expressions do not model: + +* :class:`SnowflakeDatabaseListQueryStatement` -- which databases exist. +* :class:`SnowflakeSchemaListQueryStatement` -- which schemas a database holds. +* :class:`SnowflakeWarehouseListQueryStatement` -- which warehouses the role can + see. +* :class:`SnowflakeTableStorageMetricsQueryStatement` -- what one table is + billed for storage. + +They are expressions, which is the point: each one is a *statement node* whose +``to_sql()`` renders through +:class:`~...mixins.introspection.SnowflakeIntrospectionMixin` like every other +node in the tree, so the SQL is assembled from ``Column`` / +``ComparisonPredicate`` / ``Literal`` / ``QueryStatement`` objects by the +dialect rather than written out as text in a builder function. What a query asks +is therefore a structure the rest of the framework can inspect, and the one +place that knows how Snowflake spells it is the dialect. + +Why they are this backend's statements and not core's +===================================================== + +Core's introspection expressions answer for the questions core models: which +tables, which columns, which views, which indexes. It has no expression for a +warehouse (no engine-neutral meaning: not every dialect has compute objects), +and none for the per-table storage breakdown, which is a Snowflake-specific +billing model rather than a catalog concept. Extending core to carry both would +put one backend's account model into every backend's surface, so the statements +are declared here and rendered by this backend's mixin -- the same arrangement +the ``SHOW`` and time-travel expressions use. + +Documentation. **None of these statements was executed against a Snowflake +account** -- this repository has none, and no test here pretends otherwise. The +column lists and value domains were written from the pages linked on each class +and in the mixin's formatters; the tests pin the rendered statement text and the +row parsing, never an account's answer. +""" + +from typing import Optional + +from rhosocial.activerecord.backend.expression.introspection import ( + IntrospectionExpression, +) + +__all__ = [ + "SnowflakeDatabaseListQueryStatement", + "SnowflakeSchemaListQueryStatement", + "SnowflakeWarehouseListQueryStatement", + "SnowflakeTableStorageMetricsQueryStatement", +] + + +class SnowflakeDatabaseListQueryStatement(IntrospectionExpression): + """``SELECT`` over ``INFORMATION_SCHEMA.DATABASES``. + + Documentation: https://docs.snowflake.com/en/sql-reference/info-schema/databases + + The view "displays a row for each database defined in your account" and + "contains all of the databases in the account (regardless of the database's + INFORMATION_SCHEMA used to query the view)". It is therefore the answer to + "which databases exist here", which + :class:`~rhosocial.activerecord.backend.expression.introspection.DatabaseInfoQueryStatement` + is not: that one answers "which database am I connected to". + + Args: + dialect: The dialect that renders this statement. + schema: Unused -- the view is account-level and takes no schema + filter. Present because every introspection expression carries one, + and a caller building these generically passes a schema without + knowing which statements read it. + + Example: + >>> expr = SnowflakeDatabaseListQueryStatement(dialect) + >>> sql, params = expr.to_sql() + """ + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_database_list_query_statement" + + +class SnowflakeSchemaListQueryStatement(IntrospectionExpression): + """``SELECT`` over one database's ``INFORMATION_SCHEMA.SCHEMATA``. + + Documentation: https://docs.snowflake.com/en/sql-reference/info-schema/schemata + + The view "displays a row for each schema in the specified (or current) + database", so it is a **database-level** view: another database is reached + by qualifying the view with it, and ``database=None`` is a real request for + "the database this session is in" rather than a missing argument. + + Args: + dialect: The dialect that renders this statement. + schema: Unused, as on the other list statement; the database is the + container being enumerated here. + database: The database whose schemas to list, or ``None`` for the + session's current one. Rendered as an identifier -- it is part of + the view's name, not a value compared to a column -- and quoted, so + a database created with a case-sensitive name is reachable. + + Example: + >>> expr = SnowflakeSchemaListQueryStatement(dialect, database="ANALYTICS") + >>> sql, params = expr.to_sql() + """ + + def __init__( + self, + dialect, + schema: Optional[str] = None, + database: Optional[str] = None, + ): + super().__init__(dialect, schema) + self.database = database + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_schema_list_query_statement" + + +class SnowflakeWarehouseListQueryStatement(IntrospectionExpression): + """``SHOW WAREHOUSES`` -- there is no Information Schema view for warehouses. + + Documentation: https://docs.snowflake.com/en/sql-reference/sql/show-warehouses + + The view index at https://docs.snowflake.com/en/sql-reference/info-schema + lists no ``WAREHOUSES`` view (the direct + ``/sql-reference/info-schema/warehouses`` URL answers 404), so the ``SHOW`` + command is the documented route, and the existing + :class:`~...expression.show.SnowflakeShowStatement` renders it. + + Two more facts from that page shape what the parser can expect, and both + are reasons to read the rows as they are documented rather than as + Information Schema rows: + + * "You must use double-quoted identifiers because the output column names + for SHOW commands are in lowercase" -- the row keys are lowercase where + every Information Schema column is uppercase. + * "The command doesn't require a running warehouse to execute", where an + Information Schema view needs one ("Warehouse must be running and + currently in use to query the views", + https://docs.snowflake.com/en/sql-reference/info-schema). Listing + warehouses therefore works in a session that has none. + + Args: + dialect: The dialect that renders this statement. + schema: Unused -- warehouses are account-level, not schema objects. + + Example: + >>> expr = SnowflakeWarehouseListQueryStatement(dialect) + >>> sql, params = expr.to_sql() + """ + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_warehouse_list_query_statement" + + +class SnowflakeTableStorageMetricsQueryStatement(IntrospectionExpression): + """Storage billed to one table, joined to its row count and clustering. + + Primary view: https://docs.snowflake.com/en/sql-reference/info-schema/table_storage_metrics + Joined view: https://docs.snowflake.com/en/sql-reference/info-schema/tables + + The storage view "displays table-level storage utilization information, + which is used to calculate the storage billing for each table in the + account, including tables that have been dropped, but are still incurring + storage costs", and it splits the bytes into Active / Time Travel / + Fail-safe / retained-for-clone. It documents no row count, no table type + and no clustering key, so those come from ``TABLES`` -- which documents + ``ROW_COUNT`` ("Number of rows in the table"), ``CLUSTERING_KEY`` + ("Clustering key for the table"), ``AUTO_CLUSTERING_ON`` (``BOOLEAN``) and + ``TABLE_TYPE``. The join is on the three-part name, which both views carry. + + ``table_name`` and ``schema`` are **bind values**, not identifiers: they + are compared to the catalog's columns, not interpolated into an object + name. Unquoted object names are stored uppercase, so a caller passes the + catalog's own spelling. + + Args: + dialect: The dialect that renders this statement. + table_name: The table to measure. + schema: Schema to filter by, or ``None`` to ask for any schema. There + is no ``database`` parameter because the view has none: it belongs + to the session's current database, so the schema is the only + narrowing filter this statement can express and omitting it is a + legitimate request. + + Example: + >>> expr = SnowflakeTableStorageMetricsQueryStatement( + ... dialect, "ORDERS", schema="PUBLIC" + ... ) + >>> sql, params = expr.to_sql() + """ + + def __init__( + self, + dialect, + table_name: str, + schema: Optional[str] = None, + ): + super().__init__(dialect, schema) + self.table_name = table_name + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_table_storage_metrics_query_statement" diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/pivot.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/pivot.py index 57082e3..579f0d2 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/pivot.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/pivot.py @@ -63,7 +63,7 @@ def to_sql(self) -> "Tuple[str, tuple]": Tuple of (clause SQL string, empty params tuple). """ - return self.dialect.format_pivot_clause(self) + return self.dialect.format_pivot_expression(self) class SnowflakeUnpivotExpression(BaseExpression): @@ -116,4 +116,4 @@ def to_sql(self) -> "Tuple[str, tuple]": Tuple of (clause SQL string, empty params tuple). """ - return self.dialect.format_unpivot_clause(self) + return self.dialect.format_unpivot_expression(self) diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/sample.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/sample.py index 982cf99..a999236 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/sample.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/sample.py @@ -15,6 +15,8 @@ from rhosocial.activerecord.backend.expression.bases import BaseExpression +from .coercion import as_member + if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase @@ -26,23 +28,35 @@ ] -class SnowflakeSampleForm(Enum): +class SnowflakeSampleForm(str, Enum): """Syntactic form of the sampling clause. SAMPLE: the ``SAMPLE`` keyword. TABLESAMPLE: the ``TABLESAMPLE`` keyword (synonym of ``SAMPLE``). """ + #: A member *is* the word it carries: ``str()`` and f-string + #: interpolation must yield the value, not ``ClassName.MEMBER``, or the + #: rendered SQL and the artifact would carry the Python spelling. + #: ``(str, Enum)`` alone does not give that -- ``Enum.__str__`` wins -- + #: so it is stated here once. + __str__ = str.__str__ SAMPLE = "SAMPLE" TABLESAMPLE = "TABLESAMPLE" -class SnowflakeSamplingMethod(Enum): +class SnowflakeSamplingMethod(str, Enum): """Sampling method used by TABLESAMPLE. BERNOULLI: row-level sampling, each row is selected independently. SYSTEM: block-level sampling, faster but less statistically uniform. """ + #: A member *is* the word it carries: ``str()`` and f-string + #: interpolation must yield the value, not ``ClassName.MEMBER``, or the + #: rendered SQL and the artifact would carry the Python spelling. + #: ``(str, Enum)`` alone does not give that -- ``Enum.__str__`` wins -- + #: so it is stated here once. + __str__ = str.__str__ BERNOULLI = "BERNOULLI" SYSTEM = "SYSTEM" @@ -70,10 +84,10 @@ def __init__( seed: Optional[int] = None, ): super().__init__(dialect) - self.form = form + self.form = as_member(SnowflakeSampleForm, form, parameter="form") self.count = count self.is_percent = is_percent - self.sampling_method = sampling_method + self.sampling_method = as_member(SnowflakeSamplingMethod, sampling_method, parameter="sampling_method") self.seed = seed def to_sql(self) -> "Tuple[str, tuple]": @@ -85,4 +99,4 @@ def to_sql(self) -> "Tuple[str, tuple]": """ if self.form is SnowflakeSampleForm.TABLESAMPLE: return self.dialect.format_tablesample_clause(self) - return self.dialect.format_sample_clause(self) + return self.dialect.format_sample_expression(self) diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/sequence.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/sequence.py new file mode 100644 index 0000000..5e2deb1 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/sequence.py @@ -0,0 +1,49 @@ +# src/rhosocial/activerecord/backend/impl/snowflake/expression/sequence.py +"""Snowflake's sequence value reference: ``.NEXTVAL``. + +A sequence is not a relation (core's ``Sequence`` object says so), and it is +not reached through a function either: Snowflake reads the next value from a +*property* on the sequence, ``seq.nextval``, where PostgreSQL's spelling is +``nextval('seq')``. Both are ``SequenceNextval``-class operations, but the SQL +they share no more than the name, so the node is this backend's own and its +kind names the backend first. + +The node carries the alias too, because the only reason a statement calls it is +to name the value it returns -- ``SELECT my_seq.NEXTVAL AS next_id`` -- and an +alias applied by a caller would be SQL text built outside a formatter. +""" + +from typing import TYPE_CHECKING, Optional + +from rhosocial.activerecord.backend.expression.bases import AliasableMixin, SQLValue + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + +__all__ = ["SnowflakeNextvalExpression"] + + +class SnowflakeNextvalExpression(AliasableMixin, SQLValue): + """``.NEXTVAL`` -- the next value of a Snowflake sequence. + + Attributes: + sequence: The sequence's name, as it is written before the dot. + alias: Optional name for the value, rendered as ``AS ``. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + sequence: str, + alias: Optional[str] = None, + ): + super().__init__(dialect) + if not isinstance(sequence, str) or not sequence: + raise ValueError("NEXTVAL requires a non-empty sequence name") + self.sequence = sequence + self.alias = alias + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_snowflake_nextval_expression" diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/show.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/show.py index cc5b108..857fc3c 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/show.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/show.py @@ -16,6 +16,8 @@ from rhosocial.activerecord.backend.expression.bases import BaseExpression +from .coercion import as_member + if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase @@ -23,12 +25,18 @@ __all__ = [ "SnowflakeShowObjectType", "SnowflakeShowScope", - "SnowflakeShowExpression", + "SnowflakeShowStatement", ] -class SnowflakeShowObjectType(Enum): +class SnowflakeShowObjectType(str, Enum): """Object types supported by the SHOW command.""" + #: A member *is* the word it carries: ``str()`` and f-string + #: interpolation must yield the value, not ``ClassName.MEMBER``, or the + #: rendered SQL and the artifact would carry the Python spelling. + #: ``(str, Enum)`` alone does not give that -- ``Enum.__str__`` wins -- + #: so it is stated here once. + __str__ = str.__str__ TABLES = "TABLES" VIEWS = "VIEWS" @@ -48,15 +56,21 @@ class SnowflakeShowObjectType(Enum): COLUMNS = "COLUMNS" -class SnowflakeShowScope(Enum): +class SnowflakeShowScope(str, Enum): """Namespace scope for the SHOW ``IN`` clause.""" + #: A member *is* the word it carries: ``str()`` and f-string + #: interpolation must yield the value, not ``ClassName.MEMBER``, or the + #: rendered SQL and the artifact would carry the Python spelling. + #: ``(str, Enum)`` alone does not give that -- ``Enum.__str__`` wins -- + #: so it is stated here once. + __str__ = str.__str__ ACCOUNT = "ACCOUNT" DATABASE = "DATABASE" SCHEMA = "SCHEMA" -class SnowflakeShowExpression(BaseExpression): +class SnowflakeShowStatement(BaseExpression): """Snowflake SHOW statement expression. Attributes: @@ -78,8 +92,8 @@ def __init__( limit: Optional[int] = None, ): super().__init__(dialect) - self.object_type = object_type - self.in_scope = in_scope + self.object_type = as_member(SnowflakeShowObjectType, object_type, parameter="object_type") + self.in_scope = as_member(SnowflakeShowScope, in_scope, parameter="in_scope") self.in_name = in_name self.like = like self.limit = limit diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/types.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/types.py index a4731e4..5e85b74 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/types.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/types.py @@ -49,9 +49,8 @@ type either, it goes ``DataType.to_sql()`` → ``dialect.format_data_type(self)`` → ``format_data_type_``. """ -from typing import Any, Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING -from rhosocial.activerecord.backend.expression.serialization import ExpressionRegistry from rhosocial.activerecord.backend.expression.types._base import DataType from rhosocial.activerecord.backend.expression.types.integer import IntegerType from rhosocial.activerecord.backend.expression.types.string import VarCharType @@ -496,24 +495,10 @@ def __init__( PARAMETERS = ("database_name", "schema_name", "type_name",) -_SNOWFLAKE_DATA_TYPES = ( - SnowflakeVarcharType, - SnowflakeNumberType, - SnowflakeFloatType, - SnowflakeBooleanType, - SnowflakeTimestampLtzType, - SnowflakeTimestampNtzType, - SnowflakeTimestampTzType, - SnowflakeDateType, - SnowflakeTimeType, - SnowflakeBinaryType, - SnowflakeUuidType, - SnowflakeVariantType, - SnowflakeObjectType, - SnowflakeArrayType, - SnowflakeGeographyType, - SnowflakeGeometryType, - SnowflakeUserDefinedType, -) -for _data_type in _SNOWFLAKE_DATA_TYPES: - ExpressionRegistry.register(_data_type) \ No newline at end of file +# No registration loop. Serializing needs no registry -- a node reports its +# own kind -- and reading is the reader's assembly: the framework's default +# registry holds only the packages the framework itself ships, so a reader +# that wants one of these kinds registers it into a registry of its own +# (see ``registry.ExpressionRegistry``). Import-time self-registration would +# promise a readability no reader asked for, and there is no global class +# index left to register into. \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/variant.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/variant.py index bb711d0..48fd769 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/variant.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/variant.py @@ -53,7 +53,7 @@ def to_sql(self) -> "Tuple[str, tuple]": Tuple of (SQL string, empty params tuple). """ - sql, params = self.dialect.format_variant_path_access(self) + sql, params = self.dialect.format_variant_path_access_expression(self) return sql, () @@ -88,5 +88,5 @@ def to_sql(self) -> "Tuple[str, tuple]": Tuple of (SQL string, empty params tuple). """ - sql, params = self.dialect.format_variant_cast(self) + sql, params = self.dialect.format_variant_cast_expression(self) return sql, () diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/wildcard.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/wildcard.py new file mode 100644 index 0000000..c40793f --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/wildcard.py @@ -0,0 +1,189 @@ +# src/rhosocial/activerecord/backend/impl/snowflake/expression/wildcard.py +"""Snowflake's own wildcard spellings: ``* EXCLUDE`` / ``* ILIKE`` and ``COUNT(t.*)``. + +Core splits the one asterisk into two by grammar position (spec 4.10): the +select-list ``*`` expands columns and belongs to ``AllColumnsWildcard``, while +``COUNT(*)``'s asterisk counts rows and belongs to ``RowCountWildcard``. Core +draws the line at the bare asterisk -- ``RowCountWildcard`` accepts no relation, +because standard ``COUNT(*)`` never qualifies it. + +Snowflake then adds two spellings core deliberately leaves to the backends that +have them, both documented on the ``COUNT`` reference page: + +* ``COUNT(.*)`` -- "Returns the number of records that don't contain any + NULL values." That is not the question ``COUNT(*)`` answers (``COUNT(*)`` is + the total number of records, NULLs included), so it is a different node rather + than a flag on the same one, exactly as PostgreSQL's own row-count wildcard is. +* ``* ILIKE ''`` / ``* EXCLUDE | ( , ... )`` -- a filter on + the expansion, valid only in a SELECT list or GROUP BY on this server. The + page states the two keywords *can't be combined in a single call*, so the node + refuses a request that carries both instead of rendering SQL the parser will + reject. + +**Evidence level: not server-verified.** There is no Snowflake instance +reachable from this repository. Every claim above is transcribed from + + https://docs.snowflake.com/en/sql-reference/functions/count + +(the ``*`` argument's own text, including its worked ``COUNT(n.*)`` example), +and both ``SELECT``-list spellings are named there as valid "only in a SELECT +list or GROUP BY clause". Nothing here was measured; a rendering assertion is +not a measurement. + +**Known core gap, recorded rather than worked around.** Core's shared +``format_function_call`` answers a ``RowCountWildcard`` argument with the bare +asterisk *before* any formatter is consulted, so +``FunctionCall(dialect, "COUNT", SnowflakeRowCountWildcard(...))`` renders +``COUNT(*)`` even though the node carries a relation. The qualified spelling is +therefore reachable through the node's own ``to_sql()`` (and through a clause +that dispatches to it), not through the aggregate's factory -- the same gap +PostgreSQL's derived row-count wildcard has, and fixing it means changing core's +shared formatter, which this branch does not do. The behaviour is pinned in +``test_snowflake_wildcards.py`` so a change in core fails there rather than +passing quietly. +""" + +from typing import Optional, Sequence, Union, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.core import ( + AllColumnsWildcard, + RowCountWildcard, +) + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + from rhosocial.activerecord.backend.expression.sources import NamedRelationRef + + +def _validate_filter( + ilike: Optional[str], + exclude: Optional[Union[str, Sequence[str]]], +) -> None: + """Refuse a filter combination or shape Snowflake's grammar does not take. + + Called from both nodes' constructors, so a malformed filter is refused where + it is written rather than at render time -- the same reason the branch nodes + validate in ``__init__``. + + Raises: + TypeError: A pattern or a column name is not a string, or ``exclude`` is + neither a string nor a sequence of strings. + ValueError: Both keywords are given (the reference says they cannot be + combined), or a filter is given empty. + """ + if ilike is not None and exclude is not None: + raise ValueError( + "Snowflake's * ILIKE and * EXCLUDE keywords can't be combined in a " + "single call; ask for one or the other." + ) + if ilike is not None: + if not isinstance(ilike, str): + raise TypeError( + f"* ILIKE takes one pattern string, got {type(ilike).__name__}" + ) + if not ilike: + raise ValueError("* ILIKE needs a pattern: an empty pattern filters nothing") + return + if exclude is None: + return + if isinstance(exclude, str): + if not exclude: + raise ValueError("* EXCLUDE needs a column name or a list of them") + return + if isinstance(exclude, (list, tuple)): + if not exclude: + raise ValueError("* EXCLUDE needs at least one column name") + for column in exclude: + if not isinstance(column, str): + raise TypeError( + f"* EXCLUDE takes column names, got {type(column).__name__}" + ) + return + raise TypeError( + f"* EXCLUDE takes a column name or a sequence of them, got " + f"{type(exclude).__name__}" + ) + + +class SnowflakeAllColumnsWildcard(AllColumnsWildcard): + """Snowflake's filtered all-columns wildcard: ``[* | rel.*] [ILIKE p | EXCLUDE c]``. + + The unfiltered forms are core's: a bare ``*`` or a qualified ``rel.*``. What + this node adds is the Snowflake-only filter, which is a second thing to say + about the same expansion rather than a different expansion -- so it extends + the core node instead of replacing it, and core's ``format_all_columns_wildcard`` + still renders the asterisk itself. + + Args: + dialect: The dialect that renders the node. + relation: The relation whose columns the expansion spans, or ``None`` for + the bare ``*``. Rendered as the query's own spelling for it, which is + core's rule and not restated here. + ilike: A single pattern; renders ``* ILIKE ''``. + exclude: One column name or a sequence of them; renders + ``* EXCLUDE (, )``. A single name renders bare, without + parentheses, because the reference shows both spellings. + + Raises: + TypeError: The pattern or a column name is not a string. + ValueError: Both ``ilike`` and ``exclude`` are given. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + relation: Optional["NamedRelationRef"] = None, + *, + ilike: Optional[str] = None, + exclude: Optional[Union[str, Sequence[str]]] = None, + ): + _validate_filter(ilike, exclude) + super().__init__(dialect, relation=relation) + self.ilike = ilike + self.exclude = exclude + + +class SnowflakeRowCountWildcard(RowCountWildcard): + """``COUNT(.*)`` -- rows of *relation* with no NULL, not rows of the join. + + Core's ``RowCountWildcard`` is the bare asterisk and takes no relation by + contract. Snowflake accepts the qualified form (and filters it too), and the + reference defines it as a *different* count -- "the number of records that + don't contain any NULL values" -- so it is a derived node here, not a flag on + core's. + + With no relation this renders ``*`` and is the plain ``COUNT(*)``; the + relation is what makes the qualified form possible at all. + + Args: + dialect: The dialect that renders the node. + relation: The relation whose rows are counted, or ``None`` for ``*``. + ilike: A single pattern; renders ``... ILIKE ''``. + exclude: One column name or a sequence of them. + + Raises: + TypeError: The pattern or a column name is not a string. + ValueError: Both ``ilike`` and ``exclude`` are given. Note this is the + same rule as ``SnowflakeAllColumnsWildcard``: the reference states it + once, for the argument, not per clause. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + relation: Optional["NamedRelationRef"] = None, + *, + ilike: Optional[str] = None, + exclude: Optional[Union[str, Sequence[str]]] = None, + ): + _validate_filter(ilike, exclude) + super().__init__(dialect) + self.relation = relation + self.ilike = ilike + self.exclude = exclude + + +__all__ = [ + "SnowflakeAllColumnsWildcard", + "SnowflakeRowCountWildcard", +] diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/field.py b/src/rhosocial/activerecord/backend/impl/snowflake/field.py index c27fffc..29d9737 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/field.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/field.py @@ -9,6 +9,8 @@ from pydantic import Field +from ..expression.sequence import SnowflakeNextvalExpression + class SnowflakePKMixin: """Snowflake Sequence-based integer primary key Mixin. @@ -46,8 +48,15 @@ def prepare_save_data(self, data: Dict[str, Any], is_new: bool) -> None: backend = self.__class__.backend() if backend is not None: seq_name = self.__class__._snowflake_sequence_name + # The statement is built by the node and rendered by the + # dialect -- this layer collects a name and reads back a value, + # it does not spell SQL. ``backend.dialect`` is the instance the + # connection was opened with, so the node and the executor agree + # on the dialect by construction. result = backend.execute( - f"SELECT {seq_name}.NEXTVAL AS next_id", () + *SnowflakeNextvalExpression( + backend.dialect, seq_name, alias="next_id" + ).to_sql() ) if result.data and len(result.data) > 0: next_id = result.data[0].get("NEXT_ID") or result.data[0].get("next_id") diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/functions/semi_structured.py b/src/rhosocial/activerecord/backend/impl/snowflake/functions/semi_structured.py index 8626ca5..34debe4 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/functions/semi_structured.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/functions/semi_structured.py @@ -4,9 +4,15 @@ from typing import Union, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core +from rhosocial.activerecord.backend.expression.functions import ( + array_agg as _core_array_agg, +) if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase + from rhosocial.activerecord.backend.expression.functions.array import ( + ArrayAggregateFunction, + ) def _to_expr(dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"]): @@ -106,12 +112,17 @@ def array_contains( def array_agg( dialect: "SQLDialectBase", expr: Union[str, "bases.BaseExpression"], -) -> "core.FunctionCall": - """Create ARRAY_AGG aggregate function call. - - Returns the input values, pivoted into an ARRAY. +) -> "ArrayAggregateFunction": + """Create an ARRAY_AGG aggregate over *expr*. + + The classed node core models for this aggregate (``ArrayAggregateFunction``) + replaces the bare ``FunctionCall`` this factory used to return: the node + carries DISTINCT and the window clauses every aggregate takes, and its + artifact names ``array_aggregate_function`` instead of a generic call. + The spelling is unchanged -- ``ARRAY_AGG(operand)`` -- and a bare string + still becomes a Column, exactly as before. """ - return core.FunctionCall(dialect, "ARRAY_AGG", _to_expr(dialect, expr)) + return _core_array_agg(dialect, expr) # ========== OBJECT Functions ========== diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/introspection/__init__.py b/src/rhosocial/activerecord/backend/impl/snowflake/introspection/__init__.py index 435beb3..b2aad95 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/introspection/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/introspection/__init__.py @@ -10,9 +10,19 @@ SyncSnowflakeIntrospector, AsyncSnowflakeIntrospector, ) +from .types import ( + SnowflakeDatabaseInfo, + SnowflakeSchemaInfo, + SnowflakeTableStorageMetrics, + SnowflakeWarehouseInfo, +) __all__ = [ "SnowflakeIntrospectorMixin", "SyncSnowflakeIntrospector", "AsyncSnowflakeIntrospector", + "SnowflakeDatabaseInfo", + "SnowflakeSchemaInfo", + "SnowflakeTableStorageMetrics", + "SnowflakeWarehouseInfo", ] diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/introspection/introspector.py b/src/rhosocial/activerecord/backend/impl/snowflake/introspection/introspector.py index 6213317..12c4da2 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/introspection/introspector.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/introspection/introspector.py @@ -11,13 +11,13 @@ - SQL generation: Delegated to SnowflakeIntrospectionMixin.format_*_query() methods in the Dialect layer via Expression.to_sql() - Query execution: Handled by IntrospectorExecutor - - Result parsing: _parse_* methods in this module (pure Python, no I/O) + - Result parsing: parse_* methods in this module (pure Python, no I/O) Key behaviours: - Queries INFORMATION_SCHEMA.TABLES, COLUMNS, TABLE_CONSTRAINTS, KEY_COLUMN_USAGE, REFERENTIAL_CONSTRAINTS, VIEWS - - _parse_* methods are pure Python -- shared by sync and async introspectors - - Snowflake does not support triggers; _parse_triggers returns empty list + - parse_* methods are pure Python -- shared by sync and async introspectors + - Snowflake does not support triggers; parse_triggers returns empty list - Snowflake does not have traditional indexes; constraints are used instead Design principle: Sync and Async are separate and cannot coexist. @@ -35,25 +35,41 @@ SyncAbstractIntrospector, AsyncAbstractIntrospector, ) +from rhosocial.activerecord.backend.introspection.cache import ( + IntrospectionCache, +) from rhosocial.activerecord.backend.introspection.executor import ( SyncIntrospectorExecutor, ) from rhosocial.activerecord.backend.introspection.types import ( - DatabaseInfo, - TableInfo, - TableType, - ColumnInfo, + IntrospectionDatabaseInfo, + IntrospectionTableInfo, + IntrospectionColumnInfo, ColumnNullable, IndexInfo, IndexColumnInfo, IndexType, - ForeignKeyInfo, + IntrospectionForeignKeyInfo, ReferentialAction, - ViewInfo, - TriggerInfo, + IntrospectionViewInfo, + IntrospectionTriggerInfo, IntrospectionScope, ) +from ..expression.introspection import ( + SnowflakeDatabaseListQueryStatement, + SnowflakeSchemaListQueryStatement, + SnowflakeTableStorageMetricsQueryStatement, + SnowflakeWarehouseListQueryStatement, +) +from .table_types import core_table_type, read_table_type +from .types import ( + SnowflakeDatabaseInfo, + SnowflakeSchemaInfo, + SnowflakeTableStorageMetrics, + SnowflakeWarehouseInfo, +) + class SnowflakeIntrospectorMixin(IntrospectorMixin): """Mixin providing shared Snowflake-specific introspection logic. @@ -62,13 +78,14 @@ class SnowflakeIntrospectorMixin(IntrospectorMixin): from this mixin to share: - Default schema handling - Snowflake version detection - - _parse_* implementations + - parse_* implementations SQL generation is delegated to the Dialect layer via Expression.to_sql() which calls SnowflakeIntrospectionMixin.format_*_query() methods. """ - def _get_default_schema(self) -> str: + @property + def default_schema(self) -> str: """Return the Snowflake schema name from the backend config. Snowflake uses a three-level namespace: database.schema.table. @@ -82,50 +99,310 @@ def _get_default_schema(self) -> str: return schema or "" return "" - def _get_version(self) -> tuple: - """Return the Snowflake server version tuple from the backend.""" - return getattr(self._backend, '_version', (8, 0, 0)) - # ------------------------------------------------------------------ # # Parse methods — pure Python, no I/O # ------------------------------------------------------------------ # - def _parse_database_info(self, rows: List[Dict[str, Any]]) -> DatabaseInfo: - version = self._get_version() - version_str = ".".join(str(v) for v in version) - db_name = self._get_default_schema() + def parse_database_info(self, rows: List[Dict[str, Any]]) -> IntrospectionDatabaseInfo: + """Parse database info, reporting the version without raising. + + ``self.server_version`` reads the dialect, then the backend's cached + server version, and never opens a connection. A version nothing knows + yet is reported as ``"unknown"`` with the all-zero tuple, per the + introspection contract. + """ + version = self.server_version + version_tuple = version if version is not None else (0, 0, 0) + version_str = ( + "unknown" if version is None else ".".join(str(v) for v in version) + ) + db_name = self.default_schema db_row = rows[0] if rows else {} - return DatabaseInfo( + return IntrospectionDatabaseInfo( name=db_row.get("CATALOG_NAME", db_name), version=version_str, - version_tuple=version, + version_tuple=version_tuple, vendor="Snowflake", ) - def _parse_tables( + # ------------------------------------------------------------------ # + # Account-object parsing -- Snowflake's own views, not core's scopes + # ------------------------------------------------------------------ # + + @staticmethod + def _upper_row(row: Dict[str, Any]) -> Dict[str, Any]: + """One result row with its keys upper-cased. + + ``INFORMATION_SCHEMA`` columns are documented uppercase, but + ``SHOW`` output is documented lowercase (the ``SHOW WAREHOUSES`` + page's sample header is ``name | state | type | ...``). The driver + reproduces whichever spelling the statement produces, so the parser + normalizes before reading and does not have to care which kind of + statement answered. Values are untouched: only the keys are casing. + """ + return {str(key).upper(): value for key, value in row.items()} + + @staticmethod + def _as_flag(value: Any) -> bool: + """Read one of Snowflake's several spellings of a boolean flag. + + The catalog is not consistent, and the difference is documented + rather than incidental: Information Schema columns such as + ``IS_TRANSIENT`` are ``VARCHAR`` holding ``'YES'``/``'NO'`` + (https://docs.snowflake.com/en/sql-reference/info-schema/databases), + while ``SHOW WAREHOUSES`` documents ``is_current`` and friends as + ``Y``/``N`` (https://docs.snowflake.com/en/sql-reference/sql/show-warehouses). + ``TABLES.AUTO_CLUSTERING_ON`` is a real ``BOOLEAN``. Treating an + unreadable value as false matches the "no" default all three + spellings share; treating it as true would invent a capability. + """ + if isinstance(value, bool): + return value + if value is None: + return False + if isinstance(value, (int, float)): + return value != 0 + return str(value).strip().upper() in ("Y", "YES", "TRUE", "1") + + @classmethod + def _as_optional_flag(cls, value: Any) -> Optional[bool]: + """A flag that distinguishes "not reported" from "reported false". + + ``SHOW WAREHOUSES`` documents ``auto_resume`` as a boolean but says + nothing about NULL, and a missing optional property is not the same + answer as "false" -- so the ``None`` is preserved for the fields + where the docs describe a possibly-absent property. + """ + if value is None: + return None + return cls._as_flag(value) + + @staticmethod + def _as_int(value: Any) -> Optional[int]: + """One catalog number as an ``int``, or ``None`` when unreadable. + + The catalog is not under this backend's control; an unreadable + count should cost that field its value, not the whole row. Zero is + preserved: it is a real byte count and a real retention period. + """ + if value is None or isinstance(value, bool): + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + def parse_databases(self, rows: List[Dict[str, Any]]) -> List[SnowflakeDatabaseInfo]: + """Parse ``INFORMATION_SCHEMA.DATABASES`` rows. + + Columns read and their documented meanings are in + :meth:`~..mixins.introspection.SnowflakeIntrospectionMixin.format_database_list_query_statement` + and https://docs.snowflake.com/en/sql-reference/info-schema/databases: + ``DATABASE_NAME``, ``DATABASE_OWNER``, ``IS_TRANSIENT`` (``VARCHAR`` + ``'YES'``/``'NO'``), ``COMMENT``, ``CREATED``, ``LAST_ALTERED``, + ``RETENTION_TIME``, ``TYPE`` and ``OWNER_ROLE_TYPE``. + """ + databases = [] + for row in rows: + normalized = self._upper_row(row) + databases.append( + SnowflakeDatabaseInfo( + name=normalized.get("DATABASE_NAME", ""), + owner=normalized.get("DATABASE_OWNER"), + is_transient=self._as_flag(normalized.get("IS_TRANSIENT")), + comment=normalized.get("COMMENT"), + created=normalized.get("CREATED"), + last_altered=normalized.get("LAST_ALTERED"), + retention_time=self._as_int(normalized.get("RETENTION_TIME")), + database_type=normalized.get("TYPE"), + owner_role_type=normalized.get("OWNER_ROLE_TYPE"), + ) + ) + return databases + + def parse_schemas(self, rows: List[Dict[str, Any]]) -> List[SnowflakeSchemaInfo]: + """Parse ``INFORMATION_SCHEMA.SCHEMATA`` rows. + + Columns read are documented on + https://docs.snowflake.com/en/sql-reference/info-schema/schemata: + ``CATALOG_NAME``, ``SCHEMA_NAME``, ``SCHEMA_OWNER``, + ``IS_TRANSIENT``, ``IS_MANAGED_ACCESS``, ``RETENTION_TIME``, + ``CREATED``, ``LAST_ALTERED`` and ``COMMENT``. + + ``IS_MANAGED_ACCESS`` is kept as a flag rather than dropped: it is + the documented answer to "does this schema use managed access?", and + a caller deciding whether a grant will be honoured needs it. + """ + schemas = [] + for row in rows: + normalized = self._upper_row(row) + schemas.append( + SnowflakeSchemaInfo( + name=normalized.get("SCHEMA_NAME", ""), + catalog_name=normalized.get("CATALOG_NAME"), + owner=normalized.get("SCHEMA_OWNER"), + is_transient=self._as_flag(normalized.get("IS_TRANSIENT")), + is_managed_access=self._as_flag(normalized.get("IS_MANAGED_ACCESS")), + retention_time=self._as_int(normalized.get("RETENTION_TIME")), + created=normalized.get("CREATED"), + last_altered=normalized.get("LAST_ALTERED"), + comment=normalized.get("COMMENT"), + ) + ) + return schemas + + def parse_warehouses(self, rows: List[Dict[str, Any]]) -> List[SnowflakeWarehouseInfo]: + """Parse ``SHOW WAREHOUSES`` rows. + + The row shape is the one documented on + https://docs.snowflake.com/en/sql-reference/sql/show-warehouses: + **lowercase** keys, ``is_default`` / ``is_current`` / + ``is_interactive`` as ``Y``/``N``, ``auto_suspend`` in seconds with + ``null`` for "never", and ``size`` spelled ``X-Small`` ... + ``6X-Large``. ``_upper_row`` bridges the casing and ``_as_flag`` the + ``Y``/``N`` spelling, so nothing here depends on the driver choosing + one casing over the other. + + ``available`` / ``provisioning`` / ``quiescing`` / ``other`` are + percentages documented as possibly empty, and the ``actives`` / + ``pendings`` / ``failed`` / ``suspended`` / ``uuid`` columns are + documented as "for internal use and will be removed in a future + release" -- none is modelled, because a field whose meaning the + vendor has disclaimed is not metadata this backend should promise. + """ + warehouses = [] + for row in rows: + normalized = self._upper_row(row) + warehouses.append( + SnowflakeWarehouseInfo( + name=normalized.get("NAME", ""), + state=normalized.get("STATE"), + warehouse_type=normalized.get("TYPE"), + size=normalized.get("SIZE"), + min_cluster_count=self._as_int(normalized.get("MIN_CLUSTER_COUNT")), + max_cluster_count=self._as_int(normalized.get("MAX_CLUSTER_COUNT")), + started_clusters=self._as_int(normalized.get("STARTED_CLUSTERS")), + running=self._as_int(normalized.get("RUNNING")), + queued=self._as_int(normalized.get("QUEUED")), + is_default=self._as_flag(normalized.get("IS_DEFAULT")), + is_current=self._as_flag(normalized.get("IS_CURRENT")), + is_interactive=self._as_flag(normalized.get("IS_INTERACTIVE")), + auto_suspend=self._as_int(normalized.get("AUTO_SUSPEND")), + auto_resume=self._as_optional_flag(normalized.get("AUTO_RESUME")), + created_on=normalized.get("CREATED_ON"), + resumed_on=normalized.get("RESUMED_ON"), + updated_on=normalized.get("UPDATED_ON"), + owner=normalized.get("OWNER"), + comment=normalized.get("COMMENT"), + scaling_policy=normalized.get("SCALING_POLICY"), + resource_constraint=normalized.get("RESOURCE_CONSTRAINT"), + generation=self._as_int(normalized.get("GENERATION")), + enable_query_acceleration=self._as_optional_flag( + normalized.get("ENABLE_QUERY_ACCELERATION") + ), + query_acceleration_max_scale_factor=self._as_int( + normalized.get("QUERY_ACCELERATION_MAX_SCALE_FACTOR") + ), + resource_monitor=normalized.get("RESOURCE_MONITOR"), + owner_role_type=normalized.get("OWNER_ROLE_TYPE"), + ) + ) + return warehouses + + def parse_table_storage_metrics( + self, + rows: List[Dict[str, Any]], + table_name: str, + schema: Optional[str], + ) -> Optional[SnowflakeTableStorageMetrics]: + """Parse the storage-metrics row for one table, or ``None``. + + The byte columns are the ones documented on + https://docs.snowflake.com/en/sql-reference/info-schema/table_storage_metrics; + ``ROW_COUNT``, ``CLUSTERING_KEY``, ``AUTO_CLUSTERING_ON`` and + ``TABLE_TYPE`` come from the joined ``TABLES`` row, documented on + https://docs.snowflake.com/en/sql-reference/info-schema/tables. + See :meth:`~..mixins.introspection.SnowflakeIntrospectionMixin.format_table_storage_metrics_query_statement` + for why the join exists and why only the current table version is asked + for. + + Only the current version is used even if a wider result set arrives: + a row with ``TABLE_DROPPED`` set describes a table that no longer + exists, and reporting it as this table's metrics would answer a + question nobody asked. The first current row wins; a result with no + current row falls back to the first row so a caller that widened the + query still gets the row it selected rather than a silent ``None``. + + ``None`` is returned for no rows, which -- per the view's documented + ``ACCOUNTADMIN`` requirement -- can also mean "this role may not see + the metrics". The caller cannot tell the two apart from this view; + the docstring of :meth:`get_table_storage_metrics` says so instead of + pretending otherwise. + """ + if not rows: + return None + + normalized_rows = [self._upper_row(row) for row in rows] + row = next( + (candidate for candidate in normalized_rows if candidate.get("TABLE_DROPPED") is None), + normalized_rows[0], + ) + + return SnowflakeTableStorageMetrics( + table_name=row.get("TABLE_NAME") or table_name, + table_catalog=row.get("TABLE_CATALOG"), + table_schema=row.get("TABLE_SCHEMA") or schema or None, + table_id=self._as_int(row.get("ID")), + clone_group_id=self._as_int(row.get("CLONE_GROUP_ID")), + is_transient=self._as_flag(row.get("IS_TRANSIENT")), + table_type=row.get("TABLE_TYPE"), + active_bytes=self._as_int(row.get("ACTIVE_BYTES")) or 0, + time_travel_bytes=self._as_int(row.get("TIME_TRAVEL_BYTES")) or 0, + failsafe_bytes=self._as_int(row.get("FAILSAFE_BYTES")) or 0, + retained_for_clone_bytes=self._as_int( + row.get("RETAINED_FOR_CLONE_BYTES") + ) + or 0, + row_count=self._as_int(row.get("ROW_COUNT")), + clustering_key=row.get("CLUSTERING_KEY"), + auto_clustering_on=self._as_flag(row.get("AUTO_CLUSTERING_ON")), + table_created=row.get("TABLE_CREATED"), + table_dropped=row.get("TABLE_DROPPED"), + table_entered_failsafe=row.get("TABLE_ENTERED_FAILSAFE"), + comment=row.get("COMMENT"), + ) + + def parse_tables( self, rows: List[Dict[str, Any]], schema: Optional[str] - ) -> List[TableInfo]: - target_schema = schema if schema is not None else self._get_default_schema() - table_type_map = { - "BASE TABLE": TableType.BASE_TABLE, - "VIEW": TableType.VIEW, - "SYSTEM TABLE": TableType.SYSTEM_TABLE, - "EXTERNAL TABLE": TableType.EXTERNAL, - "TEMPORARY TABLE": TableType.TEMPORARY, - } + ) -> List[IntrospectionTableInfo]: + """Turn a table listing into records, keeping the catalogue's word. + + ``TABLE_TYPE`` is read in Snowflake's vocabulary (see + :mod:`.table_types`) and the raw spelling is stored on every record as + ``extra["snowflake_table_type"]``. It is always present, not only for + the vendor kinds: a caller that wants to filter on + ``"ICEBERG TABLE"`` or tell ``"BASE TABLE"`` from ``"MANAGED TABLE"`` + needs the spelling the catalogue actually reported, and a field that + exists only for some rows cannot be filtered on at all. + + A spelling this backend does not know still reads as ``BASE_TABLE``, + because collapsing it is the only honest answer when the core enum has + no member for it -- but the spelling is kept, so the fold is no longer + silent. + """ + target_schema = schema if schema is not None else self.default_schema tables = [] for row in rows: - t_type = table_type_map.get( - row.get("TABLE_TYPE", "BASE TABLE"), TableType.BASE_TABLE - ) + raw_type = read_table_type(row.get("TABLE_TYPE")) tables.append( - TableInfo( + IntrospectionTableInfo( name=row["TABLE_NAME"], schema=target_schema, - table_type=t_type, + table_type=core_table_type(raw_type), comment=row.get("COMMENT"), + extra={"snowflake_table_type": raw_type}, ) ) return tables @@ -247,7 +524,7 @@ def _catalog_type_string( Snowflake splits what a caller writes as a single type across several catalog columns, so handing ``parse_type`` the bare ``DATA_TYPE`` alone throws away the size and only the size. Measured on this worktree with - the query in :meth:`~...mixins.introspection.SnowflakeIntrospectionMixin.format_column_info_query`: + the query in :meth:`~...mixins.introspection.SnowflakeIntrospectionMixin.format_column_info_query_statement`: a ``VARCHAR(50)`` column answered ``16777216`` -- the *bare* word reached the parser and the parser honestly filled in Snowflake's documented default -- and a ``NUMBER(10, 2)`` column answered ``DecimalType()`` with @@ -301,7 +578,7 @@ def _catalog_type_string( # An unreadable word is caught here rather than raised, because not being # able to name its concept is the same as not knowing which column would # have carried its size -- so nothing is composed, the bare word is handed - # on, and :meth:`_parse_data_type` is where the unreadable type is + # on, and :meth:`parse_data_type` is where the unreadable type is # reported and degraded to ``None`` for that one column. try: concept = getattr( @@ -328,12 +605,12 @@ def _catalog_type_string( return word @staticmethod - def _parse_data_type( + def parse_data_type( composed: str, row: Dict[str, Any], dialect: Any ) -> Optional[Any]: """Read one composed catalog type string into a ``DataType``, or ``None``. - ``ColumnInfo.parsed_data_type`` is ``None`` for exactly one reason -- + ``IntrospectionColumnInfo.parsed_data_type`` is ``None`` for exactly one reason -- the catalog string could not be read -- and core's differ branches on it, falling back to comparing the ``data_type`` *strings*. So an unreadable column must degrade to ``None`` rather than abort @@ -364,12 +641,12 @@ def _parse_data_type( ) return None - def _parse_columns( + def parse_columns( self, rows: List[Dict[str, Any]], table_name: str, schema: str, - ) -> List[ColumnInfo]: + ) -> List[IntrospectionColumnInfo]: columns = [] dialect = getattr(self._backend, "dialect", None) for row in rows: @@ -388,7 +665,7 @@ def _parse_columns( self._catalog_type_string(row, dialect) if dialect else data_type ) columns.append( - ColumnInfo( + IntrospectionColumnInfo( name=row["COLUMN_NAME"], table_name=table_name, schema=schema, @@ -396,7 +673,7 @@ def _parse_columns( data_type=data_type.lower(), data_type_full=composed, parsed_data_type=( - self._parse_data_type(composed, row, dialect) + self.parse_data_type(composed, row, dialect) if dialect else None ), @@ -411,7 +688,7 @@ def _parse_columns( ) return columns - def _parse_indexes( + def parse_indexes( self, rows: List[Dict[str, Any]], table_name: str, @@ -448,12 +725,12 @@ def _parse_indexes( ) return list(constraint_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, "RESTRICT": ReferentialAction.RESTRICT, @@ -461,13 +738,13 @@ def _parse_foreign_keys( "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("CONSTRAINT_NAME", "") if fk_name not in fk_map: on_update_raw = (row.get("UPDATE_RULE") or "NO ACTION").upper() on_delete_raw = (row.get("DELETE_RULE") or "NO ACTION").upper() - fk_map[fk_name] = ForeignKeyInfo( + fk_map[fk_name] = IntrospectionForeignKeyInfo( name=fk_name, table_name=table_name, schema=schema, @@ -483,11 +760,11 @@ 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]: return [ - ViewInfo( + IntrospectionViewInfo( name=row.get("TABLE_NAME", ""), schema=schema, definition=row.get("VIEW_DEFINITION"), @@ -497,16 +774,16 @@ def _parse_views( 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("TABLE_NAME", view_name), schema=schema, definition=row.get("VIEW_DEFINITION"), @@ -514,9 +791,9 @@ def _parse_view_info( 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]: """Snowflake does not support triggers; always returns empty list.""" return [] @@ -533,17 +810,20 @@ class SyncSnowflakeIntrospector( """ 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 @@ -556,9 +836,145 @@ 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 + # ------------------------------------------------------------------ # + # Account-level objects: databases, schemas, warehouses, storage + # ------------------------------------------------------------------ # + + def list_databases(self) -> List[SnowflakeDatabaseInfo]: + """List every database the session's role can see. + + Reads ``SNOWFLAKE.INFORMATION_SCHEMA.DATABASES``; see + :meth:`~..mixins.introspection.SnowflakeIntrospectionMixin.format_database_list_query_statement` + for the columns and the reason the view is qualified with ``SNOWFLAKE``. + This answers "which + databases exist in the account", which is not what + :meth:`get_database_info` answers ("which database am I connected + to") -- hence the ``extra`` on the cache key: both live in + :attr:`IntrospectionScope.DATABASE`, and without it the two questions + would serve each other's answers. + + The view is documented to return fewer rows than ``SHOW DATABASES`` + for a holder of ``MANAGE GRANTS``, so this is the privilege-filtered + answer, not a claim of omniscience. + """ + key = self._cache.key(IntrospectionScope.DATABASE, extra="databases") + cached = self._cache.get(key) + if cached is not None: + return cached + sql, params = SnowflakeDatabaseListQueryStatement(self.dialect).to_sql() + result = self.parse_databases(self._executor.execute(sql, params)) + self._cache.set(key, result) + return result + + def list_schemas(self, database: Optional[str] = None) -> List[SnowflakeSchemaInfo]: + """List the schemas of *database*, or of the session's current one. + + ``SCHEMATA`` is a database-level view (documented on + https://docs.snowflake.com/en/sql-reference/info-schema/schemata), + so ``database=None`` is a real choice -- "the current database" -- + and not a default standing in for a required argument. Passing a + name qualifies the view with it; see + :meth:`~..mixins.introspection.SnowflakeIntrospectionMixin.format_schema_list_query_statement` + for the quoting rule. + + The ``database`` is part of the cache key as a positional + discriminator, so ``list_schemas()`` and ``list_schemas("OTHER")`` + cannot answer each other -- ``schema=`` would be the wrong slot for + it, because that parameter names the schema of the *requested* + objects, and here the database is the container being enumerated. + """ + key = self._cache.key( + IntrospectionScope.SCHEMA, database or "*", extra="schemas" + ) + cached = self._cache.get(key) + if cached is not None: + return cached + sql, params = SnowflakeSchemaListQueryStatement( + self.dialect, database=database + ).to_sql() + result = self.parse_schemas(self._executor.execute(sql, params)) + self._cache.set(key, result) + return result + + def list_warehouses(self) -> List[SnowflakeWarehouseInfo]: + """List the virtual warehouses the session's role can see. + + ``SHOW WAREHOUSES`` is the documented route because there is no + Information Schema view for warehouses; see + :meth:`~..mixins.introspection.SnowflakeIntrospectionMixin.format_warehouse_list_query_statement` + for the evidence, the lowercase row shape and why ``RESULT_SCAN`` is + not used. + + Warehouses are account-level compute, not schema objects, and core's + :class:`IntrospectionScope` has no warehouse member. The closest + scope is ``DATABASE`` -- the account-level one -- and the ``extra`` + keeps it from colliding with both ``get_database_info`` and + ``list_databases``. Core is not edited to add a scope this one + backend needs; the key policy is the extension point it documents. + + Unlike the Information Schema queries, this one is documented not to + require a running warehouse: "The command doesn't require a running + warehouse to execute." + """ + key = self._cache.key(IntrospectionScope.DATABASE, extra="warehouses") + cached = self._cache.get(key) + if cached is not None: + return cached + sql, params = SnowflakeWarehouseListQueryStatement(self.dialect).to_sql() + result = self.parse_warehouses(self._executor.execute(sql, params)) + self._cache.set(key, result) + return result + + def get_table_storage_metrics( + self, table_name: str, schema: Optional[str] = None + ) -> Optional[SnowflakeTableStorageMetrics]: + """Billed storage, row count and clustering for one live table. + + Read ``INFORMATION_SCHEMA.TABLE_STORAGE_METRICS`` joined to + ``INFORMATION_SCHEMA.TABLES``; the columns, the join and the + current-version filter are explained in + :meth:`~..mixins.introspection.SnowflakeIntrospectionMixin.format_table_storage_metrics_query_statement`. + + The view belongs to the session's current **database**, so this + method has no ``database`` argument: it answers for the database the + session is in, exactly like the other schema introspection methods. + *schema* defaults to the configured schema, as elsewhere; when there + is no configured schema either, the statement does not filter by + schema and the catalog's ``TABLE_SCHEMA`` is reported back. + + ``None`` means "no metrics row", which the documentation makes + ambiguous on purpose: the view "is visible to other views and can be + queried, but the queries will return no rows" for roles other than + ``ACCOUNTADMIN``. This method cannot distinguish "the table is gone" + from "your role cannot see the metrics", and does not pretend to. + + ``None`` is not cached, following :meth:`get_view_info`: an absent + answer may become present after a grant or a load, and caching a + permission-shaped ``None`` would outlive the permission. + """ + target_schema = schema if schema is not None else self.default_schema + key = self._cache.key( + IntrospectionScope.TABLE, + table_name, + schema=schema, + extra="storage_metrics", + ) + cached = self._cache.get(key) + if cached is not None: + return cached + sql, params = SnowflakeTableStorageMetricsQueryStatement( + self.dialect, table_name, schema=target_schema + ).to_sql() + result = self.parse_table_storage_metrics( + self._executor.execute(sql, params), table_name, target_schema + ) + if result is not None: + self._cache.set(key, result) + return result + class _SnowflakeAsyncIntrospectorExecutor: """Async executor wrapping synchronous Snowflake cursor operations. @@ -609,17 +1025,20 @@ class AsyncSnowflakeIntrospector( """ def __init__( - self, backend: Any, executor: _SnowflakeAsyncIntrospectorExecutor + self, + backend: Any, + executor: _SnowflakeAsyncIntrospectorExecutor, + 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 @@ -632,5 +1051,91 @@ async def get_table_info( table.columns = await self.list_columns(table_name, schema) table.indexes = await self.list_indexes(table_name, schema) table.foreign_keys = await self.list_foreign_keys(table_name, schema) - self._set_cached(key, table) + self._cache.set(key, table) return table + + # ------------------------------------------------------------------ # + # Account-level objects: databases, schemas, warehouses, storage + # Method names and signatures match the sync class exactly. + # ------------------------------------------------------------------ # + + async def list_databases(self) -> List[SnowflakeDatabaseInfo]: + """List every database the session's role can see. + + Asynchronous twin of :meth:`SyncSnowflakeIntrospector.list_databases`; + the SQL, parsing and cache key are shared through the mixin, so only + the ``await`` differs. + """ + key = self._cache.key(IntrospectionScope.DATABASE, extra="databases") + cached = self._cache.get(key) + if cached is not None: + return cached + sql, params = SnowflakeDatabaseListQueryStatement(self.dialect).to_sql() + result = self.parse_databases(await self._executor.execute(sql, params)) + self._cache.set(key, result) + return result + + async def list_schemas( + self, database: Optional[str] = None + ) -> List[SnowflakeSchemaInfo]: + """List the schemas of *database*, or of the session's current one. + + Asynchronous twin of :meth:`SyncSnowflakeIntrospector.list_schemas`. + """ + key = self._cache.key( + IntrospectionScope.SCHEMA, database or "*", extra="schemas" + ) + cached = self._cache.get(key) + if cached is not None: + return cached + sql, params = SnowflakeSchemaListQueryStatement( + self.dialect, database=database + ).to_sql() + result = self.parse_schemas(await self._executor.execute(sql, params)) + self._cache.set(key, result) + return result + + async def list_warehouses(self) -> List[SnowflakeWarehouseInfo]: + """List the virtual warehouses the session's role can see. + + Asynchronous twin of :meth:`SyncSnowflakeIntrospector.list_warehouses`. + """ + key = self._cache.key(IntrospectionScope.DATABASE, extra="warehouses") + cached = self._cache.get(key) + if cached is not None: + return cached + sql, params = SnowflakeWarehouseListQueryStatement(self.dialect).to_sql() + result = self.parse_warehouses(await self._executor.execute(sql, params)) + self._cache.set(key, result) + return result + + async def get_table_storage_metrics( + self, table_name: str, schema: Optional[str] = None + ) -> Optional[SnowflakeTableStorageMetrics]: + """Billed storage, row count and clustering for one live table. + + Asynchronous twin of + :meth:`SyncSnowflakeIntrospector.get_table_storage_metrics`, including + the documented ``ACCOUNTADMIN`` caveat on the view and the decision + not to cache ``None``. + """ + target_schema = schema if schema is not None else self.default_schema + key = self._cache.key( + IntrospectionScope.TABLE, + table_name, + schema=schema, + extra="storage_metrics", + ) + cached = self._cache.get(key) + if cached is not None: + return cached + sql, params = SnowflakeTableStorageMetricsQueryStatement( + self.dialect, table_name, schema=target_schema + ).to_sql() + result = self.parse_table_storage_metrics( + await self._executor.execute(sql, params), table_name, target_schema + ) + if result is not None: + self._cache.set(key, result) + return result + diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/introspection/table_types.py b/src/rhosocial/activerecord/backend/impl/snowflake/introspection/table_types.py new file mode 100644 index 0000000..d1cc269 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/snowflake/introspection/table_types.py @@ -0,0 +1,117 @@ +# src/rhosocial/activerecord/backend/impl/snowflake/introspection/table_types.py +"""Snowflake's ``INFORMATION_SCHEMA.TABLES.TABLE_TYPE`` vocabulary. + +The catalogue's spelling is the fact; the core +:class:`~rhosocial.activerecord.backend.introspection.types.TableType` member +is this backend's reading of it. Both directions live here so the parser and +the query formatter cannot drift apart: :data:`SNOWFLAKE_TABLE_TYPE_MAP` is the +one table that says which spelling means which member, and +:func:`catalogue_spellings` derives a filter's value list from that same table. + +The ``TABLES`` page lists six spellings -- ``BASE TABLE``, ``TEMPORARY TABLE``, +``EXTERNAL TABLE``, ``EVENT TABLE``, ``VIEW`` and ``MATERIALIZED VIEW`` +(https://docs.snowflake.com/en/sql-reference/info-schema/tables) -- but +Snowflake has since grown table kinds of its own, each documented as an object +in its own right: Snowflake-managed tables, event tables, Iceberg tables, +hybrid tables and dynamic tables. Their catalogue spellings are recorded here +rather than being left to an unrecognised-value fallback, because folding one +of them into ``BASE_TABLE`` with no trace is exactly what erases the only +distinction the catalogue made. The raw spelling survives in +``IntrospectionTableInfo.extra["snowflake_table_type"]``. +""" + +from typing import Optional, Tuple + +from rhosocial.activerecord.backend.introspection.types import TableType + +#: Snowflake's ``TABLE_TYPE`` spellings -> the core member each one means. +#: +#: A kind the core enum has no member for still needs a member, and none of +#: the vendor kinds is a view: they are all tables a caller can select from, +#: so they read as ``BASE_TABLE`` beside their own spelling in ``extra``. +#: ``DYNAMIC TABLE`` is deliberately not ``MATERIALIZED_VIEW``: Snowflake's +#: catalogue distinguishes the two, and reporting a dynamic table as a +#: materialized view would make a listing built with ``include_views=False`` +#: return a row whose member says it is a view. +#: +#: https://docs.snowflake.com/en/sql-reference/info-schema/tables +#: https://docs.snowflake.com/en/sql-reference/sql/show-iceberg-tables +#: https://docs.snowflake.com/en/user-guide/dynamic-tables/overview +#: https://docs.snowflake.com/en/user-guide/tables-hybrid +SNOWFLAKE_TABLE_TYPE_MAP = { + "BASE TABLE": TableType.BASE_TABLE, + "MANAGED TABLE": TableType.BASE_TABLE, + "EVENT TABLE": TableType.BASE_TABLE, + "HYBRID TABLE": TableType.BASE_TABLE, + "ICEBERG TABLE": TableType.BASE_TABLE, + "DYNAMIC TABLE": TableType.BASE_TABLE, + "VIEW": TableType.VIEW, + "MATERIALIZED VIEW": TableType.MATERIALIZED_VIEW, + "SYSTEM TABLE": TableType.SYSTEM_TABLE, + "TEMPORARY TABLE": TableType.TEMPORARY, + "EXTERNAL TABLE": TableType.EXTERNAL, +} + +#: The spellings that are views rather than tables. ``include_views=False`` +#: removes exactly these and leaves every table kind -- including the vendor +#: kinds above -- visible. +SNOWFLAKE_VIEW_TYPES = ("VIEW", "MATERIALIZED VIEW") + + +def read_table_type(value: Optional[str]) -> str: + """One row's catalogue spelling, in the catalogue's own case. + + ``TABLE_TYPE`` is documented as a ``VARCHAR`` the catalogue fills in; an + absent value is read as ``BASE TABLE``, which is what the parser assumed + before any spelling was recorded. Upper-casing is how the catalogue + spells every value, so a fixture that preserved a driver's case still + matches the map. + """ + return str(value or "BASE TABLE").strip().upper() + + +def core_table_type(value: Optional[str]) -> TableType: + """The core member a catalogue spelling means.""" + return SNOWFLAKE_TABLE_TYPE_MAP.get(read_table_type(value), TableType.BASE_TABLE) + + +def catalogue_spellings(table_type) -> Tuple[str, ...]: + """The catalogue spellings one requested ``table_type`` selects. + + Three answers, in this order of authority: + + * an exact catalogue spelling -- ``"ICEBERG TABLE"``, ``"VIEW"`` -- + selects exactly that spelling, so a caller can filter on the raw + vocabulary the catalogue reported; + * a core ``TableType`` name or member -- ``"MATERIALIZED_VIEW"``, + ``"EXTERNAL"`` -- expands onto every spelling mapped to that member, + which is how a caller who never saw Snowflake's vocabulary asks for a + kind; + * a spelling this backend does not know is passed through unchanged, the + way the formatter used to treat every value, so a kind Snowflake adds + later is filterable before this table learns it. + + A core kind the catalogue has no spelling for -- ``FOREIGN_TABLE``, + ``PARTITIONED_TABLE`` -- answers with an empty tuple. The formatter turns + that into a comparison that cannot hold rather than dropping the + condition and answering with every table. + """ + if isinstance(table_type, TableType): + return tuple( + spelling + for spelling, member in SNOWFLAKE_TABLE_TYPE_MAP.items() + if member is table_type + ) + if not table_type: + return () + raw = str(table_type).strip().upper() + if raw in SNOWFLAKE_TABLE_TYPE_MAP: + return (raw,) + member = TableType.__members__.get(raw) + if member is not None: + return tuple( + spelling + for spelling, mapped in SNOWFLAKE_TABLE_TYPE_MAP.items() + if mapped is member + ) + return (raw,) diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/introspection/types.py b/src/rhosocial/activerecord/backend/impl/snowflake/introspection/types.py new file mode 100644 index 0000000..af92dee --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/snowflake/introspection/types.py @@ -0,0 +1,228 @@ +# src/rhosocial/activerecord/backend/impl/snowflake/introspection/types.py +"""Snowflake account-object metadata, as typed immutable values. + +These are *this backend's* answers, not core's: core has no scope for a +warehouse, and ``IntrospectionDatabaseInfo`` answers "which database am I +connected to?" rather than "which databases exist in the account?". Each +class here mirrors one documented Snowflake result set and says which +document it was written from. + +Frozen because they are cache values: the cache hands the same object to +every caller, so an answer that a caller could mutate would let one caller +change what the next one sees. ``total_billed_bytes`` and friends are +properties rather than stored fields for the same reason -- derived values +cannot disagree with the columns they are derived from. + +**Nothing here was run against a Snowflake account.** The column names and +value domains come from the documentation linked on each class; the test +suite asserts the row -> value mapping against fabricated rows, which pins +the parser and the SQL text but cannot pin the account's answer. +""" + +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + + +@dataclass(frozen=True) +class SnowflakeDatabaseInfo: + """One row of ``INFORMATION_SCHEMA.DATABASES``. + + Documentation: https://docs.snowflake.com/en/sql-reference/info-schema/databases + + The columns read are the view's own: ``DATABASE_NAME``, + ``DATABASE_OWNER``, ``IS_TRANSIENT`` (documented ``VARCHAR``, ``'YES'`` + or ``'NO'``), ``COMMENT``, ``CREATED`` and ``LAST_ALTERED`` + (``TIMESTAMP_LTZ``), ``RETENTION_TIME`` (``NUMBER``), ``TYPE`` and + ``OWNER_ROLE_TYPE`` (``VARCHAR``). ``TYPE`` is documented as one of + ``APPLICATION``, ``APPLICATION_PACKAGE``, ``STANDARD``, + ``IMPORTED DATABASE`` and ``PERSONAL DATABASE``; it is stored under + ``database_type`` so the field does not read as a Python type. + """ + + name: str + owner: Optional[str] = None + is_transient: bool = False + comment: Optional[str] = None + created: Optional[datetime] = None + last_altered: Optional[datetime] = None + retention_time: Optional[int] = None + database_type: Optional[str] = None + owner_role_type: Optional[str] = None + + +@dataclass(frozen=True) +class SnowflakeSchemaInfo: + """One row of ``INFORMATION_SCHEMA.SCHEMATA`` in a chosen database. + + Documentation: https://docs.snowflake.com/en/sql-reference/info-schema/schemata + + Columns read: ``CATALOG_NAME`` (the database the schema belongs to), + ``SCHEMA_NAME``, ``SCHEMA_OWNER``, ``IS_TRANSIENT`` and + ``IS_MANAGED_ACCESS`` (both documented ``VARCHAR``), ``RETENTION_TIME`` + (``NUMBER``), ``CREATED`` / ``LAST_ALTERED`` (``TIMESTAMP_LTZ``) and + ``COMMENT``. + + ``catalog_name`` is kept rather than assumed from the request: the view + answers for the database it is qualified with, and keeping the reported + catalog means a result cannot silently claim the wrong database when a + caller passes ``database=None`` and the session's current database is + something other than what they expected. + """ + + name: str + catalog_name: Optional[str] = None + owner: Optional[str] = None + is_transient: bool = False + is_managed_access: bool = False + retention_time: Optional[int] = None + created: Optional[datetime] = None + last_altered: Optional[datetime] = None + comment: Optional[str] = None + + +@dataclass(frozen=True) +class SnowflakeWarehouseInfo: + """One row of ``SHOW WAREHOUSES``. + + Documentation: https://docs.snowflake.com/en/sql-reference/sql/show-warehouses + + ``SHOW`` output columns are documented in **lowercase** (the page's + sample output header is ``name | state | type | ...``), which is why the + parser normalizes row keys before reading them. ``state`` is documented + as ``STARTED``, ``SUSPENDED`` or ``RESIZING``; ``type`` as ``STANDARD`` + or ``SNOWPARK-OPTIMIZED``; ``size`` as ``X-Small`` ... ``6X-Large``. + + ``is_default`` and ``is_current`` are documented as ``Y``/``N`` -- not + the ``YES``/``NO`` that ``INFORMATION_SCHEMA`` uses -- so the flag + parser accepts both. ``auto_suspend`` is documented as seconds with + ``null`` meaning "never"; ``generation`` is a number in the page's + sample output (1, 2, NULL) and is stored as an int. + """ + + name: str + state: Optional[str] = None + warehouse_type: Optional[str] = None + size: Optional[str] = None + min_cluster_count: Optional[int] = None + max_cluster_count: Optional[int] = None + started_clusters: Optional[int] = None + running: Optional[int] = None + queued: Optional[int] = None + is_default: bool = False + is_current: bool = False + is_interactive: bool = False + auto_suspend: Optional[int] = None + auto_resume: Optional[bool] = None + created_on: Optional[datetime] = None + resumed_on: Optional[datetime] = None + updated_on: Optional[datetime] = None + owner: Optional[str] = None + comment: Optional[str] = None + scaling_policy: Optional[str] = None + resource_constraint: Optional[str] = None + generation: Optional[int] = None + enable_query_acceleration: Optional[bool] = None + query_acceleration_max_scale_factor: Optional[int] = None + resource_monitor: Optional[str] = None + owner_role_type: Optional[str] = None + + +@dataclass(frozen=True) +class SnowflakeTableStorageMetrics: + """Storage billed to one table, from ``TABLE_STORAGE_METRICS``. + + Documented columns and their meanings are on + https://docs.snowflake.com/en/sql-reference/info-schema/table_storage_metrics: + + * ``TABLE_CATALOG``, ``TABLE_SCHEMA``, ``TABLE_NAME`` -- where the table + is. + * ``ID`` and ``CLONE_GROUP_ID`` -- ``NUMBER``. They are equal when the + table is not a clone, and ``CLONE_GROUP_ID`` names the oldest clone + ancestor. + * ``IS_TRANSIENT`` -- ``'YES'`` if transient or temporary. + * ``ACTIVE_BYTES``, ``TIME_TRAVEL_BYTES``, ``FAILSAFE_BYTES``, + ``RETAINED_FOR_CLONE_BYTES`` -- the byte categories the page splits + billed storage into. + * ``TABLE_CREATED``, ``TABLE_DROPPED``, ``TABLE_ENTERED_FAILSAFE``, + ``COMMENT`` -- lifecycle and comment. + + ``ROW_COUNT``, ``CLUSTERING_KEY``, ``AUTO_CLUSTERING_ON`` and + ``TABLE_TYPE`` are **not** columns of this view; the page lists neither + of them. They come from ``INFORMATION_SCHEMA.TABLES`` in the query that + fills this object (https://docs.snowflake.com/en/sql-reference/info-schema/tables, + which documents ``CLUSTERING_KEY`` as "Clustering key for the table", + ``ROW_COUNT`` as "Number of rows in the table", ``AUTO_CLUSTERING_ON`` + as a ``BOOLEAN`` and ``TABLE_TYPE`` as one of ``BASE TABLE``, + ``TEMPORARY TABLE``, ``EXTERNAL TABLE``, ``EVENT TABLE``, ``VIEW`` or + ``MATERIALIZED VIEW``). They are optional because the storage view also + keeps rows for **dropped** tables, which ``TABLES`` no longer lists; + the query filters to the current version so a dropped row is not + reported as if it had a live row count. + + Partition information is not a column of either view. Snowflake + micro-partitions are not user-defined objects, and the documented + clustering signal is ``CLUSTERING_KEY`` on ``TABLES``, which is what + :attr:`is_clustered` reads. There is deliberately no invented + ``partition_count`` field: the account does not report one here, and a + fabricated number would be worse than an absent one. + + The view is documented as visible only to ``ACCOUNTADMIN``: "The view + is visible to other views and can be queried, but the queries will + return no rows." So an empty answer is not an error and the caller must + read it as "no metrics for this role", not "no data in this table". + """ + + table_name: str + table_catalog: Optional[str] = None + table_schema: Optional[str] = None + table_id: Optional[int] = None + clone_group_id: Optional[int] = None + is_transient: bool = False + table_type: Optional[str] = None + active_bytes: int = 0 + time_travel_bytes: int = 0 + failsafe_bytes: int = 0 + retained_for_clone_bytes: int = 0 + row_count: Optional[int] = None + clustering_key: Optional[str] = None + auto_clustering_on: bool = False + table_created: Optional[datetime] = None + table_dropped: Optional[datetime] = None + table_entered_failsafe: Optional[datetime] = None + comment: Optional[str] = None + + @property + def total_billed_bytes(self) -> int: + """All four byte categories added up. + + They are states of the same bytes, not overlapping slices: a byte is + active, in Time Travel, in Fail-safe, or retained for a clone, and + the page bills each category to this table. The sum is therefore the + table's total billed storage -- and, per the page's usage notes, it + is subject to the documented 1-2 hour delay in updating storage + statistics. + """ + return ( + self.active_bytes + + self.time_travel_bytes + + self.failsafe_bytes + + self.retained_for_clone_bytes + ) + + @property + def is_dropped(self) -> bool: + """Whether ``TABLE_DROPPED`` carries a timestamp. + + Dropped tables stay in the view while they still incur storage + costs, which is the documented reason the column exists. The + introspector's query keeps only the current version, so this is + ``False`` for every value it returns; it is defined here so a caller + that builds the object from a wider query can still ask. + """ + return self.table_dropped is not None + + @property + def is_clustered(self) -> bool: + """Whether ``TABLES.CLUSTERING_KEY`` names a clustering key.""" + return bool(self.clustering_key) diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/__init__.py index 7132835..91f569f 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/__init__.py @@ -46,10 +46,13 @@ from .set_operation import SnowflakeSetOperationMixin from .dql import SnowflakeDQLMixin from .capabilities import SnowflakeCapabilityMixin +from .constraint import SnowflakeConstraintMixin from .ilike import SnowflakeILIKEMixin from .generated_column import SnowflakeGeneratedColumnMixin from .ordered_set_aggregation import SnowflakeOrderedSetAggregationMixin from .truncate import SnowflakeTruncateMixin +from .wildcard import SnowflakeWildcardMixin +from .literal import SnowflakeLiteralValueMixin from .schema import SnowflakeSchemaMixin from .ddl_database import SnowflakeDatabaseMixin from .sequence import SnowflakeSequenceMixin @@ -90,10 +93,13 @@ "SnowflakeSetOperationMixin", "SnowflakeDQLMixin", "SnowflakeCapabilityMixin", + "SnowflakeConstraintMixin", "SnowflakeILIKEMixin", "SnowflakeGeneratedColumnMixin", "SnowflakeOrderedSetAggregationMixin", "SnowflakeTruncateMixin", + "SnowflakeWildcardMixin", + "SnowflakeLiteralValueMixin", "SnowflakeSchemaMixin", "SnowflakeDatabaseMixin", "SnowflakeSequenceMixin", diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/capabilities.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/capabilities.py index f34b142..2adc38b 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/capabilities.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/capabilities.py @@ -89,15 +89,15 @@ def supports_introspection(self) -> bool: def supports_returning_insert(self) -> bool: """Snowflake supports RETURNING for INSERT from version 7.32.0+.""" - return self.version >= (7, 32, 0) + return self.version_at_least((7, 32, 0)) def supports_returning_update(self) -> bool: """Snowflake supports RETURNING for UPDATE from version 7.32.0+.""" - return self.version >= (7, 32, 0) + return self.version_at_least((7, 32, 0)) def supports_returning_delete(self) -> bool: """Snowflake supports RETURNING for DELETE from version 7.32.0+.""" - return self.version >= (7, 32, 0) + return self.version_at_least((7, 32, 0)) def supports_filter_clause(self) -> bool: """Snowflake supports FILTER clause.""" @@ -350,28 +350,39 @@ def supports_alter_column_type(self) -> bool: def alter_column_type_action(self, old_col, new_col): """Build the in-place type-change action (MODIFY COLUMN).""" - from rhosocial.activerecord.backend.expression.statements.ddl_alter import ModifyColumn + from rhosocial.activerecord.backend.expression.statements.ddl_alter import ModifyColumnAction - return ModifyColumn(self, column=new_col) + return ModifyColumnAction(self, column=new_col) def format_modify_column_action(self, action) -> Tuple[str, tuple]: - """Render Snowflake ALTER TABLE ... MODIFY COLUMN .""" - from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - ColumnConstraintType, + """Render Snowflake ALTER TABLE ... MODIFY COLUMN . + + The MODIFY path re-spells the column's inline constraint list, which + Snowflake accepts on a column type change: NOT NULL, NULL and DEFAULT. + The other kinds cannot ride this action and are not rendered here; the + original list is not filtered down to these three (a PK is skipped, not + refused) because this is a *re-spelling* of an already-validated column, + not a new declaration. + """ + from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefaultConstraint, + ColumnNotNullConstraint, + ColumnNullableConstraint, ) col = action.column type_sql, _ = self.format_data_type(col.data_type) parts = [type_sql] for c in col.constraints: - if c.constraint_type == ColumnConstraintType.NOT_NULL: + branch = c.branch + if isinstance(branch, ColumnNotNullConstraint): parts.append("NOT NULL") - elif c.constraint_type == ColumnConstraintType.NULL: + elif isinstance(branch, ColumnNullableConstraint): parts.append("NULL") - elif c.constraint_type == ColumnConstraintType.DEFAULT and c.default_value is not None: + elif isinstance(branch, ColumnDefaultConstraint) and branch.value is not None: from rhosocial.activerecord.backend.expression.core import Literal - lit_sql, lit_params = Literal(self, c.default_value).to_sql() + lit_sql, lit_params = Literal(self, branch.value).to_sql() parts.append(f"DEFAULT {lit_sql}") spec = " ".join(parts) return f"MODIFY COLUMN {self.format_identifier(col.name)} {spec}", () diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/collation.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/collation.py index c1fa382..217416e 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/collation.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/collation.py @@ -5,7 +5,7 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError if TYPE_CHECKING: - from rhosocial.activerecord.backend.expression.collation import CollateExpression + from rhosocial.activerecord.backend.expression.collation import CollateFunction class SnowflakeCollationMixin: @@ -15,14 +15,18 @@ def supports_collate_expression(self) -> bool: """Snowflake supports expression-level COLLATE.""" return True - def validate_collation_name(self, expr: "CollateExpression") -> str: + def validate_collation_name(self, expr: "CollateFunction") -> str: """Validate Snowflake collation specs and return their SQL representation.""" - if expr.collation_options: - unsupported = ", ".join(sorted(expr.collation_options)) - raise UnsupportedFeatureError(self.name, f"COLLATE options: {unsupported}") + # A schema-qualified collation is the one option the core node still + # carries; Snowflake's collation specs are spec strings like 'en-ci' + # and are never schema-qualified, so the option is refused rather than + # rendered. Core replaced the former open ``**collation_options`` bag + # with this named parameter, so the refusal names it directly. + if expr.schema: + raise UnsupportedFeatureError(self.name, "COLLATE options: schema") from ..collation import validate_snowflake_collation_name spec = validate_snowflake_collation_name(expr.collation_name, getattr(self, "version", None)) - return f"'{self._escape_sql_string(spec)}'" + return f"'{self.escape_sql_string(spec)}'" def format_column_attribute(self, attr) -> Tuple[str, tuple]: """Snowflake requires a quoted collation specification on a column.""" @@ -31,7 +35,7 @@ def format_column_attribute(self, attr) -> Tuple[str, tuple]: if isinstance(attr, CollationAttribute): spec = validate_snowflake_collation_name(attr.name, getattr(self, "version", None)) - return f" COLLATE '{self._escape_sql_string(spec)}'", () + return f" COLLATE '{self.escape_sql_string(spec)}'", () return super().format_column_attribute(attr) diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/constraint.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/constraint.py new file mode 100644 index 0000000..f03248c --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/constraint.py @@ -0,0 +1,2082 @@ +# src/rhosocial/activerecord/backend/impl/snowflake/mixins/constraint.py +"""Snowflake constraint capability declarations, read off the official grammar. + +Every switch here is declared **in this package**, even where core's +``ConstraintMixin`` / ``DDLColumnMixin`` already defaults it to the same value. +That is deliberate: those two mixins are shared with the dummy dialect and are +exactly the kind of default that can move, and a silent move of a core default +would move Snowflake's contract with it. A declaration here is the pin. Before +this module the backend declared **three** constraint switches in total +(:meth:`supports_constraints`, :meth:`supports_drop_constraint_if_exists` and +:meth:`supports_column_comment`), so every other answer below was whatever core +happened to say -- and for three of them (the two enforcement probes and the +deferral probe) core's default licensed a render this backend never measured. +Every such answer is now a local declaration, stated here rather than inherited. + +Evidence level: not server-verified (文档(待云验)) +-------------------------------------------------- +**Every value below is official-grammar-only. Nothing here was measured.** +There is no Snowflake instance reachable from this repository's test environment +-- no account credentials exist, and the repository has no Snowflake emulator -- +so none of this could be checked against a server even in principle. The two +reference pages were transcribed on 2026-10-12 and are named at +:data:`OFFICIAL_GRAMMAR_URL` and :data:`ALTER_TABLE_GRAMMAR_URL`; a ``待云验`` +marker means "a real Snowflake run must confirm this before a caller relies on +it". The contract tests assert the *declaration*, never the server's agreement +with it, and every test for this file is render-only -- a rendering assertion +here is **not** a measurement and must never be read as one. + +Modifier policy: the grammar has the tokens; this dialect does not render them +--------------------------------------------------------------------------- +``supports_constraint_enforced``, ``supports_alter_constraint_enforced`` and +``supports_deferrable_constraint`` are declared **explicitly ``False``** even +though the grammar productions transcribed below contain ``[ [ NOT ] ENFORCED ]`` +and ``[ [ NOT ] DEFERRABLE ]``. Three reasons, in order of weight: + +* **Not verified.** No Snowflake instance is reachable, so a ``True`` here would + license SQL nobody has seen the server accept. The project's rule for the + clauses it cannot measure is to fail closed, and the two probes core's + renderers consult are exactly where a guess would turn into emitted SQL. +* **``ENFORCED`` is worse than a syntax error on this server.** The reference + states that for a standard table a PRIMARY KEY, UNIQUE or FOREIGN KEY is not + enforced *regardless of the property*, and that specifying the non-default + ``ENABLE`` / ``VALIDATE`` when creating one means *"the constraint isn't + created"* -- silently, with no error. Rendering the marker would therefore + hand a caller a success that enforces nothing. +* **Precedent in this repository.** ``supports_alter_constraint_enforced`` was + already declared ``False`` before this file existed, and the clause-pair guard + asserts the two spellings are refused by name. Flipping it without a server + would be a regression in the honest direction. + +The per-level and per-kind grammar probes (``supports_{column,table}_*``, +``supports_*_enforced_*_constraint``, ``supports_*_deferrable_*_constraint``) +stay as the record of *what the grammar contains*: they are backend-only, no core +formatter consults them, and they are what an eventual live measurement would be +checked against. The two families are kept apart deliberately and each +docstring says which one it is. + +Two corrections to the inherited answers are unusually load-bearing, because they +turn a refusal of real grammar into a render: + +* :meth:`supports_fk_match` moves **``False`` -> ``True``**. Core reaches + ``False`` by accident of MRO -- ``DDLColumnMixin`` declares the probe with a + ``False`` default and is composed *ahead* of ``ConstraintMixin``, which + declares the same probe with a ``True`` default. Snowflake is one of the few + backends in this group that genuinely has ``MATCH``, so the inherited answer + was refusing SQL Snowflake's own grammar contains. +* :meth:`supports_alter_constraint_enforced` stays **``False``**, but is now a + *declaration* rather than an inherited default (core's P1 flipped the shared + default to ``False`` as well, so the value is unchanged and the ownership is + what moved). Snowflake's ``constraintAction`` lists + ``[ [ NOT ] ENFORCED ]`` as one of only three properties the ALTER branch can + set. +* :meth:`supports_constraint_enforced` and + :meth:`supports_deferrable_constraint` likewise stay **``False``** as explicit + declarations where the core default used to license a render: before P1, + ``ConstraintMixin`` answered ``True`` for both, so Snowflake emitted + ``ENFORCED`` / ``DEFERRABLE`` it had never measured. See the modifier-policy + section above for why the declaration outweighs the grammar reading. + +Source, transcribed 2026-10-12 from the two pages every production below is on + +Inline and out-of-line constraint definitions -- +https://docs.snowflake.com/en/sql-reference/sql/create-table-constraint +ALTER TABLE's ``constraintAction`` -- +https://docs.snowflake.com/en/sql-reference/sql/alter-table + +Column level, verbatim (``CREATE | ALTER TABLE ... CONSTRAINT``):: + + inlineUniquePK ::= + [ CONSTRAINT ] + { UNIQUE | PRIMARY KEY } + [ [ NOT ] ENFORCED ] + [ [ NOT ] DEFERRABLE ] + [ INITIALLY { DEFERRED | IMMEDIATE } ] + [ { ENABLE | DISABLE } ] + [ { VALIDATE | NOVALIDATE } ] + [ { RELY | NORELY } ] + + inlineFK ::= + [ CONSTRAINT ] + [ FOREIGN KEY ] + REFERENCES [ ( ) ] + [ MATCH { FULL | SIMPLE | PARTIAL } ] + [ ON [ UPDATE { CASCADE | SET NULL | SET DEFAULT | RESTRICT | NO ACTION } ] + [ DELETE { CASCADE | SET NULL | SET DEFAULT | RESTRICT | NO ACTION } ] ] + [ [ NOT ] ENFORCED ] + [ [ NOT ] DEFERRABLE ] + [ INITIALLY { DEFERRED | IMMEDIATE } ] + [ { ENABLE | DISABLE } ] + [ { VALIDATE | NOVALIDATE } ] + [ { RELY | NORELY } ] + + inlineCH ::= + [ CONSTRAINT ] CHECK ( ) + [ ENABLE { VALIDATE | NOVALIDATE } ] + +``inlineCH`` carries **one** property. That single line is the reason half of +this module exists: within one backend, three constraint kinds, three different +capability sets. + +Table level, verbatim (same page):: + + outoflineUniquePK ::= + [ CONSTRAINT ] + { UNIQUE | PRIMARY KEY } ( [ , , ... ] ) + [ [ NOT ] ENFORCED ] [ [ NOT ] DEFERRABLE ] [ INITIALLY { DEFERRED | IMMEDIATE } ] + [ { ENABLE | DISABLE } ] [ { VALIDATE | NOVALIDATE } ] [ { RELY | NORELY } ] + [ COMMENT '' ] + + outoflineFK ::= + [ CONSTRAINT ] + FOREIGN KEY ( [ , , ... ] ) + REFERENCES [ ( [ , , ... ] ) ] + [ MATCH { FULL | SIMPLE | PARTIAL } ] + [ ON [ UPDATE { ... } ] [ DELETE { ... } ] ] + [ [ NOT ] ENFORCED ] [ [ NOT ] DEFERRABLE ] [ INITIALLY { DEFERRED | IMMEDIATE } ] + [ { ENABLE | DISABLE } ] [ { VALIDATE | NOVALIDATE } ] [ { RELY | NORELY } ] + [ COMMENT '' ] + + outoflineCH ::= + [ CONSTRAINT ] CHECK ( ) + [ ENABLE { VALIDATE | NOVALIDATE } ] + +Note the tail: the table-level ``COMMENT`` exists on ``outoflineUniquePK`` and +``outoflineFK`` and is **absent from ``outoflineCH``**. See +:meth:`supports_table_constraint_comment`. + +ALTER TABLE level, verbatim (``ALTER TABLE``):: + + constraintAction ::= + { + ADD outoflineConstraint + | RENAME CONSTRAINT TO + | { ALTER | MODIFY } { CONSTRAINT + | PRIMARY KEY + | UNIQUE + | FOREIGN KEY } ( [ , ... ] ) + } + [ [ NOT ] ENFORCED ] [ VALIDATE | NOVALIDATE ] [ RELY | NORELY ] + | DROP { CONSTRAINT + | PRIMARY KEY + | UNIQUE | FOREIGN KEY } ( [ , ... ] ) + [ CASCADE | RESTRICT ] + } + +Five properties that invert the SQL standard +-------------------------------------------- + +The reference states each default in its own right. Four of them are the +**opposite** of what every other backend in this group does, and the renderer +must never supply one implicitly: + +====================== ========================= ================= +Property Snowflake's default SQL standard +====================== ========================= ================= +``{ ENFORCED | ``NOT ENFORCED`` ``ENFORCED`` +NOT ENFORCED }`` +``INITIALLY { DEFERRED ``INITIALLY DEFERRED`` ``IMMEDIATE`` +| IMMEDIATE }`` +``MATCH { FULL | ``MATCH FULL`` ``SIMPLE`` +SIMPLE | PARTIAL }`` +``{ ENABLE | DISABLE }`` ``DISABLE`` -- +====================== ========================= ================= + +Plus two that are kind-dependent rather than global: + +* ``{ VALIDATE | NOVALIDATE }`` -- "Default for PRIMARY KEY and FOREIGN KEY + constraints: NOVALIDATE" / "Default for CHECK constraints: VALIDATE". +* ``{ RELY | NORELY }`` -- ``NORELY``. + +**Practical consequence, stated as a hard constraint on this module: core must +not render ``ENFORCED``, ``INITIALLY``, ``MATCH`` or ``ENABLE`` for Snowflake +unless a caller asked for it explicitly.** Writing any of them changes the +backend's meaning, not merely its spelling. The four switches below that answer +"may this dialect spell it?" are all ``True``; that says the *clause* is +available, never that it should be emitted by default. + +And the fact that governs all of the above, verbatim from the reference:: + + For standard tables, NOT NULL and CHECK are the only types of constraints + that are enforced by Snowflake, regardless of this property. + +So on a **standard** table a Snowflake ``PRIMARY KEY``, ``FOREIGN KEY`` or +``UNIQUE`` is declarative metadata: it records the intent and it is not +maintained. A caller that relies on a Snowflake primary key to reject duplicate +rows is relying on something the grammar does not promise. + +Four shapes no other backend in this group has +----------------------------------------------- + +1. **{ ``ALTER | MODIFY``} are two spellings of ONE operation.** They are + synonyms, not alternatives, so they collapse to a single semantic state with + the spelling chosen by a formatter. They are deliberately **not** two + switches here: two booleans both defaulting to ``True`` would encode nothing + and invite an ``alter=``/``modify=`` pair that the grammar cannot + distinguish. (The same reasoning was applied to SQLite's + ``TEMP``/``TEMPORARY`` earlier this session.) +2. **A constraint can be addressed by name *or* by kind + column list** on both + the ALTER and the DROP branch -- ``ALTER CONSTRAINT (cols)`` and + ``ALTER PRIMARY KEY (cols)`` are both in the production. **CHECK is the + exception and must be addressed by name**, twice in the page's own prose: + *"For CHECK constraints, the ``constraint_name`` is required."* -- under both + ``{ ALTER | MODIFY } CONSTRAINT ...`` and under ``DROP``. +3. **``DROP`` can address by kind.** ``DROP PRIMARY KEY (cols)`` / + ``DROP UNIQUE (cols)`` / ``DROP FOREIGN KEY (cols)`` are the only backend of + the ten compared this session that can remove a constraint without naming it. + See :meth:`supports_drop_constraint_by_kind`. +4. **The ALTER surface is strictly smaller than the ADD surface.** + ``ADD outoflineConstraint`` can set the full property set -- including + ``[ [ NOT ] DEFERRABLE ]`` and ``INITIALLY { DEFERRED | IMMEDIATE }`` -- while + ``{ ALTER | MODIFY }`` offers only three: ``[ [ NOT ] ENFORCED ]``, + ``VALIDATE | NOVALIDATE`` and ``RELY | NORELY``. **Deferral is ADD-only.** + An ALTER path that could set ``DEFERRABLE`` would be writing a clause + Snowflake's parser does not accept in that position. The asymmetry is + restated as a comment on the constraint render path in + :mod:`rhosocial.activerecord.backend.impl.snowflake.mixins.ddl`, which is + where someone reaching for it will be. + +Documented restriction on hybrid tables, verbatim:: + + For hybrid tables, you can specify a CHECK constraint only in a + CREATE HYBRID TABLE statement, not in an ALTER TABLE ... ADD CONSTRAINT + statement. + +That is a property of the *table kind*, not of the dialect, so it is recorded +here rather than encoded in a switch: a boolean cannot say "CHECK is addable +except on hybrid tables", and the render path has no way to know the table kind +today. + +Two places the two official pages disagree with each other +--------------------------------------------------------- +Recorded rather than resolved, because both pages are official and this module +must not pick a winner and call it a measurement. + +* **Is the out-of-line column list required?** The constraint page's + ``outoflineUniquePK`` / ``outoflineFK`` productions make it mandatory -- + ``{ UNIQUE | PRIMARY KEY } ( [ , , ... ] )`` -- while + the ALTER TABLE page's ``outoflineConstraint`` brackets it: + ``UNIQUE [ ( [ , , ... ] ) ]``. Core always renders + the list, so it happens to satisfy the stricter reading; noted so no one + "simplifies" it away. (This corrects + ``.claude/plan/2026-10-11/snowflake-constraint-coverage.md`` item 4, which + transcribes all four ADD branches as having *optional* column lists. The + stricter page is the one the plan's own switch table was derived from.) +* **Does ``ALTER CONSTRAINT`` take a column list?** The ALTER TABLE page's + grammar puts ``( [ , ... ] )`` after the name-or-kind head, but the + constraint page's own ``RELY`` example is + ``ALTER TABLE table_with_primary_key ALTER CONSTRAINT a_primary_key_constraint RELY;`` + -- a name and a property, no list. No switch depends on the answer. + +Known gaps a switch cannot close (recorded, not worked around) +-------------------------------------------------------------- +These are **render-path** problems. The values below are right; the generic core +formatters do not act on all of them, and teaching them to is a formatter change +that is out of this module's scope. Each is named here so the next reader does +not mistake the pins for working behaviour: + +* **The enforcement marker is unreachable on a column-level primary key.** + ``inlineUniquePK`` has ``[ [ NOT ] ENFORCED ]``, and + :meth:`supports_column_enforced_primary_key_constraint` is ``True`` -- but + core's ``format_constraint_traits`` renders a trait only after the flat + :meth:`supports_constraint_enforced` agrees, and that flat probe is ``False`` + here (see the modifier-policy section above). So the per-kind ``True`` records + the grammar while the render path refuses the spelling, and the refusal is a + decision (fail closed without a server) rather than an accident of core's + internals. The same applies to ``UNIQUE``. +* **``NOT ENFORCED`` is not rendered at all, and that is a decision.** This is + the more awkward half of the same problem on Snowflake than on any other + backend, because ``NOT ENFORCED`` **is** the Snowflake default: a caller who + omits it already gets ``NOT ENFORCED`` semantics from the server. Not + emitting it is therefore *correct* here and wrong on BigQuery, where the token + is compulsory -- do not "fix" one backend by copying the other. +* **``ENFORCED`` / ``ENABLE`` / ``VALIDATE`` are refused by the flat probes, and + on a standard table they are worse than a syntax error.** The reference is + explicit: *"for PRIMARY KEY, UNIQUE, and FOREIGN KEY constraints, if you + specify ENABLE or VALIDATE (the non-default values for these properties) when + creating a new constraint, the constraint isn't created."* A renderer that + honoured a caller who asked for them would emit SQL that **silently succeeds + while creating no constraint at all** -- which is why the flat + ``supports_constraint_enforced`` answers ``False`` rather than leaving the + spelling reachable. +* **``NOT VALID`` is not a Snowflake spelling; ``NOVALIDATE`` is.** + ``_format_constraint_validation`` emits the two-word ``" NOT VALID"``, which + Snowflake's grammar does not contain anywhere. That is why + :meth:`supports_constraint_novalidate` is ``False``: it gates the ``NOT VALID`` + spelling specifically, and is a different question from the backend-only + :meth:`supports_table_constraint_novalidate`, which gates the + ``{ VALIDATE | NOVALIDATE }`` *property*. +* **Drop-by-kind has no node to hang on.** ``DropTableConstraint`` carries a + ``constraint_name`` only, so ``DROP PRIMARY KEY (cols)`` -- the shape + :meth:`supports_drop_constraint_by_kind` records -- cannot be expressed. A + node that carries kind-plus-columns is core S1 work. +* **``CASCADE`` sits on the by-kind ``DROP`` branch.** Core appends ``CASCADE`` + to ``DROP CONSTRAINT ``, which is grammatically fine; what has no gate + is ``RESTRICT``, the other value the same bracket allows. +* **``{ ALTER | MODIFY }``, by-kind addressing, and "CHECK must be by name"** + have no node on this core either. All three are grammar facts recorded above + so that the S1/S3 shape work has them written down. + +Name note +--------- +The per-level (``supports_{column,table}_*``) and per-kind +(``supports_*_check_constraint``, ``supports_{column,table}_enforced_*``) +probes, the ``naming_*`` family, ``supports_{column,table}_constraint_rely``, +``supports_{column,table}_constraint_novalidate``, +``supports_{column,table}_constraint_enable_disable``, +``supports_{column,table}_on_update_action``, ``supports_add_constraint_if_not_exists``, +``supports_table_rename_constraint``, ``supports_table_alter_constraint``, +``supports_table_add_{primary_key,unique,check,foreign_key}_constraint``, +``supports_table_constraint_comment`` and ``supports_drop_constraint_by_kind`` +are **backend-only** on this core: no ``ConstraintMixin`` or ``DDLColumnMixin`` +in ``rhosocial.activerecord.backend.dialect.mixins`` declares or consults any +of them. They are the names from +``.claude/plan/2026-10-11/snowflake-constraint-coverage.md`` and they are +declared here so the grammar answer is written down in one place next to its +evidence, **on the understanding that core S3 may rename or absorb them**. The +flat switches are the ones core's renderers actually consult today; the two +groups are kept apart deliberately, and every docstring says which it is. +""" + +#: The page the inline/out-of-line constraint productions and every property +#: default were transcribed from. Kept as a constant so the docstrings, the +#: tests and the plan document cannot drift onto different pages. +OFFICIAL_GRAMMAR_URL = "https://docs.snowflake.com/en/sql-reference/sql/create-table-constraint" + +#: The page ``constraintAction`` was transcribed from. Separate from the above +#: because the two pages disagree in two places -- see the module docstring. +ALTER_TABLE_GRAMMAR_URL = "https://docs.snowflake.com/en/sql-reference/sql/alter-table" + + +class SnowflakeConstraintMixin: + """Snowflake constraint capability declarations. + + Every value in this class comes from the official ``inlineConstraint`` / + ``outoflineConstraint`` / ``constraintAction`` grammar productions + transcribed at :data:`OFFICIAL_GRAMMAR_URL` and + :data:`ALTER_TABLE_GRAMMAR_URL` on 2026-10-12. **None of them is a live + measurement** -- no Snowflake instance is reachable from this repository, + and the repository has no emulator, so nothing here could have been checked + against a server. Read each value as "this is what the grammar says", not + as "this is what a server was seen to do"; see the module docstring for the + evidence level, the five inverted defaults and the known render-path gaps. + """ + + # ------------------------------------------------------------------ + # Column level -- ``inlineConstraint`` + # + # inlineUniquePK ::= [ CONSTRAINT ] { UNIQUE | PRIMARY KEY } + # [ [NOT] ENFORCED ] [ [NOT] DEFERRABLE ] + # [ INITIALLY { DEFERRED | IMMEDIATE } ] + # [ { ENABLE | DISABLE } ] + # [ { VALIDATE | NOVALIDATE } ] + # [ { RELY | NORELY } ] + # + # inlineFK ::= [ CONSTRAINT ] [ FOREIGN KEY ] + # REFERENCES [ ( ) ] + # [ MATCH { FULL | SIMPLE | PARTIAL } ] + # [ ON [ UPDATE {...} ] [ DELETE {...} ] ] + # ... the same six properties ... + # + # inlineCH ::= [ CONSTRAINT ] CHECK ( ) + # [ ENABLE { VALIDATE | NOVALIDATE } ] + # + # All four kinds are True here. Snowflake is the one backend in this group + # whose grammar has every kind at both levels; the ``False`` answers in this + # backend are all about *properties* a kind does not have, never about kinds + # it lacks. + # ------------------------------------------------------------------ + + def supports_column_primary_key_constraint(self) -> bool: + """True: ``PRIMARY KEY`` is the second alternative of ``inlineUniquePK``. + + Official grammar, verbatim:: + + inlineUniquePK ::= + [ CONSTRAINT ] + { UNIQUE | PRIMARY KEY } + [ [ NOT ] ENFORCED ] + ... + + Declared locally even though core's ``ConstraintMixin`` already answers + ``True``: it is the gate on the refusal in + ``format_column_primary_key_constraint``, so the value is load-bearing + and must not be able to move with a core default. + + Distinct from :meth:`supports_primary_key_constraint`, the flat probe + core's formatter actually consults. Both are ``True`` and both are + declared, so a change to one cannot silently change the other. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_column_unique_constraint(self) -> bool: + """True: ``UNIQUE`` is the first alternative of ``inlineUniquePK``. + + Official grammar: ``{ UNIQUE | PRIMARY KEY }`` inside ``inlineUniquePK`` + (quoted in full at :meth:`supports_column_primary_key_constraint`). + + Core consults :meth:`supports_unique_constraint` -- not this one -- for + the ``UNIQUE`` branch in ``format_column_constraint``; both are declared + and both are ``True``. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_column_check_constraint(self) -> bool: + """True: ``inlineCH`` exists, and its one property is ``ENABLE``-shaped. + + Official grammar, verbatim:: + + inlineCH ::= + [ CONSTRAINT ] CHECK ( ) + [ ENABLE { VALIDATE | NOVALIDATE } ] + + ``CHECK`` is present, so the kind is supported. Note what is *not* + present: ``[NOT] ENFORCED``, ``[NOT] DEFERRABLE``, ``INITIALLY``, + ``{ ENABLE | DISABLE }`` as a standalone property and ``{ RELY | + NORELY }`` -- hence the ``False`` answers at + :meth:`supports_column_enforced_check_constraint` and + :meth:`supports_column_deferrable_check_constraint`. + + Worth stating because ``inlineCH`` is a genuine anomaly rather than a + transcription artefact: the reference states the same three-way split in + prose ("Some properties apply to all keys (unique, primary, and foreign). + Other properties apply only to foreign keys"), and the CHECK branch is + the odd one out with a single combined ``ENABLE { VALIDATE | NOVALIDATE }`` + property instead of the six the other two branches carry separately. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_column_foreign_key_constraint(self) -> bool: + """True: ``inlineFK`` is the column grammar's foreign-key branch. + + Official grammar, verbatim:: + + inlineFK ::= + [ CONSTRAINT ] + [ FOREIGN KEY ] + REFERENCES [ ( ) ] + [ MATCH { FULL | SIMPLE | PARTIAL } ] + ... + + Two details in that quote. ``FOREIGN KEY`` itself is optional -- the + ``REFERENCES`` clause is what identifies the branch, so both + ``c INT REFERENCES t(id)`` and ``c INT FOREIGN KEY REFERENCES t(id)`` + are in the grammar. And the referenced column list is optional + (a single ````, not ``[, ...]``), because the reference + allows it to be omitted when the referenced table's primary key signature + matches exactly. + + Known coupling, inherited from core and worth restating: + ``format_column_fk_constraint`` consults the **table**-level + :meth:`supports_foreign_key_constraint` -- there is no column-level + probe on this core. Snowflake answers ``True`` at both levels, so the + coupling costs nothing here. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_column_naming_primary_key_constraint(self) -> bool: + """True: ``[ CONSTRAINT ]`` opens ``inlineUniquePK``. + + Official grammar:: + + inlineUniquePK ::= + [ CONSTRAINT ] + { UNIQUE | PRIMARY KEY } + ... + + The name is optional at every level and for every kind on Snowflake -- + the keyword appears in all three inline branches and in all three + out-of-line branches. That is what makes ``ALTER CONSTRAINT `` + and ``RENAME CONSTRAINT TO`` reachable for a Snowflake primary + key; compare BigQuery, where a primary key cannot be named at all. + + Name note: no core probe is per-level or ``naming_*`` yet. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_column_naming_unique_constraint(self) -> bool: + """True: same ``[ CONSTRAINT ]`` as the primary key. + + Official grammar: the name is bracketed in front of the + ``{ UNIQUE | PRIMARY KEY }`` pair of ``inlineUniquePK``, so it covers + ``UNIQUE`` as well as ``PRIMARY KEY``. + + Name note: no core probe is per-level or ``naming_*`` yet. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_column_naming_check_constraint(self) -> bool: + """True: ``inlineCH`` is nameable -- and that is what makes it droppable. + + Official grammar:: + + inlineCH ::= + [ CONSTRAINT ] CHECK ( ) + [ ENABLE { VALIDATE | NOVALIDATE } ] + + Pairs with the "CHECK must be addressed by name" rule on the ALTER side + (see the module docstring). The two facts fit together: a CHECK is the + one kind Snowflake will not address by kind-plus-column-list, and it is + also one of only two kinds that is nameable at column level on every + backend in this group. Without this ``True`` there would be no way to + reach a column CHECK at all. + + Name note: no core probe is per-level or ``naming_*`` yet. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_column_naming_foreign_key_constraint(self) -> bool: + """True: ``[ CONSTRAINT ] [ FOREIGN KEY ] REFERENCES``. + + Official grammar: the name is the first bracketed group of ``inlineFK`` + (quoted at :meth:`supports_column_foreign_key_constraint`), ahead of the + optional ``FOREIGN KEY`` keyword. + + Name note: no core probe is per-level or ``naming_*`` yet. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + # ------------------------------------------------------------------ + # Table level -- ``outoflineConstraint`` + # + # Same four kinds, but composite, and the ``COMMENT`` tail exists on two of + # the three branches only. The plan's name for the ADD-side switch is + # ``supports_table_add_constraint``; the name core's + # ``format_add_table_constraint_action`` actually consults is the flat + # :meth:`supports_add_constraint`, and that is the one declared -- see its + # docstring for the mapping. + # ------------------------------------------------------------------ + + def supports_table_primary_key_constraint(self) -> bool: + """True: ``outoflineUniquePK`` is a table element. + + Official grammar, verbatim:: + + outoflineUniquePK ::= + [ CONSTRAINT ] + { UNIQUE | PRIMARY KEY } ( [ , , ... ] ) + [ [ NOT ] ENFORCED ] ... + + The comma in ``[ , , ... ]`` is what makes this composite + primary key the only way to express a multi-column one: the reference + states *"Multi-column constraints (composite unique or primary keys) can + only be defined out-of-line."* + + The column list is mandatory on this page's production. The ALTER TABLE + page brackets it instead; see the module docstring for that conflict. + Either way core always renders it, so the stricter reading holds. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_unique_constraint(self) -> bool: + """True: ``UNIQUE`` is the first alternative of ``outoflineUniquePK``. + + Official grammar: ``{ UNIQUE | PRIMARY KEY } ( [ , + , ... ] )`` inside ``outoflineUniquePK``, quoted in full at + :meth:`supports_table_primary_key_constraint`. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_check_constraint(self) -> bool: + """True: ``outoflineCH`` is a table element, with the same one property. + + Official grammar, verbatim:: + + outoflineCH ::= + [ CONSTRAINT ] CHECK ( ) + [ ENABLE { VALIDATE | NOVALIDATE } ] + + Identical to ``inlineCH`` including the single property, so the + column-level and table-level ``False`` answers agree: + :meth:`supports_table_enforced_check_constraint` and + :meth:`supports_table_deferrable_check_constraint`. + + One behaviour the reference attaches to this branch specifically, and + which no switch can express: on a **hybrid** table a CHECK may be + specified only in ``CREATE HYBRID TABLE``, never in + ``ALTER TABLE ... ADD CONSTRAINT``. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_foreign_key_constraint(self) -> bool: + """True: ``outoflineFK`` is a table element and takes composite columns. + + Official grammar, verbatim:: + + outoflineFK ::= + [ CONSTRAINT ] + FOREIGN KEY ( [ , , ... ] ) + REFERENCES [ ( [ , , ... ] ) ] + [ MATCH { FULL | SIMPLE | PARTIAL } ] + [ ON [ UPDATE { ... } ] [ DELETE { ... } ] ] + ... + + Unlike ``inlineFK``, both column lists are composite here. This is also + the flat probe ``format_column_fk_constraint`` reaches for at column + level; Snowflake answers ``True`` at both, so the coupling is harmless. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_naming_primary_key_constraint(self) -> bool: + """True: ``[ CONSTRAINT ]`` opens ``outoflineUniquePK``. + + Official grammar: ``outoflineUniquePK ::= [ CONSTRAINT + ] { UNIQUE | PRIMARY KEY } ( ... )``. + + Name note: no core probe is per-level or ``naming_*`` yet. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_naming_unique_constraint(self) -> bool: + """True: same bracketed name as the table-level primary key. + + Official grammar: ``outoflineUniquePK`` brackets the name ahead of the + ``{ UNIQUE | PRIMARY KEY }`` pair, so it covers both. + + Name note: no core probe is per-level or ``naming_*`` yet. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_naming_check_constraint(self) -> bool: + """True: ``outoflineCH`` is nameable. + + Official grammar:: + + outoflineCH ::= + [ CONSTRAINT ] CHECK ( ) + [ ENABLE { VALIDATE | NOVALIDATE } ] + + This is the precondition for the "CHECK must be addressed by name" rule + the ALTER TABLE page states twice, verbatim: *"For CHECK constraints, + the constraint_name is required."* + + Name note: no core probe is per-level or ``naming_*`` yet. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_naming_foreign_key_constraint(self) -> bool: + """True: ``[ CONSTRAINT ]`` opens ``outoflineFK``. + + Official grammar: ``outoflineFK ::= [ CONSTRAINT ] + FOREIGN KEY ( ... ) REFERENCES ...``. + + Name note: no core probe is per-level or ``naming_*`` yet. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_constraint_comment(self) -> bool: + """True, but only on the primary-key / unique / foreign-key branches. + + Official grammar, verbatim -- the tail of ``outoflineUniquePK`` and of + ``outoflineFK``:: + + [ { RELY | NORELY } ] + [ COMMENT '' ] + + and ``outoflineCH`` in full:: + + outoflineCH ::= + [ CONSTRAINT ] CHECK ( ) + [ ENABLE { VALIDATE | NOVALIDATE } ] + + **The ``COMMENT`` tail is absent from the CHECK branch.** A switch is a + boolean and a boolean cannot say "yes for three of the four kinds", so + this records the branch that carries the clause and + :meth:`supports_table_check_constraint`'s neighbourhood carries the + exception. Per-kind gates are core S1/S3 work. + + Table-level only, and that is a real asymmetry rather than an oversight: + the column-level ``COMMENT`` (:meth:`supports_column_comment`, declared + on ``SnowflakeAlterColumnModifierMixin``) comments the *column*, and the + reference distinguishes them -- *"The DESCRIBE TABLE command shows + comments defined on columns, but not comments defined on constraints."* + + Name note: no core probe is per-kind or ``*_constraint_comment`` yet. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + # ------------------------------------------------------------------ + # Per-kind property probes: enforcement and deferral. + # + # These are the cleanest instance in this group of why the split is by kind + # rather than by clause. ``ENFORCED`` and ``DEFERRABLE`` are both ``True`` + # for primary key / unique / foreign key and both ``False`` for CHECK -- on + # the same backend, on the same page, for the same clause. A single boolean + # per clause cannot represent it. + # + # The three inverted defaults make the ``True``s subtle: the clause is + # available, but writing it is never the right default on Snowflake. + # ------------------------------------------------------------------ + + def supports_column_enforced_primary_key_constraint(self) -> bool: + """True: ``[ [ NOT ] ENFORCED ]`` is in ``inlineUniquePK``. + + Official grammar:: + + inlineUniquePK ::= + [ CONSTRAINT ] + { UNIQUE | PRIMARY KEY } + [ [ NOT ] ENFORCED ] + ... + + **The default is the opposite of the SQL standard** -- the reference + states ``Default: NOT ENFORCED`` -- and on a standard table the property + changes nothing anyway: *"For standard tables, NOT NULL and CHECK are the + only types of constraints that are enforced by Snowflake, regardless of + this property."* So on a standard table the marker is documentation, and + on a hybrid table it is the one that matters (the reference notes you + cannot set ``NOT ENFORCED`` on a hybrid ``PRIMARY KEY`` at all). + + Reachability, so the ``True`` is not over-read: it records the grammar + only. Core's render path consults the flat + :meth:`supports_constraint_enforced`, which is ``False`` here, so the + marker is refused for a primary key (and for every other kind) rather + than emitted. See the modifier-policy section of the module docstring + for why the grammar answer and the render answer differ. + + Name note: no core probe is per-level or per-kind for enforcement yet. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_column_enforced_unique_constraint(self) -> bool: + """True: ``[ [ NOT ] ENFORCED ]`` is in ``inlineUniquePK``, same branch. + + Official grammar: ``{ UNIQUE | PRIMARY KEY }`` then + ``[ [ NOT ] ENFORCED ]`` -- one production covers both kinds, so this is + the same fact as :meth:`supports_column_enforced_primary_key_constraint` + and inherits every caveat in its docstring, including the note that the + flat render gate is ``False`` here (``UNIQUE`` is refused by it). + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_column_enforced_foreign_key_constraint(self) -> bool: + """True: ``[ [ NOT ] ENFORCED ]`` is in ``inlineFK``. + + Official grammar:: + + inlineFK ::= + [ CONSTRAINT ] + [ FOREIGN KEY ] + REFERENCES [ ( ) ] + [ MATCH { FULL | SIMPLE | PARTIAL } ] + [ ON [ UPDATE { ... } ] [ DELETE { ... } ] ] + [ [ NOT ] ENFORCED ] + ... + + ``Default: NOT ENFORCED``, the SQL-standard inverse. Like every other + kind, the flat :meth:`supports_constraint_enforced` is ``False`` here, so + the render path refuses the spelling; this ``True`` is the grammar record + and nothing more. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_column_enforced_check_constraint(self) -> bool: + """False -- and this is the pin, because core would otherwise say ``True``. + + Official grammar, in full, with nothing elided:: + + inlineCH ::= + [ CONSTRAINT ] CHECK ( ) + [ ENABLE { VALIDATE | NOVALIDATE } ] + + **Two properties and neither is an enforcement marker.** There is no + ``ENFORCED``, no ``NOT ENFORCED``, not even in a ``[NOT]`` optional + bracket. A ``NOT ENFORCED`` on a Snowflake CHECK constraint is not + "unavailable" in the ordinary sense -- it is not in the language. + + The direction of the core default is what makes this the one to pin + hardest. ``ConstraintMixin.supports_constraint_enforced`` once answered + ``True``, and core's P1 flipped that default to ``False``; this + declaration pins Snowflake's own answer either way. CHECK is the one + kind Snowflake *does* enforce on a standard table, so the marker would + be the most consequential clause available to get wrong. + + Name note: no core probe is per-level or per-kind for enforcement yet. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + def supports_table_enforced_primary_key_constraint(self) -> bool: + """True: ``[ [ NOT ] ENFORCED ]`` is in ``outoflineUniquePK``. + + Official grammar:: + + outoflineUniquePK ::= + [ CONSTRAINT ] + { UNIQUE | PRIMARY KEY } ( [ , , ... ] ) + [ [ NOT ] ENFORCED ] + ... + + Same inverted default (``NOT ENFORCED``) and same "not enforced on a + standard table regardless of this property" as the column level; see + :meth:`supports_column_enforced_primary_key_constraint`. + + The reference's own worked example spells the marker out at table level, + which is the clearest evidence that callers should:: + + CONSTRAINT pkey_1 PRIMARY KEY (col1, col2) NOT ENFORCED + + Name note: no core probe is per-level or per-kind for enforcement yet. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_enforced_unique_constraint(self) -> bool: + """True: same ``outoflineUniquePK`` branch as the table-level primary key. + + Official grammar: ``[ [ NOT ] ENFORCED ]`` follows + ``{ UNIQUE | PRIMARY KEY } ( ... )`` in the single ``outoflineUniquePK`` + production, so it covers both kinds. Same inverted default, same + standard-table caveat. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_enforced_foreign_key_constraint(self) -> bool: + """True: ``[ [ NOT ] ENFORCED ]`` is in ``outoflineFK``. + + Official grammar:: + + outoflineFK ::= + [ CONSTRAINT ] + FOREIGN KEY ( [ , , ... ] ) + REFERENCES [ ( [ , ... ] ) ] + [ MATCH { FULL | SIMPLE | PARTIAL } ] + [ ON [ UPDATE { ... } ] [ DELETE { ... } ] ] + [ [ NOT ] ENFORCED ] + ... + + The reference's second worked example writes it at table level:: + + CONSTRAINT fkey_1 FOREIGN KEY (col_a, col_b) REFERENCES table2 (col1, col2) NOT ENFORCED + + ``Default: NOT ENFORCED``; and on a standard table the property does not + change what is enforced. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_enforced_check_constraint(self) -> bool: + """False: ``outoflineCH`` has one property and it is not an enforcement one. + + Official grammar, in full:: + + outoflineCH ::= + [ CONSTRAINT ] CHECK ( ) + [ ENABLE { VALIDATE | NOVALIDATE } ] + + Same absence as :meth:`supports_column_enforced_check_constraint`, at the + other level. The two agreeing is the point: Snowflake's CHECK capability + set is identical inline and out-of-line, which is what makes CHECK the + clean case for a per-kind gate rather than a per-level one. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + def supports_column_deferrable_primary_key_constraint(self) -> bool: + """True: ``[ [ NOT ] DEFERRABLE ] [ INITIALLY ... ]`` is in ``inlineUniquePK``. + + Official grammar:: + + inlineUniquePK ::= + [ CONSTRAINT ] + { UNIQUE | PRIMARY KEY } + [ [ NOT ] ENFORCED ] + [ [ NOT ] DEFERRABLE ] + [ INITIALLY { DEFERRED | IMMEDIATE } ] + ... + + **``INITIALLY`` is the third inverted default**: the reference states + ``Default: INITIALLY DEFERRED`` where the SQL standard says ``IMMEDIATE``. + ``DEFERRABLE`` itself defaults to ``NOT DEFERRABLE``, which is the standard + direction. + + Neither spelling may be emitted implicitly. A caller who omits both + already gets ``NOT DEFERRABLE``; a caller who omits ``INITIALLY`` already + gets ``INITIALLY DEFERRED`` *as far as the default goes*, which is the + opposite of what writing nothing would mean on any other backend in this + group. + + Name note: no core probe is per-level or per-kind for deferral yet. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_column_deferrable_unique_constraint(self) -> bool: + """True: same ``inlineUniquePK`` branch as the column-level primary key. + + Official grammar: ``[ [ NOT ] DEFERRABLE ]`` and + ``[ INITIALLY { DEFERRED | IMMEDIATE } ]`` follow + ``{ UNIQUE | PRIMARY KEY }`` in the single production, so this is the same + fact as :meth:`supports_column_deferrable_primary_key_constraint`. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_column_deferrable_foreign_key_constraint(self) -> bool: + """True: the deferral pair is in ``inlineFK`` too. + + Official grammar: ``inlineFK`` ends its property block with the same + ``[ [ NOT ] ENFORCED ] [ [ NOT ] DEFERRABLE ] [ INITIALLY { DEFERRED | + IMMEDIATE } ] [ { ENABLE | DISABLE } ] [ { VALIDATE | NOVALIDATE } ] + [ { RELY | NORELY } ]`` sequence that ``inlineUniquePK`` carries. + + Of the three kinds that have deferral, the foreign key is the one where + it could plausibly matter -- except that Snowflake does not enforce + foreign keys on standard tables either ("NOT NULL and CHECK are the only + types of constraints that are enforced by Snowflake"), so on a standard + table the property is metadata there too. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_column_deferrable_check_constraint(self) -> bool: + """False -- the other half of the per-kind split, pinned against core's ``True``. + + Official grammar, in full:: + + inlineCH ::= + [ CONSTRAINT ] CHECK ( ) + [ ENABLE { VALIDATE | NOVALIDATE } ] + + No ``DEFERRABLE``, no ``NOT DEFERRABLE``, no ``INITIALLY``. The + reference says why the shape is what it is: the CHECK branch's single + property is the *combined* ``ENABLE { VALIDATE | NOVALIDATE }``, which + has no room for a separate deferral. + + ``ConstraintMixin.supports_deferrable_constraint`` once answered ``True`` + and core's P1 flipped it to ``False``; this declaration pins Snowflake's + own answer. The flat probe is the gate ``format_references_clause`` + raises on -- so it is declared ``False`` here (see the modifier-policy + section of the module docstring) while the per-kind pair below carries + the grammar record. Those two are different questions, which is why + they are kept apart. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + def supports_table_deferrable_primary_key_constraint(self) -> bool: + """True: ``outoflineUniquePK`` carries the deferral pair. + + Official grammar:: + + outoflineUniquePK ::= + [ CONSTRAINT ] + { UNIQUE | PRIMARY KEY } ( [ , , ... ] ) + [ [ NOT ] ENFORCED ] + [ [ NOT ] DEFERRABLE ] + [ INITIALLY { DEFERRED | IMMEDIATE } ] + ... + + Same inverted ``INITIALLY DEFERRED`` default as the column level; see + :meth:`supports_column_deferrable_primary_key_constraint`. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_deferrable_unique_constraint(self) -> bool: + """True: same ``outoflineUniquePK`` branch as the table-level primary key. + + Official grammar: the deferral pair follows + ``{ UNIQUE | PRIMARY KEY } ( ... )`` in the single production. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_deferrable_foreign_key_constraint(self) -> bool: + """True: ``outoflineFK`` carries the deferral pair. + + Official grammar: the property block of ``outoflineFK`` is the same six + optional groups as ``inlineFK``'s. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_deferrable_check_constraint(self) -> bool: + """False: ``outoflineCH`` has one property and it is not a deferral one. + + Official grammar, in full:: + + outoflineCH ::= + [ CONSTRAINT ] CHECK ( ) + [ ENABLE { VALIDATE | NOVALIDATE } ] + + Same absence as :meth:`supports_column_deferrable_check_constraint`, at + the other level. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + # ------------------------------------------------------------------ + # The four remaining property families, present on primary key / unique / + # foreign key at both levels. ``ENABLE | DISABLE``, ``VALIDATE | + # NOVALIDATE`` and ``RELY | NORELY`` are named by the reference as + # Snowflake's own extensions to the ANSI properties; ``MATCH`` and the + # referential actions are foreign-key-only. + # ------------------------------------------------------------------ + + def supports_column_constraint_enable_disable(self) -> bool: + """True: ``[ { ENABLE | DISABLE } ]`` is in ``inlineUniquePK``. + + Official grammar:: + + inlineUniquePK ::= + ... + [ { ENABLE | DISABLE } ] + [ { VALIDATE | NOVALIDATE } ] + [ { RELY | NORELY } ] + + ``Default: DISABLE``, and the reference says why it exists at all: + *"Specifies whether the constraint is enabled or disabled. These + properties are provided for compatibility with Oracle."* + + **This is a constraint *property*, not an ``ENABLE CONSTRAINT`` lifecycle + statement.** Snowflake has no standalone enable/disable verb; of the ten + backends compared this session Oracle is the only one that does. A + switch named for the property must not be read as the capability to + issue ``ALTER TABLE ... ENABLE CONSTRAINT``. + + The hazard worth knowing before rendering it: *"For CHECK constraints, + ENABLE is the default and is required. If you specify DISABLE, then the + CHECK constraint isn't created."* + + Name note: no core probe is per-level for constraint properties yet. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_constraint_enable_disable(self) -> bool: + """True: ``[ { ENABLE | DISABLE } ]`` is in ``outoflineUniquePK`` / ``outoflineFK``. + + Official grammar: the same ``[ { ENABLE | DISABLE } ]`` group, ahead of + ``[ { VALIDATE | NOVALIDATE } ]``, in both out-of-line key productions. + + Same ``DISABLE`` default and same "compatibility with Oracle" rationale as + :meth:`supports_column_constraint_enable_disable`, and the same warning: + on a **standard** table, specifying ``ENABLE`` when creating a PRIMARY + KEY / UNIQUE / FOREIGN KEY means *"the constraint isn't created"* -- + silently, with no error. + + Name note: no core probe is per-level for constraint properties yet. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_column_constraint_novalidate(self) -> bool: + """True: ``[ { VALIDATE | NOVALIDATE } ]`` is in ``inlineUniquePK``. + + Official grammar:: + + inlineUniquePK ::= + ... + [ { ENABLE | DISABLE } ] + [ { VALIDATE | NOVALIDATE } ] + [ { RELY | NORELY } ] + + ``Default for PRIMARY KEY and FOREIGN KEY constraints: NOVALIDATE`` / + ``Default for CHECK constraints: VALIDATE`` -- so the default is + kind-dependent and a single boolean cannot carry it; the per-kind + ``ENABLE { VALIDATE | NOVALIDATE }`` shape of ``inlineCH`` is recorded at + :meth:`supports_column_check_constraint`. + + Distinct from the flat :meth:`supports_constraint_novalidate`, which is + ``False``. **These two are not in conflict and must not be reconciled:** + the flat switch gates the ``" NOT VALID"`` *spelling* core emits on the + ADD path, and Snowflake has no such token; this one gates the + ``{ VALIDATE | NOVALIDATE }`` *property*, which Snowflake does have. + Collapsing them would either license ``NOT VALID`` or lose ``NOVALIDATE``. + + Name note: no core probe is per-level for constraint properties yet. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_constraint_novalidate(self) -> bool: + """True: ``[ { VALIDATE | NOVALIDATE } ]`` is in the out-of-line key productions. + + Official grammar: the same ``[ { VALIDATE | NOVALIDATE } ]`` group in + ``outoflineUniquePK`` and ``outoflineFK``. + + Also the property the ALTER branch can set -- the ``constraintAction`` + production carries ``[ VALIDATE | NOVALIDATE ]`` on + ``{ ALTER | MODIFY }``. See :meth:`supports_table_alter_constraint` and + the flat :meth:`supports_constraint_novalidate` for why the two + differently-spelled switches disagree on purpose. + + Name note: no core probe is per-level for constraint properties yet. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_column_constraint_rely(self) -> bool: + """True: ``[ { RELY | NORELY } ]`` is in ``inlineUniquePK``. + + Official grammar:: + + inlineUniquePK ::= + ... + [ { VALIDATE | NOVALIDATE } ] + [ { RELY | NORELY } ] + + ``Default: NORELY``. ``RELY`` is the one property the reference singles + out as doing real work on a standard table: *"An exception is the RELY + property. If you have ensured that the data in your standard tables + complies with UNIQUE, PRIMARY KEY, and FOREIGN KEY constraints, you can + set the RELY property for those constraints"* -- it lets the optimizer + eliminate joins. + + The same reference section is blunt about the risk: *"For standard + tables, it is your responsibility to enforce RELY constraints; otherwise, + you might risk unintended behavior and unexpected results"* and *"If the + RELY property is set for a constraint and a violation of referential + integrity occurs, DML and CTAS statements might insert incorrect data."* + + Name note: no core probe is per-level for constraint properties yet. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_constraint_rely(self) -> bool: + """True: ``[ { RELY | NORELY } ]`` is in the out-of-line key productions. + + Official grammar: the same ``[ { RELY | NORELY } ]`` group in + ``outoflineUniquePK`` and ``outoflineFK``. + + This is also one of only three properties the ALTER branch can set -- the + reference's own example uses it:: + + ALTER TABLE table_with_primary_key ALTER CONSTRAINT a_primary_key_constraint RELY; + ALTER TABLE table_with_foreign_key ALTER CONSTRAINT a_foreign_key_constraint RELY; + + Note the reference's advice to set ``RELY`` on **both** sides of a + primary-key / foreign-key pair. That is a caller obligation, not a + capability. + + Name note: no core probe is per-level for constraint properties yet. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_column_fk_match(self) -> bool: + """True: ``[ MATCH { FULL | SIMPLE | PARTIAL } ]`` is in ``inlineFK``. + + Official grammar:: + + inlineFK ::= + [ CONSTRAINT ] + [ FOREIGN KEY ] + REFERENCES [ ( ) ] + [ MATCH { FULL | SIMPLE | PARTIAL } ] + + The third default that inverts the standard: the reference states + ``Default: MATCH FULL`` where SQL says ``SIMPLE``. So a caller who omits + ``MATCH`` already has ``FULL`` semantics on Snowflake -- and a caller who + omits it on any other backend in this group would have ``SIMPLE``. That + is a behavioural difference, not a rendering one, and it is why the + renderer must not add ``MATCH`` on its own. + + Snowflake is one of the few backends here where ``MATCH`` is genuinely + supported; the 2026-10-11 live measurement found PostgreSQL supports it + and MySQL/MariaDB **silently discard** it (which is why they must refuse + it). Do not read this ``True`` as transferable to those backends. + + Name note: no core probe is per-level for ``fk_match`` yet; the flat + :meth:`supports_fk_match` is the gate with teeth. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_fk_match(self) -> bool: + """True: ``[ MATCH { FULL | SIMPLE | PARTIAL } ]`` is in ``outoflineFK`` too. + + Official grammar: the same group, positioned after the ``REFERENCES`` line + of ``outoflineFK``, ahead of the ``ON [ UPDATE ... ] [ DELETE ... ]`` + pair. Same ``MATCH FULL`` default as + :meth:`supports_column_fk_match`. + + Name note: no core probe is per-level for ``fk_match`` yet. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_column_on_update_action(self) -> bool: + """True: ``ON [ UPDATE { ... } ]`` is in ``inlineFK``. + + Official grammar:: + + inlineFK ::= + ... + [ ON [ UPDATE { CASCADE | SET NULL | SET DEFAULT | RESTRICT | NO ACTION } ] + [ DELETE { CASCADE | SET NULL | SET DEFAULT | RESTRICT | NO ACTION } ] ] + + Five actions, all of them. ``Default: UPDATE NO ACTION``. Note the + shape: **one ``ON`` governs both the ``UPDATE`` and the ``DELETE`` + keyword**, so a caller wanting only ``ON DELETE CASCADE`` writes + ``ON DELETE CASCADE`` -- a single ``ON`` with one following keyword -- + where the SQL-standard spelling uses two ``ON`` keywords. The render + path's separator is load-bearing on this point. + + Worth noting alongside Oracle 21c, which the 2026-10-11 live + measurement found has **no** ``ON UPDATE`` syntax at all. Snowflake is at + the other extreme, and the switch is how that is recorded. + + Name note: no core probe is per-level for referential actions yet; see + :meth:`supports_fk_on_update`. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_on_update_action(self) -> bool: + """True: ``ON [ UPDATE { ... } ]`` is in ``outoflineFK``. + + Official grammar: the same ``ON`` group, after ``MATCH`` and before the + six-property block. Same five actions, same ``UPDATE NO ACTION`` default, + same single-``ON`` shape as :meth:`supports_column_on_update_action`. + + Name note: no core probe is per-level for referential actions yet. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + # ------------------------------------------------------------------ + # Flat kind switches -- the probes core's renderers actually consult + # today. + # + # Read this section next to the two above. Those describe the grammar's + # per-level, per-kind shape; these are what a formatter asks. Where the two + # could be read as disagreeing, the entry that differs says so -- most + # importantly :meth:`supports_constraint_enforced` versus + # :meth:`supports_column_enforced_check_constraint`: the flat probe is + # ``False`` (the render decision; see the module docstring) while the + # per-kind grammar record is ``True`` for the key kinds and ``False`` for + # CHECK. + # ------------------------------------------------------------------ + + def supports_constraint_naming(self) -> bool: + """True: ``[ CONSTRAINT ]`` opens every constraint production. + + Official grammar. All three column-level branches:: + + inlineUniquePK ::= [ CONSTRAINT ] { UNIQUE | PRIMARY KEY } ... + inlineFK ::= [ CONSTRAINT ] [ FOREIGN KEY ] REFERENCES ... + inlineCH ::= [ CONSTRAINT ] CHECK ( ) ... + + and all three table-level ones, from the same page:: + + outoflineUniquePK ::= [ CONSTRAINT ] { UNIQUE | PRIMARY KEY } ( ... ) + outoflineFK ::= [ CONSTRAINT ] FOREIGN KEY ( ... ) REFERENCES ... + outoflineCH ::= [ CONSTRAINT ] CHECK ( ) + + So the name is optional, universal and not kind-dependent: every kind at + both levels carries the prefix. That is the contract's "Snowflake 待核" + item answered from the reference -- there is no Snowflake page that + omits the bracket, and the ALTER TABLE page depends on the name being + there (``ALTER CONSTRAINT `` and ``RENAME CONSTRAINT TO`` + are by-name verbs). + + This is the flat probe core's ``format_column_constraint`` / + ``format_table_constraint`` consult before rendering the prefix, so the + declaration is load-bearing. The per-level, per-kind + :meth:`supports_column_naming_*` / :meth:`supports_table_naming_*` probes + below record the same fact kind by kind; both families are declared so a + change to one cannot move the other silently. + + Declared locally even though core's default is ``True``: the value is the + gate on a refusal, so it must be Snowflake's statement. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_primary_key_constraint(self) -> bool: + """True: core's ``format_column_primary_key_constraint`` may emit ``PRIMARY KEY``. + + Official grammar: ``{ UNIQUE | PRIMARY KEY }`` in ``inlineUniquePK`` at + column level and in ``outoflineUniquePK`` at table level -- the keyword is + in both, so ``True`` at both. + + ``True`` says the keyword may be emitted. It says nothing about what + Snowflake then *does*: on a standard table a primary key is not enforced + ("NOT NULL and CHECK are the only types of constraints that are enforced + by Snowflake"). A caller must not read this as an integrity guarantee. + + Declared locally even though core already answers ``True``: this is the + gate on the refusal, so the value is load-bearing and must not move. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_unique_constraint(self) -> bool: + """True: core's ``format_column_constraint`` may emit ``UNIQUE``. + + Official grammar: the same ``{ UNIQUE | PRIMARY KEY }`` pair in + ``inlineUniquePK`` and ``outoflineUniquePK``. + + Load-bearing detail: ``format_column_unique_constraint`` pairs the + ``UNIQUE`` branch with **this** probe and raises + ``UnsupportedFeatureError(self.name, "UNIQUE column constraint")`` when it + declines, so the ``True`` is the gate on that refusal. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_not_null_constraint(self) -> bool: + """True: ``[ NOT NULL ]`` is in the column definition. + + Official grammar: ``NOT NULL`` sits between the column type and the + inline constraint on both pages:: + + CREATE TABLE ( + [ NOT NULL ] { inlineUniquePK | inlineFK | inlineCH } + [ , [ NOT NULL ] { inlineUniquePK | inlineFK | inlineCH } ] + [ , ... ] + ) + + ADD [ COLUMN ] [ IF NOT EXISTS ] [ ... ] [ NOT NULL ] + { inlineUniquePK | inlineFK | inlineCH } + + Two facts beyond the ``True``: + + * it is **inline only** -- the reference says it "can be specified only as + an inline constraint within the column definition", and there is no + ``NOT NULL`` branch in ``outoflineConstraint``; + * ``NOT NULL`` is one of the **two** constraint kinds Snowflake enforces + on a standard table, so this is the most load-bearing ``True`` on the + backend and the one most worth getting right. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_check_constraint(self) -> bool: + """True: core's ``format_column_check_constraint`` may emit ``CHECK``. + + Official grammar, verbatim:: + + inlineCH ::= + [ CONSTRAINT ] CHECK ( ) + [ ENABLE { VALIDATE | NOVALIDATE } ] + + The kind exists at both levels, so ``True``. Two limitations that are + properties of the clause rather than of the kind, and are recorded + because they bound what a rendered CHECK can mean: + + * *"An inline CHECK constraint can reference only the column it's defined + on. To enforce a condition that spans multiple columns, define the + constraint out of line."* + * the predicate may not contain UDFs, aggregate/window/table functions, + subqueries, state-changing or environment-dependent functions -- + *"System-defined functions that rely on the environment or execution + context, such as the CURRENT_DATE function or the CURRENT_ROLE + function."* + * on a **hybrid** table, a CHECK may be created only in + ``CREATE HYBRID TABLE``. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_foreign_key_constraint(self) -> bool: + """True: core's ``format_column_fk_constraint`` may emit ``REFERENCES``. + + Official grammar: ``[ FOREIGN KEY ] REFERENCES [ + ( ) ]`` in ``inlineFK``, and ``FOREIGN KEY ( ... ) + REFERENCES ...`` in ``outoflineFK``. Both levels, so ``True``. + + Known coupling, inherited from core and worth restating because it is the + only switch where a per-level question has no per-level probe: this flat + probe is what ``format_column_fk_constraint`` consults for the *column* + level. Snowflake answers ``True`` at both levels -- recorded separately + at :meth:`supports_column_foreign_key_constraint` and + :meth:`supports_table_foreign_key_constraint` -- so the coupling costs + nothing here. + + Not enforced on standard tables: see + :meth:`supports_primary_key_constraint` for the shared caveat. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_exclude_constraint(self) -> bool: + """False: ``EXCLUDE`` is a PostgreSQL-family feature, absent here. + + Official grammar: neither page contains an ``EXCLUDE`` production. The + inline grammar's three branches are ``inlineUniquePK``, ``inlineFK`` and + ``inlineCH``; the out-of-line grammar's three are ``outoflineUniquePK``, + ``outoflineFK`` and ``outoflineCH``; and ``constraintAction``'s four + alternatives are ``ADD``, ``RENAME``, ``{ ALTER | MODIFY }`` and + ``DROP``. + + Declared locally because this switch is one of the few *table*-level + probes core's ``format_table_constraint`` actually gates on -- the only + kind there whose absence refuses by name rather than rendering. So the + ``False`` is load-bearing in a way the neighbouring kind switches are + not, and it must not be left to an inherited default. + + Declared ``False`` rather than omitted: Snowflake's clustering keys are + not exclusion constraints, and nothing on this dialect can express one. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + # ------------------------------------------------------------------ + # Referential actions and ``MATCH`` -- the ``ConstraintMixin`` half of + # each question. The ``DDLColumnMixin`` halves are further down and are + # the ones with teeth; both are declared so the halves cannot drift. + # ------------------------------------------------------------------ + + def supports_fk_on_delete(self) -> bool: + """True: ``ON [ DELETE { ... } ]`` is in ``inlineFK`` and ``outoflineFK``. + + Official grammar:: + + [ ON [ UPDATE { CASCADE | SET NULL | SET DEFAULT | RESTRICT | NO ACTION } ] + [ DELETE { CASCADE | SET NULL | SET DEFAULT | RESTRICT | NO ACTION } ] ] + + All five actions, ``Default: DELETE NO ACTION``. One spelling note that + affects rendering: a single ``ON`` covers both ``UPDATE`` and ``DELETE``, + so ``ON DELETE CASCADE`` and ``ON UPDATE ... ON DELETE ...`` are not the + same shape. + + Distinct from the flat declaration and from + :meth:`supports_foreign_key_on_delete`, which is the probe + ``format_references_clause`` raises on. All three are ``True`` and all + three are declared. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_fk_on_update(self) -> bool: + """True: ``ON [ UPDATE { ... } ]`` is in ``inlineFK`` and ``outoflineFK``. + + Official grammar: the same ``ON`` group, quoted at + :meth:`supports_fk_on_delete`. Five actions, ``Default: UPDATE NO + ACTION``. + + Distinct from :meth:`supports_foreign_key_on_update``, the + ``DDLColumnMixin`` probe that ``format_references_clause`` raises on. + Both ``True``; both declared. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_fk_match(self) -> bool: + """**True -- corrected from the inherited ``False``.** Snowflake has ``MATCH``. + + Official grammar:: + + inlineFK ::= + [ CONSTRAINT ] + [ FOREIGN KEY ] + REFERENCES [ ( ) ] + [ MATCH { FULL | SIMPLE | PARTIAL } ] + ... + + and identically in ``outoflineFK``. ``Default: MATCH FULL``. + + **How the inherited answer was ``False``, and why that was wrong.** The + probe is declared by *two* core mixins with *opposite* defaults -- + ``ConstraintMixin.supports_fk_match`` returns ``True`` and + ``DDLColumnMixin.supports_fk_match`` returns ``False`` -- and C3 + linearisation gives priority to the one that appears earlier in the + dialect's base list. On this dialect that is ``DDLColumnMixin``, so the + answer a caller got was ``False``: the shape of an accident, not a + decision. + + The consequence was a refusal of the grammar's own clause. + ``format_references_clause`` raises + ``UnsupportedFeatureError(self.name, "FOREIGN KEY MATCH")`` whenever + ``expr.match_type`` is set and this probe declines, so a caller writing + ``match_type="FULL"`` against Snowflake was told Snowflake does not + support ``MATCH`` -- while Snowflake's reference page lists all three + modes and names ``FULL`` as the default. + + Declared locally so the ``True`` is Snowflake's statement rather than a + side effect of two mixins' base-list order. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_deferrable_constraint(self) -> bool: + """**False** -- the render decision, not the grammar answer. + + Official grammar: ``[ [ NOT ] DEFERRABLE ] [ INITIALLY { DEFERRED | + IMMEDIATE } ]`` appear in ``inlineUniquePK``, ``inlineFK``, + ``outoflineUniquePK`` and ``outoflineFK`` -- and in **neither** ``CHECK`` + branch. + + So the tokens are in the language, and the per-kind probes below record + that. This flat probe is the gate ``format_references_clause`` and + ``format_constraint_traits`` raise on, and it answers ``False`` anyway, + because this backend has no live server to confirm that Snowflake + honours the clause: *"For standard tables, NOT NULL and CHECK are the + only types of constraints that are enforced by Snowflake"*, so a + rendered ``DEFERRABLE`` would be a promise nobody has seen kept. Core's + P1 flipped the shared default to ``False``; this declaration pins the + answer locally so a core change cannot move it. See the + modifier-policy section of the module docstring. + + **The per-kind grammar answer lives elsewhere**, at + :meth:`supports_column_deferrable_primary_key_constraint` and its + siblings. That is not a contradiction to tidy up: core has one flat + probe, this server has no measurement, and the per-kind pair is the + record a live run would be checked against. + + ``INITIALLY``'s inverted default (``INITIALLY DEFERRED``) is recorded at + :meth:`supports_column_deferrable_primary_key_constraint`. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + def supports_constraint_enforced(self) -> bool: + """**False** -- the render decision, taken deliberately. + + Official grammar, in the two branches that have the token:: + + inlineUniquePK ::= [ CONSTRAINT ] { UNIQUE | PRIMARY KEY } [ [ NOT ] ENFORCED ] ... + inlineFK ::= [ CONSTRAINT ] [ FOREIGN KEY ] REFERENCES ... [ [ NOT ] ENFORCED ] ... + + and in the two that do not:: + + inlineCH ::= [ CONSTRAINT ] CHECK ( ) [ ENABLE { VALIDATE | NOVALIDATE } ] + outoflineCH ::= [ CONSTRAINT ] CHECK ( ) [ ENABLE { VALIDATE | NOVALIDATE } ] + + So the clause is in the grammar for the key kinds, and the per-kind + probes record exactly that. This flat probe is what + ``_format_constraint_enforcement`` consumes, and it answers ``False``: + with no Snowflake instance reachable, a ``True`` would licence + ``ENFORCED`` and ``NOT ENFORCED`` SQL nobody has seen the server accept, + and the reference says the non-default spelling is worse than an error -- + *"for PRIMARY KEY, UNIQUE, and FOREIGN KEY constraints, if you specify + ENABLE or VALIDATE ... the constraint isn't created."* Core's P1 flipped + the shared default to ``False``; this declaration pins it here so the + answer is Snowflake's, not an inherited one. + + The kind-accurate grammar record lives at + :meth:`supports_{column,table}_enforced_{primary_key,unique,foreign_key}_constraint` + (``True``) and :meth:`supports_{column,table}_enforced_check_constraint` + (``False``). The two families disagree on purpose; see the + modifier-policy section of the module docstring. + + ``Default: NOT ENFORCED`` -- the SQL-standard inverse -- and on a standard + table the property changes nothing either way: *"For standard tables, NOT + NULL and CHECK are the only types of constraints that are enforced by + Snowflake, regardless of this property."* A caller who sets nothing + already gets ``NOT ENFORCED`` semantics, which is why refusing to spell it + costs this backend nothing it actually relies on. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + def supports_alter_constraint_enforced(self) -> bool: + """**False -- declared explicitly, not inherited.** The ALTER branch lists it. + + Official grammar, the ``{ ALTER | MODIFY }`` branch of + ``constraintAction``, in full:: + + | { ALTER | MODIFY } { CONSTRAINT + | PRIMARY KEY + | UNIQUE + | FOREIGN KEY } ( [ , ... ] ) + } + [ [ NOT ] ENFORCED ] [ VALIDATE | NOVALIDATE ] [ RELY | NORELY ] + + ``[ [ NOT ] ENFORCED ]`` is the **first** of the three properties this + branch can set, so the grammar answer is "the token is there". This + declaration is the *render* answer and it is ``False``, for the same + reason as :meth:`supports_constraint_enforced`: no Snowflake instance is + reachable, so ``ALTER CONSTRAINT ... [NOT] ENFORCED`` cannot be emitted + on anyone's word; core's ``format_alter_constraint_action`` refuses it by + name. The backend already declared this ``False`` before this file + existed, and the clause-pair guard pins both spellings to the refusal, so + keeping it is the honest direction. + + Declared locally -- rather than left to core's shared default, which P1 + also flipped to ``False`` -- so the verdict is Snowflake's and the MRO + guard can see which class owns it. + + This answers the property side of the fact + :meth:`supports_table_alter_constraint` records: Snowflake's ALTER + surface is **strictly smaller** than its ADD surface, and the properties + it does have are exactly the three in the production above. Deferral is + ADD-only -- see the module docstring, and the comment on the render path + in + :mod:`~rhosocial.activerecord.backend.impl.snowflake.mixins.ddl`. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + # ------------------------------------------------------------------ + # ALTER TABLE constraint lifecycle. + # + # Four verbs, and Snowflake is the most complete of the ten backends + # compared this session -- all four are present: + # + # ADD -- out-of-line, all four kinds + # RENAME -- ``RENAME CONSTRAINT TO `` + # ALTER -- ``{ ALTER | MODIFY } ...`` with three mutable properties + # DROP -- by name *or* by kind + column list + # + # What it does **not** have is the set of standalone guard statements the + # other backends reach for: no ``IF EXISTS`` on ``DROP CONSTRAINT``, no + # ``IF NOT EXISTS`` on ``ADD``, no standalone ``VALIDATE CONSTRAINT``, and + # no ``ENABLE``/``DISABLE CONSTRAINT``. Those four absences are the ``False`` + # values below and each one is a grammar fact, not a gap in Snowflake. + # ------------------------------------------------------------------ + + def supports_add_constraint(self) -> bool: + """True: ``ADD outoflineConstraint`` is the first branch of ``constraintAction``. + + Official grammar, verbatim:: + + constraintAction ::= + { + ADD outoflineConstraint + | RENAME CONSTRAINT TO + ... + + and ``outoflineConstraint``'s four branches, which reach all four kinds:: + + outoflineConstraint ::= + [ CONSTRAINT ] + { + UNIQUE [ ( [ , , ... ] ) ] + | PRIMARY KEY [ ( [ , , ... ] ) ] + | [ FOREIGN KEY ] [ ( [ , , ... ] ) ] + REFERENCES [ ( [ , , ... ] ) ] + | CHECK ( ) + } + [ ] + + This is the name core's ``format_add_table_constraint_action`` consults. + The plan document's name for the same fact is + ``supports_table_add_constraint``; the core name is declared because it is + the one a formatter reads, and the per-kind entries just below carry the + "which kinds" half. Declaring both under two names would encode nothing + and let the two drift. + + All four per-kind answers are ``True``, which is why this backend is the + only one of the ten for which ``ADD CONSTRAINT`` reaches every kind. + (BigQuery is the mirror image: the operation exists but adds a foreign key + and nothing else. SQLite adds none at all -- live-measured 2026-10-11.) + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_add_constraint_if_not_exists(self) -> bool: + """False: the ``ADD`` branch has no ``IF NOT EXISTS``. + + Official grammar: ``ADD outoflineConstraint`` -- five tokens, no guard. + Compare the column action on the same page, which **does** have one:: + + ADD [ COLUMN ] [ IF NOT EXISTS ] ... + + So the absence is specific to the constraint branch and not to Snowflake's + ``ADD`` vocabulary. ``SnowflakeAlterColumnModifierMixin`` answers + ``True`` for the column-level guard; this is the constraint-level answer, + and BigQuery's ``True`` for the same switch is BigQuery's grammar + (``ADD [CONSTRAINT [IF NOT EXISTS] constraint_name] FOREIGN KEY ...``), + not a shared default. + + Name note: no core probe of this name exists; the guard is a per-branch + question and this is its record. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + def supports_table_add_primary_key_constraint(self) -> bool: + """True: ``PRIMARY KEY [ ( ... ) ]`` is a branch of ``outoflineConstraint``. + + Official grammar: ``PRIMARY KEY [ ( [ , , ... ] ) ]`` + inside ``outoflineConstraint``, which is what ``ADD`` takes. + + Name note: no core probe is per-kind for ADD yet. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_add_unique_constraint(self) -> bool: + """True: ``UNIQUE [ ( ... ) ]`` is a branch of ``outoflineConstraint``. + + Official grammar: ``UNIQUE [ ( [ , , ... ] ) ]`` + inside ``outoflineConstraint``. + + Name note: no core probe is per-kind for ADD yet. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_add_check_constraint(self) -> bool: + """True for a standard table -- and ``False`` on a hybrid table, which no boolean can say. + + Official grammar: ``CHECK ( )`` is the fourth branch of + ``outoflineConstraint``, so ``ADD CHECK (...)`` is in the grammar. + + The restriction is a property of the **table kind**, quoted verbatim from + the reference:: + + For hybrid tables, you can specify a CHECK constraint only in a + CREATE HYBRID TABLE statement, not in an ALTER TABLE ... ADD + CONSTRAINT statement. + + A boolean cannot express "addable, unless the table is hybrid", and the + render path has no way to know the table kind today, so the honest + dialect-level answer is ``True`` and the condition is recorded here. A + caller targeting a hybrid table must not rely on it. + + Name note: no core probe is per-kind for ADD yet. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_add_foreign_key_constraint(self) -> bool: + """True: ``[ FOREIGN KEY ] [ ( ... ) ] REFERENCES ...`` is a branch of ``outoflineConstraint``. + + Official grammar: the third branch of ``outoflineConstraint``, quoted in + full at :meth:`supports_add_constraint`. + + Note ``FOREIGN KEY`` is bracketed here even though the constraint page's + dedicated ``outoflineFK`` production requires it. The branch is + identified by its ``REFERENCES`` clause either way. + + Name note: no core probe is per-kind for ADD yet. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_drop_constraint(self) -> bool: + """True, and more capable here than on any other backend in this group. + + Official grammar, verbatim:: + + | DROP { CONSTRAINT + | PRIMARY KEY + | UNIQUE | FOREIGN KEY } ( [ , ... ] ) + [ CASCADE | RESTRICT ] + + Two addressable forms in one production: **by name** and **by kind plus + column list**. The 2026-10-11 live measurement found this is the only + backend of the ten that can remove a constraint without naming it -- + MySQL's ``DROP CONSTRAINT`` namespace does not even contain the primary + key, so there a primary key needs a different statement entirely. + + ``[ CASCADE | RESTRICT ]`` is on the branch. Core's + ``format_drop_table_constraint_action`` appends ``CASCADE`` when asked and + has no ``RESTRICT`` gate; see the module docstring's known gaps. + + The by-name branch is also what a CHECK constraint must use: *"For CHECK + constraints, the constraint_name is required."* + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_drop_constraint_by_kind(self) -> bool: + """True -- Snowflake's one distinctive capability among the ten backends. + + Official grammar, verbatim:: + + | DROP { CONSTRAINT + | PRIMARY KEY + | UNIQUE | FOREIGN KEY } ( [ , ... ] ) + [ CASCADE | RESTRICT ] + + Read the four alternatives: ``PRIMARY KEY``, ``UNIQUE`` and + ``FOREIGN KEY`` are constraint **kinds** in the drop position, each + followed by a mandatory column list. So ``ALTER TABLE t DROP PRIMARY KEY + (a, b)`` is in the grammar without any constraint ever having been + named. + + **Nothing else in this group can do this.** The 2026-10-11 live + measurement across nine reachable instances found drop-by-kind working + nowhere: MySQL needs ``DROP PRIMARY KEY`` as a separate statement and its + named drop will not touch a primary key at all; MariaDB, PostgreSQL, + SQL Server, ClickHouse and Firebird drop by name only; Oracle and SQLite + by name only. Nine of ten drop by name; this is the tenth, and it drops + both ways. + + **CHECK is excluded**, twice over: it is not in the kind list, and the + reference adds *"For CHECK constraints, the constraint_name is required."* + + **Known gap, recorded not worked around.** ``DropTableConstraint`` + carries a ``constraint_name`` only, so this shape has no node to hang on + and cannot be rendered today. A node carrying kind-plus-columns is core + S1 work; the value here records that the capability exists so that work + knows what to aim at. + + Name note: no core probe of this name exists. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_rename_constraint(self) -> bool: + """True: ``RENAME CONSTRAINT TO `` is the second branch. + + Official grammar, verbatim:: + + | RENAME CONSTRAINT TO + + The ALTER TABLE page's parameter text is the whole of the description: + *"Renames the specified constraint."* + + Snowflake is one of only three backends in this group with a real rename + verb -- PostgreSQL and Oracle are the others, and SQL Server reaches the + same effect through ``sp_rename`` with its two-argument form. MySQL, + MariaDB, ClickHouse, Firebird and SQLite have none of the three. + + Reachable in practice because every Snowflake constraint is nameable + (:meth:`supports_{column,table}_naming_*_constraint`), which is not + something every backend can say. + + Name note: no core probe of this name exists; ``format_*`` for a rename + action is core S1 work. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_alter_constraint(self) -> bool: + """True: ``{ ALTER | MODIFY } ...`` is the third branch. + + Official grammar, verbatim:: + + | { ALTER | MODIFY } { CONSTRAINT + | PRIMARY KEY + | UNIQUE + | FOREIGN KEY } ( [ , ... ] ) + } + [ [ NOT ] ENFORCED ] [ VALIDATE | NOVALIDATE ] [ RELY | NORELY ] + + Three things in that production, all of them facts worth having written + down: + + * **``ALTER`` and ``MODIFY`` are one operation with two spellings.** They + are alternatives in the grammar but they are *synonyms*, not + distinguishable behaviours, so they collapse to a single semantic state + with the spelling chosen by a formatter. Two booleans would encode + nothing. + * **Addressable by name or by kind plus column list** -- and ``CHECK`` + by name only: *"For CHECK constraints, the constraint_name is + required."* + * **Only three mutable properties**, and deferral is not among them. + ``[NOT] ENFORCED``, ``VALIDATE | NOVALIDATE``, ``RELY | NORELY`` -- + where ``ADD outoflineConstraint`` can set all six. **Snowflake's ALTER + surface is strictly smaller than its ADD surface**, which is the + backend's defining asymmetry and the reason an ALTER path that could set + ``DEFERRABLE`` would be wrong. + + The 2026-10-11 live measurement found this backend's ALTER verb together + with MySQL's (which only manages ``[NOT] ENFORCED``) -- MySQL and + Snowflake are the only two of the ten with any ``ALTER CONSTRAINT`` at + all, and they are the two ends of the range: one property versus three. + + Name note: no core probe of this name exists. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_constraint_novalidate(self) -> bool: + """False -- and this is about the ``NOT VALID`` *spelling*, not the ``NOVALIDATE`` property. + + Official grammar, on every production that can express the idea:: + + [ { VALIDATE | NOVALIDATE } ] + + There is no ``NOT VALID`` token anywhere in Snowflake's constraint + grammar, on either page. ``NOVALIDATE`` is a single word. + + Core's ``_format_constraint_validation`` emits the two-word ``" NOT + VALID"`` and gates it on this probe, so declaring ``False`` is what keeps + that spelling out of Snowflake DDL. Before the declaration the probe was + simply **absent** from the backend, and core consulted it defensively; an + absence doing the work of a decision. Now it is a decision. + + **Must not be reconciled with** + :meth:`supports_table_constraint_novalidate` / ``_column_``, which are + ``True``. Those gate the ``{ VALIDATE | NOVALIDATE }`` *property*, which + Snowflake has and which is a different token from the ``NOT VALID`` + spelling. Collapsing the two would either license a token that is not in + the grammar or drop a property that is. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + def supports_validate_constraint(self) -> bool: + """False: there is no standalone ``VALIDATE CONSTRAINT`` statement. + + Official grammar: ``constraintAction`` has four alternatives -- ``ADD``, + ``RENAME CONSTRAINT``, ``{ ALTER | MODIFY } ...`` and ``DROP ...``. The + only ``VALIDATE`` in the whole production is + ``[ VALIDATE | NOVALIDATE ]``, which is an **attribute of the + ``{ ALTER | MODIFY }`` branch** -- ``ALTER TABLE t ALTER CONSTRAINT c + VALIDATE`` -- not a verb of its own. + + That distinction decides this switch, because its one consumer in core, + ``format_validate_constraint_action``, renders a standalone + ``VALIDATE CONSTRAINT ``: + + return f"VALIDATE CONSTRAINT {self.format_identifier(action.constraint_name)}", () + + Declaring ``True`` would license a statement Snowflake's grammar does not + contain. The capability Snowflake *does* have -- validating a constraint + through ``ALTER`` -- is recorded at + :meth:`supports_table_alter_constraint` and, as a property, at + :meth:`supports_table_constraint_novalidate`. + + **Recorded conflict.** The 2026-10-11 live-findings matrix gives + Snowflake a tick in its ``VALIDATE CONSTRAINT`` row, while the protocol + list in the same document says ``supports_validate_constraint`` is True + for PostgreSQL and Oracle only, *"其余 False"*. The two rows of that one + document disagree, and the grammar is what settles it: there is no + standalone statement. The matrix's tick is best read as "Snowflake can + validate a constraint", which is true and is what the property switch + says. + + Declared locally: core already answers ``False``; this is the pin. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + # ------------------------------------------------------------------ + # The ``DDLColumnMixin`` pair that ``format_references_clause`` raises on, + # plus the column ``COLLATE`` probe. Declared here so every constraint + # answer Snowflake gives comes from one file. + # ------------------------------------------------------------------ + + def supports_foreign_key_on_delete(self) -> bool: + """True -- and this is the gate that stops ``ON DELETE`` being *refused*. + + Official grammar:: + + [ ON [ UPDATE { CASCADE | SET NULL | SET DEFAULT | RESTRICT | NO ACTION } ] + [ DELETE { CASCADE | SET NULL | SET DEFAULT | RESTRICT | NO ACTION } ] ] + + ``DDLColumnMixin.format_references_clause`` consults this probe and + raises ``UnsupportedFeatureError(self.name, "FOREIGN KEY ON DELETE")`` + when it declines. ``DDLColumnMixin`` defaults it to ``True``, so the + inherited answer happened to be right -- but it was inherited, and this + backend declared nothing about it. + + All five actions. Note the single-``ON`` spelling: Snowflake writes one + ``ON`` covering both the optional ``UPDATE`` and the optional ``DELETE`` + keyword, where the SQL standard uses two. A renderer that joins two + separately-rendered clauses with ``" ON "`` twice will produce SQL + Snowflake does not accept. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_foreign_key_on_update(self) -> bool: + """True -- and this is the gate that stops ``ON UPDATE`` being *refused*. + + Official grammar: the same ``ON`` group, quoted at + :meth:`supports_foreign_key_on_delete`. ``DDLColumnMixin`` defaults this + probe to ``True`` and ``format_references_clause`` raises on a ``False``, + so again the inherited answer was right by default rather than by + decision. + + All five actions, ``Default: UPDATE NO ACTION``. Contrast Oracle 21c, + which the 2026-10-11 live measurement found has **no** ``ON UPDATE`` + syntax at all -- Snowflake is at the opposite extreme and this is the + record of it. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + # ``supports_column_collation`` deliberately has **no** declaration here. + # It is a column-attribute question (``COLLATE ''`` in the column + # definition), not a constraint answer, and ``SnowflakeCollationMixin`` -- + # which precedes this class in the dialect's MRO -- already declares it + # ``True`` next to the ``format_column_attribute`` that renders the quotes. + # A second copy here would be dead code that the MRO never reaches, and two + # owners for one switch is the thing the local-declaration rule exists to + # prevent. The draft that became this module carried such a copy; it was + # dropped rather than kept in parallel with the runtime home. + + +__all__ = ["ALTER_TABLE_GRAMMAR_URL", "OFFICIAL_GRAMMAR_URL", "SnowflakeConstraintMixin"] \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/datetime.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/datetime.py index 7425352..facc58a 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/datetime.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/datetime.py @@ -7,9 +7,9 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.expression.datetime import ( - DateTimeAddExpression, - DateTimeDiffExpression, - DateTimeSubtractExpression, + DatetimeAddExpression, + DatetimeDiffExpression, + DatetimeSubtractExpression, ) @@ -27,7 +27,7 @@ class SnowflakeDateTimeMixin: """ def format_datetime_add_expression( - self, expr: "DateTimeAddExpression" + self, expr: "DatetimeAddExpression" ) -> Tuple[str, tuple]: source_sql, source_params = expr.source.to_sql() unit = expr.interval.unit.value.upper() @@ -37,7 +37,7 @@ def format_datetime_add_expression( ) def format_datetime_subtract_expression( - self, expr: "DateTimeSubtractExpression" + self, expr: "DatetimeSubtractExpression" ) -> Tuple[str, tuple]: source_sql, source_params = expr.source.to_sql() unit = expr.interval.unit.value.upper() @@ -47,7 +47,7 @@ def format_datetime_subtract_expression( ) def format_datetime_diff_expression( - self, expr: "DateTimeDiffExpression" + self, expr: "DatetimeDiffExpression" ) -> Tuple[str, tuple]: start_sql, start_params = expr.start.to_sql() end_sql, end_params = expr.end.to_sql() diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl.py index 4f7c327..1ba4e8c 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl.py @@ -4,7 +4,12 @@ from typing import Tuple from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError -from rhosocial.activerecord.backend.expression.statements import ColumnConstraintType +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefaultConstraint, + ColumnForeignKeyConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, +) class SnowflakeAlterColumnModifierMixin: @@ -45,13 +50,19 @@ def supports_column_comment(self) -> bool: def format_add_column_action(self, action) -> Tuple[str, tuple]: if getattr(action, "if_not_exists", None) is True: + # The kinds Snowflake's documented restriction names: DEFAULT, + # IDENTITY (which ``AUTO_INCREMENT`` is a spelling of here), PRIMARY + # KEY, FOREIGN KEY and UNIQUE. The kind is the *branch class* now -- + # ``ColumnConstraintType`` is gone -- so the set is a set of classes. forbidden = { - ColumnConstraintType.DEFAULT, - ColumnConstraintType.PRIMARY_KEY, - ColumnConstraintType.FOREIGN_KEY, - ColumnConstraintType.UNIQUE, + ColumnDefaultConstraint, + ColumnPrimaryKeyConstraint, + ColumnForeignKeyConstraint, + ColumnUniqueConstraint, } - if any(c.constraint_type in forbidden for c in action.column.constraints): + if any( + type(c.branch) in forbidden for c in action.column.constraints + ): raise UnsupportedFeatureError( self.name, "ADD COLUMN IF NOT EXISTS with DEFAULT/IDENTITY/PK/FK/UNIQUE", diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_database.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_database.py index f6020e7..18d599c 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_database.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_database.py @@ -4,14 +4,15 @@ from typing import Tuple, TYPE_CHECKING -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.objects import Database +from ..expression.database import SnowflakeCreateDatabaseExpression + if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements.ddl_database import ( - AlterDatabaseExpression, - CreateDatabaseExpression, - DropDatabaseExpression, + AlterDatabaseStatement, + CreateDatabaseStatement, + DropDatabaseStatement, ) @@ -86,7 +87,7 @@ def supports_database_force_drop(self) -> bool: return True def format_create_database_statement( - self, expr: CreateDatabaseExpression + self, expr: CreateDatabaseStatement ) -> Tuple[str, tuple]: # The object-kind check belongs to the formatter that consumes the # object, not to the constructor. An object carries its own @@ -101,22 +102,24 @@ def format_create_database_statement( parts = ["CREATE"] if expr.or_replace: parts.append("OR REPLACE") - if getattr(expr, "transient", False): + # ``TRANSIENT`` and ``CLONE`` are Snowflake's own grammar; the core + # statement documents that they live on this backend's subclass, so + # the subclass is asked by name rather than probed for. + if isinstance(expr, SnowflakeCreateDatabaseExpression) and expr.transient: parts.append("TRANSIENT") parts.append("DATABASE") if expr.if_not_exists: parts.append("IF NOT EXISTS") parts.append(expr.database.to_sql()[0]) - clone_source = getattr(expr, "clone", None) - if clone_source: - parts.append(f"CLONE {self.format_identifier(clone_source)}") + if isinstance(expr, SnowflakeCreateDatabaseExpression) and expr.clone: + parts.append(f"CLONE {self.format_identifier(expr.clone)}") if expr.comment: escaped_comment = expr.comment.replace("'", "''") parts.append(f"COMMENT = '{escaped_comment}'") return " ".join(parts), () def format_drop_database_statement( - self, expr: DropDatabaseExpression + self, expr: DropDatabaseStatement ) -> Tuple[str, tuple]: if not isinstance(expr.database, Database): raise TypeError( @@ -130,7 +133,7 @@ def format_drop_database_statement( return " ".join(parts), () def format_alter_database_statement( - self, expr: AlterDatabaseExpression + self, expr: AlterDatabaseStatement ) -> Tuple[str, tuple]: from rhosocial.activerecord.backend.expression.statements.ddl_database import AlterDatabaseAction if not isinstance(expr.database, Database): diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_table_modifier.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_table_modifier.py index 0b599d4..13db067 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_table_modifier.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_table_modifier.py @@ -98,9 +98,7 @@ def format_table_comment(self, comment: str) -> Tuple[str, tuple]: generic core rendering emits); the column-level form stays bare and is rendered by the generic ``format_column_definition``. """ - from rhosocial.activerecord.backend.dialect.base import SQLDialectBase - - escaped = SQLDialectBase._escape_sql_string(comment) + escaped = self.escape_sql_string(comment) return f"COMMENT = '{escaped}'", () def supports_create_table_like(self) -> bool: diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_type.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_type.py index f1524e4..f20ddbf 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_type.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_type.py @@ -6,14 +6,16 @@ from rhosocial.activerecord.backend.dialect.mixins.user_defined_type import UserDefinedTypeMixin from rhosocial.activerecord.backend.expression.objects import Type as TypeObject from rhosocial.activerecord.backend.expression.statements.ddl_type import ( - AlterTypeExpression, - CreateTypeExpression, - DropTypeExpression, + AlterTypeStatement, + CreateTypeStatement, + DropTypeStatement, TypeAlterAction, TypeDefinition, ) from ..expression.ddl.type import ( + SnowflakeCreateTypeExpression, + SnowflakeDropTypeExpression, SnowflakeObjectTypeDefinition, SnowflakeScalarTypeDefinition, SnowflakeSetTypeCommentAction, @@ -113,7 +115,7 @@ def supports_multiple_type_alter_actions(self) -> bool: def _format_type_name( self, - expr: Union[CreateTypeExpression, AlterTypeExpression, DropTypeExpression], + expr: Union[CreateTypeStatement, AlterTypeStatement, DropTypeStatement], ) -> str: """Render *expr*'s TYPE name as SQL. @@ -141,7 +143,7 @@ def _format_type_name( def format_create_type_statement( self, - expr: CreateTypeExpression, + expr: CreateTypeStatement, ) -> Tuple[str, tuple]: self._require_type_support("CREATE TYPE") if expr.if_not_exists and expr.or_replace: @@ -163,8 +165,11 @@ def format_create_type_statement( if expr.if_not_exists: parts.append("IF NOT EXISTS") parts.extend((self._format_type_name(expr), definition_sql)) - comment = getattr(expr, "comment", None) - if comment is not None: + # The ``COMMENT`` qualifier is Snowflake's own; the core statement + # carries no such field, so the subclass that declares it is asked + # by name rather than probed for. + if isinstance(expr, SnowflakeCreateTypeExpression) and expr.comment is not None: + comment = expr.comment if not isinstance(comment, str): raise TypeError("Snowflake TYPE comment must be a string") parts.extend(("COMMENT", "=", self.format_literal(comment))) @@ -172,7 +177,7 @@ def format_create_type_statement( def format_alter_type_statement( self, - expr: AlterTypeExpression, + expr: AlterTypeStatement, ) -> Tuple[str, tuple]: self._require_type_support("ALTER TYPE") if expr.if_exists and not self.supports_alter_type_if_exists(): @@ -200,15 +205,21 @@ def format_alter_type_statement( def format_drop_type_statement( self, - expr: DropTypeExpression, + expr: DropTypeStatement, ) -> Tuple[str, tuple]: self._require_type_support("DROP TYPE") if expr.if_exists: raise UnsupportedFeatureError(self.name, "DROP TYPE IF EXISTS") - if getattr(expr, "cascade", False): - raise UnsupportedFeatureError(self.name, "DROP TYPE CASCADE") - if getattr(expr, "restrict", False): - raise UnsupportedFeatureError(self.name, "DROP TYPE RESTRICT") + # ``cascade`` / ``restrict`` are declared only by this backend's + # expression, which refuses either spelling at construction time and + # pins both fields to False; the render-time refusal stays as the + # second gate rather than silently trusting the first. The core + # statement has no such fields, so a plain one renders the bare form. + if isinstance(expr, SnowflakeDropTypeExpression): + if expr.cascade: + raise UnsupportedFeatureError(self.name, "DROP TYPE CASCADE") + if expr.restrict: + raise UnsupportedFeatureError(self.name, "DROP TYPE RESTRICT") return f"DROP TYPE {self._format_type_name(expr)}", () def format_type_definition( @@ -240,7 +251,7 @@ def format_type_definition( return f"AS OBJECT({', '.join(field_sql)})", tuple(field_params) raise UnsupportedFeatureError( self.name, - f"TYPE definition {getattr(expr, 'definition_kind', type(expr).__name__)}", + f"TYPE definition {expr.definition_kind}", ) def format_snowflake_type_field( @@ -262,7 +273,7 @@ def format_type_alter_action( return "UNSET COMMENT", () raise UnsupportedFeatureError( self.name, - f"ALTER TYPE action {getattr(expr, 'action_kind', type(expr).__name__)}", + f"ALTER TYPE action {expr.action_kind}", ) diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/dml.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/dml.py index de64cd8..6a519c2 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/dml.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/dml.py @@ -3,9 +3,11 @@ from typing import Tuple, TYPE_CHECKING +from ..expression.dml import SnowflakeInsertExpression + if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements import ( - InsertExpression, + InsertStatement, ) @@ -22,26 +24,30 @@ def supports_insert_overwrite(self) -> bool: return True def format_insert_statement( - self, expr: "InsertExpression" + self, expr: "InsertStatement" ) -> Tuple[str, tuple]: """Format an INSERT statement, honouring the ``overwrite`` option. - When ``getattr(expr, "overwrite", False)`` is truthy the statement is - rendered as ``INSERT OVERWRITE INTO ...``. + When the statement is a :class:`SnowflakeInsertExpression` whose + ``overwrite`` field is truthy it is rendered as + ``INSERT OVERWRITE INTO ...``; any other INSERT keeps the core + rendering. The flag belongs to the Snowflake expression alone -- the + core ``InsertStatement`` declares no such field -- so the subclass is + asked by name rather than probed for the attribute. Args: - expr: :class:`InsertExpression`. + expr: :class:`InsertStatement`. Returns: Tuple of (SQL string, params tuple). """ - if getattr(expr, "overwrite", False): + if isinstance(expr, SnowflakeInsertExpression) and expr.overwrite: return self.format_insert_overwrite_statement(expr) return super().format_insert_statement(expr) def format_insert_overwrite_statement( - self, expr: "InsertExpression" + self, expr: "InsertStatement" ) -> Tuple[str, tuple]: """Format an INSERT OVERWRITE INTO statement. @@ -49,7 +55,7 @@ def format_insert_overwrite_statement( same transaction (avoiding the implicit commit of a separate TRUNCATE). Args: - expr: :class:`InsertExpression`. + expr: :class:`InsertStatement`. Returns: Tuple of (SQL string, params tuple). diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/file_format.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/file_format.py index e2dac91..f91f14e 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/file_format.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/file_format.py @@ -1,13 +1,14 @@ # src/rhosocial/activerecord/backend/impl/snowflake/mixins/file_format.py """SnowflakeFileFormatMixin — file format DDL support.""" +from enum import Enum from typing import Any, Tuple, TYPE_CHECKING if TYPE_CHECKING: from ..expression.ddl.file_format import ( - SnowflakeAlterFileFormatExpression, - SnowflakeCreateFileFormatExpression, - SnowflakeDropFileFormatExpression, + SnowflakeAlterFileFormatStatement, + SnowflakeCreateFileFormatStatement, + SnowflakeDropFileFormatStatement, ) @@ -35,12 +36,12 @@ def supports_file_formats(self) -> bool: return True def format_create_file_format_statement( - self, expr: "SnowflakeCreateFileFormatExpression" + self, expr: "SnowflakeCreateFileFormatStatement" ) -> Tuple[str, tuple]: """Format CREATE [OR REPLACE] FILE FORMAT statement. Args: - expr: :class:`SnowflakeCreateFileFormatExpression`. + expr: :class:`SnowflakeCreateFileFormatStatement`. Returns: Tuple of (SQL string, empty params tuple). @@ -54,25 +55,28 @@ def format_create_file_format_statement( parts.append("IF NOT EXISTS") parts.append(self.format_identifier(expr.name)) if expr.type_ is not None: - type_value = getattr(expr.type_, "value", expr.type_) + # The declared type of ``type_`` admits the enum and the raw + # string; the enum's own value is the spelling, read off the + # member by name once the kind is known rather than probed for. + type_value = expr.type_.value if isinstance(expr.type_, Enum) else expr.type_ parts.append(f"TYPE = {str(type_value).upper()}") for key, value in expr.options.items(): parts.append( - f"{key} = {_format_ff_value(value, self._escape_sql_string)}" + f"{key} = {_format_ff_value(value, self.escape_sql_string)}" ) if expr.comment is not None: parts.append( - f"COMMENT = '{self._escape_sql_string(expr.comment)}'" + f"COMMENT = '{self.escape_sql_string(expr.comment)}'" ) return " ".join(parts), () def format_alter_file_format_statement( - self, expr: "SnowflakeAlterFileFormatExpression" + self, expr: "SnowflakeAlterFileFormatStatement" ) -> Tuple[str, tuple]: """Format ALTER FILE FORMAT SET statement. Args: - expr: :class:`SnowflakeAlterFileFormatExpression`. + expr: :class:`SnowflakeAlterFileFormatStatement`. Returns: Tuple of (SQL string, empty params tuple). @@ -83,11 +87,11 @@ def format_alter_file_format_statement( options: list[str] = [] for key, value in expr.options.items(): options.append( - f"{key} = {_format_ff_value(value, self._escape_sql_string)}" + f"{key} = {_format_ff_value(value, self.escape_sql_string)}" ) if expr.comment is not None: options.append( - f"COMMENT = '{self._escape_sql_string(expr.comment)}'" + f"COMMENT = '{self.escape_sql_string(expr.comment)}'" ) if not options: raise ValueError("ALTER FILE FORMAT SET requires a property") @@ -100,12 +104,12 @@ def format_alter_file_format_statement( return " ".join(parts), () def format_drop_file_format_statement( - self, expr: "SnowflakeDropFileFormatExpression" + self, expr: "SnowflakeDropFileFormatStatement" ) -> Tuple[str, tuple]: """Format DROP FILE FORMAT statement. Args: - expr: :class:`SnowflakeDropFileFormatExpression`. + expr: :class:`SnowflakeDropFileFormatStatement`. Returns: Tuple of (SQL string, empty params tuple). diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ilike.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ilike.py index c3302ec..bebc4e8 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ilike.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ilike.py @@ -3,7 +3,7 @@ from typing import Tuple, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover - from rhosocial.activerecord.backend.expression.predicates import ILIKEExpression + from rhosocial.activerecord.backend.expression.predicates import ILikePredicate class SnowflakeILIKEMixin: @@ -13,23 +13,23 @@ def supports_ilike(self) -> bool: """Snowflake supports the native ILIKE operator.""" return True - def format_ilike_expression( - self, expr: "ILIKEExpression" + def format_ilike_predicate( + self, expr: "ILikePredicate" ) -> Tuple[str, tuple]: """Format a native Snowflake [NOT] ILIKE expression. Takes the node and reads ``column``, ``pattern`` and ``negate`` off it, which is the shape ``ILIKESupport`` declares and the shape - ``ILIKEExpression.to_sql()`` dispatches: the node carries its own data, + ``ILikePredicate.to_sql()`` dispatches: the node carries its own data, so the formatter is called with the node alone. The three-argument form ``(column, pattern, negate)`` this used to have cannot be reached by dispatch at all -- ``to_sql()`` passes one argument - -- so it raised ``TypeError: format_ilike_expression() missing 1 + -- so it raised ``TypeError: format_ilike_predicate() missing 1 required positional argument: 'pattern'`` on every ILIKE, and Snowflake ILIKE was unreachable. Nothing caught it: the conformance tests check that a method of the right *name* exists, not that it can be called the - way the protocol calls it, and no test rendered an ``ILIKEExpression``. + way the protocol calls it, and no test rendered an ``ILikePredicate``. The round-trip matrix found it by constructing one and rendering it. The pattern is passed through unchanged, not lowercased as the shared diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/introspection.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/introspection.py index 15741be..d35fd49 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/introspection.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/introspection.py @@ -1,19 +1,46 @@ # src/rhosocial/activerecord/backend/impl/snowflake/mixins/introspection.py """SnowflakeIntrospectionMixin — schema introspection via INFORMATION_SCHEMA.""" -from typing import List, Tuple, TYPE_CHECKING + +from typing import TYPE_CHECKING, List, Optional, Tuple + +from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonPredicate, + FunctionCall, + InPredicate, + IsNullPredicate, + LikeOperator, + LikePredicate, + Literal, + LogicalPredicate, + OrderByClause, + QueryStatement, + WhereClause, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.query_parts import JoinClause, JoinType +from rhosocial.activerecord.backend.expression.sources.relation import NamedRelationRef + +from ..expression.introspection import ( + SnowflakeDatabaseListQueryStatement, + SnowflakeSchemaListQueryStatement, + SnowflakeTableStorageMetricsQueryStatement, + SnowflakeWarehouseListQueryStatement, +) +from ..expression.show import SnowflakeShowObjectType, SnowflakeShowStatement if TYPE_CHECKING: - from rhosocial.activerecord.backend.introspection.types import IntrospectionScope from rhosocial.activerecord.backend.expression.introspection import ( - DatabaseInfoExpression, - TableListExpression, - ColumnInfoExpression, - IndexInfoExpression, - ForeignKeyExpression, - ViewListExpression, - ViewInfoExpression, - TriggerListExpression, + ColumnInfoQueryStatement, + DatabaseInfoQueryStatement, + ForeignKeyQueryStatement, + IndexInfoQueryStatement, + TableListQueryStatement, + TriggerListQueryStatement, + ViewInfoQueryStatement, + ViewListQueryStatement, ) + from rhosocial.activerecord.backend.introspection.types import IntrospectionScope class SnowflakeIntrospectionMixin: @@ -34,8 +61,53 @@ class SnowflakeIntrospectionMixin: Snowflake supports introspection via INFORMATION_SCHEMA views. Snowflake does not support traditional indexes or triggers. + + Every formatter here builds a **tree**, not text. The projection is a list + of :class:`~rhosocial.activerecord.backend.expression.core.Column` nodes, + the filtering a conjunction of predicate nodes, and the whole question a + :class:`~rhosocial.activerecord.backend.expression.statements.QueryStatement` + that renders itself through the dialect. Nothing in this file writes a + ``SELECT`` keyword, a comma or a placeholder: the statements below used to + be assembled one string fragment at a time, and the fragmentation was the + defect -- a clause added in one branch and forgotten in another is + invisible in a concatenation and obvious in a tree. """ + def supports_warehouse_introspection(self) -> bool: + """Snowflake answers for its warehouses from ``SHOW WAREHOUSES``. + + The capability for the ``introspect warehouses`` family. There + is no INFORMATION_SCHEMA view for them, which is why the + statement is a SHOW rather than a SELECT.""" + return True + + def supports_schema_introspection(self) -> bool: + """Snowflake answers for its schemas from ``INFORMATION_SCHEMA.SCHEMATA``. + + The capability for the ``introspect schemas`` family; a schema + here is a level below a database, which is why it is not the + same switch as the database one.""" + return True + + def supports_database_introspection(self) -> bool: + """Snowflake answers for its databases from ``INFORMATION_SCHEMA.DATABASES``. + + The capability for the ``introspect databases`` family -- an + account-level question the other engines do not have.""" + return True + + def supports_table_storage_metrics(self) -> bool: + """Snowflake answers for one table's billed storage. + + The capability for the ``introspect table-storage-metrics`` family. + The answer is a join of ``TABLE_STORAGE_METRICS`` with ``TABLES``, + not a column of the ``tables`` listing, which is why it is a family + of its own; see + :meth:`format_table_storage_metrics_query_statement` for the columns + and the ``ACCOUNTADMIN`` caveat that makes an absent row distinct + from an absent table.""" + return True + # ========== Capability Detection ========== def supports_introspection(self) -> bool: @@ -73,6 +145,7 @@ def supports_trigger_introspection(self) -> bool: def get_supported_introspection_scopes(self) -> List["IntrospectionScope"]: """Get list of supported introspection scopes.""" from rhosocial.activerecord.backend.introspection.types import IntrospectionScope + return [ IntrospectionScope.DATABASE, IntrospectionScope.TABLE, @@ -82,69 +155,280 @@ def get_supported_introspection_scopes(self) -> List["IntrospectionScope"]: IntrospectionScope.VIEW, ] + # ========== The catalog read, expressed once ========== + + # ------------------------------------------------------------------ + # Why the catalog objects are spelled unquoted + # ------------------------------------------------------------------ + # Every Information Schema view and column this mixin names is written + # with ``*_need_quote=False``, so the rendered SQL reads + # ``INFORMATION_SCHEMA.TABLES`` and ``TABLE_SCHEMA`` the way the manual + # spells them. That is not carelessness about quoting, it is the rule the + # manual states: "Snowflake automatically converts unquoted, + # case-insensitive identifiers to uppercase internally, so unquoted object + # names must be queried in uppercase in the Information Schema views" + # (https://docs.snowflake.com/en/sql-reference/info-schema). The catalog + # stores these names uppercase, so an unquoted reference resolves to + # exactly the object the manual documents, and quoting each one -- which + # this dialect does by default, for the case-sensitive names users create + # -- would only make the statement harder to read against its source page. + # The one exception is a *database* named by the caller, which is quoted: + # it may have been created with quotes and then carries its case. + + def _catalog_column( + self, + name: str, + table: Optional[str] = None, + alias: Optional[str] = None, + ) -> Column: + """One Information Schema column reference, in the manual's spelling. + + Args: + name: The column name as the manual writes it, uppercase. + table: Optional alias of the view it is read from (``m``, ``t``, + ``tc``, ...), unquoted for the same reason as the column. + alias: Optional result alias, used where a projection combines two + views' columns under one documented name. + + Returns: + The column node, ready to be selected, compared or ordered by. + """ + return Column( + self, + name, + table=table, + alias=alias, + table_need_quote=False, + name_need_quote=False, + alias_need_quote=False, + ) + + def _information_schema_view( + self, + name: str, + alias: Optional[str] = None, + catalog: Optional[str] = None, + catalog_need_quote: bool = True, + ) -> NamedRelationRef: + """One view of the ``INFORMATION_SCHEMA`` schema, as a row source. + + The schema name itself is never quoted and never omitted: the manual + says a view must be named either fully qualified, + ``database.INFORMATION_SCHEMA.name``, or as + ``INFORMATION_SCHEMA.name`` with that schema in use + (https://docs.snowflake.com/en/sql-reference/info-schema), and this + backend always writes the second form or the first -- never a bare + ``TABLES``, which would silently read a user's object of that name. + + Args: + name: The view's name, uppercase. + alias: Optional short alias for a query that reads two views. + catalog: The database to qualify the view with, or ``None`` to + leave it to the session's current database. + catalog_need_quote: Whether to quote *catalog*. ``True`` (the + default) for a name a caller supplied, which may be + case-sensitive; ``False`` for this backend's own ``SNOWFLAKE`` + qualifier, which is a fixed word. + + Returns: + The row source, ready to be handed to a statement's ``from_``. + """ + return NamedRelationRef( + self, + Table( + self, + name, + catalog_name=catalog, + schema_name="INFORMATION_SCHEMA", + catalog_need_quote=catalog_need_quote, + schema_need_quote=False, + name_need_quote=False, + ), + alias=alias, + alias_need_quote=False, + ) + + def _catalog_query( + self, + projections: List[Column], + source, + conditions: Optional[List] = None, + order_by: Optional[List[Column]] = None, + ) -> Tuple[str, tuple]: + """Build one ``SELECT`` over a catalog source as an expression tree. + + Every question this mixin asks the catalog has the same shape -- a + projection, a conjunction of conditions and an optional ordering -- so + the shape lives here and each formatter supplies only what varies. + + Args: + projections: The select list, in order. + source: The row source: one view, or a + :class:`~rhosocial.activerecord.backend.expression.query_parts.JoinClause` + over two of them. + conditions: Conditions to AND together, or ``None`` for no + ``WHERE`` clause. + order_by: The ordering expressions, or ``None`` for no ``ORDER BY``. + + Returns: + Tuple of (SQL string, parameters tuple). + """ + statement = QueryStatement(self, select=projections, from_=source) + if conditions: + statement.where = WhereClause(self, LogicalPredicate(self, "AND", *conditions)) + if order_by is not None: + statement.order_by = OrderByClause(self, order_by) + return statement.to_sql() + # ========== Query Formatting ========== - def format_database_info_query( - self, expr: "DatabaseInfoExpression" + def format_database_info_query_statement( + self, expr: "DatabaseInfoQueryStatement" ) -> Tuple[str, tuple]: """Format database information query using context functions.""" - sql = ( - "SELECT CURRENT_DATABASE() AS CATALOG_NAME, " - "CURRENT_VERSION() AS SERVER_VERSION" + # No FROM: this query reads no table, it asks the engine a question. + return QueryStatement( + self, + select=[ + FunctionCall(self, "CURRENT_DATABASE", alias="CATALOG_NAME"), + FunctionCall(self, "CURRENT_VERSION", alias="SERVER_VERSION"), + ], + ).to_sql() + + def _no_table_type_predicate(self) -> "ComparisonPredicate": + """A comparison that cannot hold, for a kind the catalogue never names. + + A core ``TableType`` such as ``FOREIGN_TABLE`` or ``PARTITIONED_TABLE`` + has no Snowflake spelling. Dropping the condition would answer with + every table, which claims rows the catalogue never reported as that + kind; the honest answer is "none" (``1 = 2``), the idiom the trigger + formatter uses for a family this engine does not have. + """ + return ComparisonPredicate( + self, + "eq", + Literal(self, 1, inline=True), + Literal(self, 2, inline=True), ) - return (sql, ()) - def format_table_list_query( - self, expr: "TableListExpression" + def format_table_list_query_statement( + self, expr: "TableListQueryStatement" ) -> Tuple[str, tuple]: - """Format table list query using INFORMATION_SCHEMA.TABLES.""" + """Format table list query using INFORMATION_SCHEMA.TABLES. + + ``include_views=False`` removes the two view spellings and nothing + else. It used to require ``TABLE_TYPE = 'BASE TABLE'``, which also + hid every other table kind the catalogue reports -- managed, event, + Iceberg, hybrid and dynamic tables are tables, not views, and a + "no views" listing must not answer as if they did not exist. + + An explicit ``table_type`` is read in both vocabularies: an exact + catalogue spelling selects that spelling, a core ``TableType`` name + expands onto the spellings mapped to it, and an unknown spelling is a + catalogue value this backend has not learned yet. See + :func:`..introspection.table_types.catalogue_spellings`. + """ + from ..introspection.table_types import ( + SNOWFLAKE_VIEW_TYPES, + catalogue_spellings, + ) + params = expr.get_params() schema = params.get("schema", "") include_views = params.get("include_views", True) include_system = params.get("include_system", False) table_type = params.get("table_type") - conditions = [f"TABLE_SCHEMA = {self.p()}"] - sql_params: list = [schema] + conditions = [ + ComparisonPredicate( + self, "eq", self._catalog_column("TABLE_SCHEMA"), Literal(self, schema) + ) + ] + # The two system schemas Snowflake creates in every database. They are + # inline values, not bind parameters: the string is part of the + # statement's own meaning and never came from a caller. if not include_system: conditions.append( - "TABLE_SCHEMA NOT IN ('information_schema', 'PUBLIC')" + ~InPredicate( + self, + self._catalog_column("TABLE_SCHEMA"), + Literal(self, ["information_schema", "PUBLIC"], inline=True), + ) ) if not include_views: - conditions.append("TABLE_TYPE = 'BASE TABLE'") + conditions.append( + ~InPredicate( + self, + self._catalog_column("TABLE_TYPE"), + Literal(self, list(SNOWFLAKE_VIEW_TYPES), inline=True), + ) + ) if table_type: - conditions.append(f"TABLE_TYPE = {self.p()}") - sql_params.append(table_type) - - where = " AND ".join(conditions) - sql = ( - "SELECT TABLE_NAME, TABLE_TYPE, COMMENT " - f"FROM INFORMATION_SCHEMA.TABLES WHERE {where} " - "ORDER BY TABLE_NAME" + spellings = catalogue_spellings(table_type) + if spellings: + conditions.append( + InPredicate( + self, + self._catalog_column("TABLE_TYPE"), + Literal(self, list(spellings)), + ) + ) + else: + conditions.append(self._no_table_type_predicate()) + + return self._catalog_query( + [ + self._catalog_column("TABLE_NAME"), + self._catalog_column("TABLE_TYPE"), + self._catalog_column("COMMENT"), + ], + self._information_schema_view("TABLES"), + conditions, + [self._catalog_column("TABLE_NAME")], ) - return (sql, tuple(sql_params)) - def format_column_info_query( - self, expr: "ColumnInfoExpression" + def format_column_info_query_statement( + self, expr: "ColumnInfoQueryStatement" ) -> Tuple[str, tuple]: """Format column information query using INFORMATION_SCHEMA.COLUMNS.""" params = expr.get_params() table_name = params.get("table", params.get("table_name", "")) schema = params.get("schema", "") - sql = ( - "SELECT COLUMN_NAME, ORDINAL_POSITION, COLUMN_DEFAULT, IS_NULLABLE, " - "DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, NUMERIC_PRECISION, NUMERIC_SCALE, " - "COLLATION_NAME, COMMENT " - "FROM INFORMATION_SCHEMA.COLUMNS " - f"WHERE TABLE_SCHEMA = {self.p()} AND TABLE_NAME = {self.p()} " - "ORDER BY ORDINAL_POSITION" + return self._catalog_query( + [ + self._catalog_column("COLUMN_NAME"), + self._catalog_column("ORDINAL_POSITION"), + self._catalog_column("COLUMN_DEFAULT"), + self._catalog_column("IS_NULLABLE"), + self._catalog_column("DATA_TYPE"), + self._catalog_column("CHARACTER_MAXIMUM_LENGTH"), + self._catalog_column("NUMERIC_PRECISION"), + self._catalog_column("NUMERIC_SCALE"), + self._catalog_column("COLLATION_NAME"), + self._catalog_column("COMMENT"), + ], + self._information_schema_view("COLUMNS"), + [ + ComparisonPredicate( + self, + "eq", + self._catalog_column("TABLE_SCHEMA"), + Literal(self, schema), + ), + ComparisonPredicate( + self, + "eq", + self._catalog_column("TABLE_NAME"), + Literal(self, table_name), + ), + ], + [self._catalog_column("ORDINAL_POSITION")], ) - return (sql, (schema, table_name)) - def format_index_info_query( - self, expr: "IndexInfoExpression" + def format_index_info_query_statement( + self, expr: "IndexInfoQueryStatement" ) -> Tuple[str, tuple]: """Format index information query using constraints. @@ -156,100 +440,542 @@ def format_index_info_query( table_name = params.get("table", params.get("table_name", "")) schema = params.get("schema", "") - sql = ( - "SELECT tc.CONSTRAINT_NAME, tc.CONSTRAINT_TYPE, " - "kcu.COLUMN_NAME, kcu.ORDINAL_POSITION " - "FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc " - "JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu " - " ON tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME " - " AND tc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA " - f"WHERE tc.TABLE_SCHEMA = {self.p()} AND tc.TABLE_NAME = {self.p()} " - " AND tc.CONSTRAINT_TYPE IN ('PRIMARY KEY', 'UNIQUE') " - "ORDER BY tc.CONSTRAINT_NAME, kcu.ORDINAL_POSITION" - ) - return (sql, (schema, table_name)) - - def format_foreign_key_query( - self, expr: "ForeignKeyExpression" + join = JoinClause( + self, + left_table=self._information_schema_view("TABLE_CONSTRAINTS", "tc"), + right_table=self._information_schema_view("KEY_COLUMN_USAGE", "kcu"), + join_type=JoinType.INNER, + condition=LogicalPredicate( + self, + "AND", + ComparisonPredicate( + self, + "eq", + self._catalog_column("CONSTRAINT_NAME", "tc"), + self._catalog_column("CONSTRAINT_NAME", "kcu"), + ), + ComparisonPredicate( + self, + "eq", + self._catalog_column("TABLE_SCHEMA", "tc"), + self._catalog_column("TABLE_SCHEMA", "kcu"), + ), + ), + ) + return self._catalog_query( + [ + self._catalog_column("CONSTRAINT_NAME", "tc"), + self._catalog_column("CONSTRAINT_TYPE", "tc"), + self._catalog_column("COLUMN_NAME", "kcu"), + self._catalog_column("ORDINAL_POSITION", "kcu"), + ], + join, + [ + ComparisonPredicate( + self, + "eq", + self._catalog_column("TABLE_SCHEMA", "tc"), + Literal(self, schema), + ), + ComparisonPredicate( + self, + "eq", + self._catalog_column("TABLE_NAME", "tc"), + Literal(self, table_name), + ), + # Inline: the two constraint names are the statement's own + # vocabulary, not a caller's value. + InPredicate( + self, + self._catalog_column("CONSTRAINT_TYPE", "tc"), + Literal(self, ["PRIMARY KEY", "UNIQUE"], inline=True), + ), + ], + [ + self._catalog_column("CONSTRAINT_NAME", "tc"), + self._catalog_column("ORDINAL_POSITION", "kcu"), + ], + ) + + def format_foreign_key_query_statement( + self, expr: "ForeignKeyQueryStatement" ) -> Tuple[str, tuple]: """Format foreign key information query. Joins REFERENTIAL_CONSTRAINTS, KEY_COLUMN_USAGE, and TABLE_CONSTRAINTS to resolve referenced table and columns. + + Chained joins nest rather than flattening: the rendered ``FROM`` reads + ``((rc JOIN kcu ...) JOIN utc ...) JOIN ukcu ...`` where the old + concatenated text read ``rc JOIN kcu ... JOIN utc ... JOIN ukcu ...``. + The two are the same four-way read -- the parentheses only make + explicit the grouping the framework already knows -- and the tree is + kept because writing the flat spelling by hand would put the clause + text back in this file. """ params = expr.get_params() table_name = params.get("table", params.get("table_name", "")) schema = params.get("schema", "") - sql = ( - "SELECT rc.CONSTRAINT_NAME, rc.UPDATE_RULE, rc.DELETE_RULE, " - "kcu.COLUMN_NAME, kcu.ORDINAL_POSITION, " - "utc.TABLE_NAME AS REFERENCED_TABLE_NAME, " - "ukcu.COLUMN_NAME AS REFERENCED_COLUMN_NAME " - "FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc " - "JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu " - " ON rc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME " - " AND rc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA " - "JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS utc " - " ON rc.UNIQUE_CONSTRAINT_NAME = utc.CONSTRAINT_NAME " - " AND rc.UNIQUE_CONSTRAINT_SCHEMA = utc.CONSTRAINT_SCHEMA " - "JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE ukcu " - " ON rc.UNIQUE_CONSTRAINT_NAME = ukcu.CONSTRAINT_NAME " - " AND rc.UNIQUE_CONSTRAINT_SCHEMA = ukcu.CONSTRAINT_SCHEMA " - " AND kcu.POSITION_IN_UNIQUE_CONSTRAINT = ukcu.ORDINAL_POSITION " - f"WHERE rc.CONSTRAINT_SCHEMA = {self.p()} " - f" AND kcu.TABLE_NAME = {self.p()} " - "ORDER BY rc.CONSTRAINT_NAME, kcu.ORDINAL_POSITION" - ) - return (sql, (schema, table_name)) - - def format_view_list_query( - self, expr: "ViewListExpression" - ) -> Tuple[str, tuple]: + def _same(left: str, right: str, table: str, other: str) -> ComparisonPredicate: + """One equality of two views' identically-named columns.""" + return ComparisonPredicate( + self, + "eq", + self._catalog_column(left, table), + self._catalog_column(right, other), + ) + + # Named per view, then joined in the order the original statement + # joined them: the constraint, its columns, the constraint it points + # at, and that constraint's columns. + source = ( + JoinClause( + self, + left_table=self._information_schema_view("REFERENTIAL_CONSTRAINTS", "rc"), + right_table=self._information_schema_view("KEY_COLUMN_USAGE", "kcu"), + join_type=JoinType.INNER, + condition=LogicalPredicate( + self, + "AND", + _same("CONSTRAINT_NAME", "CONSTRAINT_NAME", "rc", "kcu"), + _same("CONSTRAINT_SCHEMA", "CONSTRAINT_SCHEMA", "rc", "kcu"), + ), + ) + .join( + right_table=self._information_schema_view("TABLE_CONSTRAINTS", "utc"), + join_type="JOIN", + condition=LogicalPredicate( + self, + "AND", + _same("UNIQUE_CONSTRAINT_NAME", "CONSTRAINT_NAME", "rc", "utc"), + _same("UNIQUE_CONSTRAINT_SCHEMA", "CONSTRAINT_SCHEMA", "rc", "utc"), + ), + ) + .join( + right_table=self._information_schema_view("KEY_COLUMN_USAGE", "ukcu"), + join_type="JOIN", + condition=LogicalPredicate( + self, + "AND", + _same("UNIQUE_CONSTRAINT_NAME", "CONSTRAINT_NAME", "rc", "ukcu"), + _same("UNIQUE_CONSTRAINT_SCHEMA", "CONSTRAINT_SCHEMA", "rc", "ukcu"), + _same("POSITION_IN_UNIQUE_CONSTRAINT", "ORDINAL_POSITION", "kcu", "ukcu"), + ), + ) + ) + return self._catalog_query( + [ + self._catalog_column("CONSTRAINT_NAME", "rc"), + self._catalog_column("UPDATE_RULE", "rc"), + self._catalog_column("DELETE_RULE", "rc"), + self._catalog_column("COLUMN_NAME", "kcu"), + self._catalog_column("ORDINAL_POSITION", "kcu"), + self._catalog_column("TABLE_NAME", "utc", alias="REFERENCED_TABLE_NAME"), + self._catalog_column("COLUMN_NAME", "ukcu", alias="REFERENCED_COLUMN_NAME"), + ], + source, + [ + ComparisonPredicate( + self, + "eq", + self._catalog_column("CONSTRAINT_SCHEMA", "rc"), + Literal(self, schema), + ), + ComparisonPredicate( + self, + "eq", + self._catalog_column("TABLE_NAME", "kcu"), + Literal(self, table_name), + ), + ], + [ + self._catalog_column("CONSTRAINT_NAME", "rc"), + self._catalog_column("ORDINAL_POSITION", "kcu"), + ], + ) + + def format_view_list_query_statement(self, expr: "ViewListQueryStatement") -> Tuple[str, tuple]: """Format view list query using INFORMATION_SCHEMA.VIEWS.""" params = expr.get_params() schema = params.get("schema", "") include_system = params.get("include_system", False) - conditions = [f"TABLE_SCHEMA = {self.p()}"] - sql_params: list = [schema] - + conditions = [ + ComparisonPredicate( + self, + "eq", + self._catalog_column("TABLE_SCHEMA"), + Literal(self, schema), + ) + ] if not include_system: conditions.append( - "TABLE_SCHEMA NOT IN ('information_schema', 'PUBLIC')" + LikePredicate( + self, + LikeOperator.NOT_LIKE, + self._catalog_column("TABLE_SCHEMA"), + Literal(self, "information_schema", inline=True), + ) ) - where = " AND ".join(conditions) - sql = ( - "SELECT TABLE_NAME, VIEW_DEFINITION, CHECK_OPTION, IS_UPDATABLE " - f"FROM INFORMATION_SCHEMA.VIEWS WHERE {where} " - "ORDER BY TABLE_NAME" + return self._catalog_query( + [ + self._catalog_column("TABLE_NAME"), + self._catalog_column("VIEW_DEFINITION"), + self._catalog_column("CHECK_OPTION"), + self._catalog_column("IS_UPDATABLE"), + ], + self._information_schema_view("VIEWS"), + conditions, + [self._catalog_column("TABLE_NAME")], ) - return (sql, tuple(sql_params)) - def format_view_info_query( - self, expr: "ViewInfoExpression" - ) -> Tuple[str, tuple]: + def format_view_info_query_statement(self, expr: "ViewInfoQueryStatement") -> Tuple[str, tuple]: """Format single view information query.""" params = expr.get_params() view_name = params.get("view_name", "") schema = params.get("schema", "") - sql = ( - "SELECT TABLE_NAME, VIEW_DEFINITION, CHECK_OPTION, IS_UPDATABLE " - "FROM INFORMATION_SCHEMA.VIEWS " - f"WHERE TABLE_SCHEMA = {self.p()} AND TABLE_NAME = {self.p()}" + return self._catalog_query( + [ + self._catalog_column("TABLE_NAME"), + self._catalog_column("VIEW_DEFINITION"), + self._catalog_column("CHECK_OPTION"), + self._catalog_column("IS_UPDATABLE"), + ], + self._information_schema_view("VIEWS"), + [ + ComparisonPredicate( + self, + "eq", + self._catalog_column("TABLE_SCHEMA"), + Literal(self, schema), + ), + ComparisonPredicate( + self, + "eq", + self._catalog_column("TABLE_NAME"), + Literal(self, view_name), + ), + ], ) - return (sql, (schema, view_name)) - def format_trigger_list_query( - self, expr: "TriggerListExpression" + def format_trigger_list_query_statement( + self, expr: "TriggerListQueryStatement" ) -> Tuple[str, tuple]: """Snowflake does not support triggers; return empty result.""" - return ("SELECT 1 WHERE 1 = 0", ()) + # The predicate is a node like any other, so "always false" is stated + # rather than typed out, and the statement renders the same empty + # read the concatenated form produced. + return QueryStatement( + self, + select=[Literal(self, 1, inline=True)], + where=WhereClause( + self, + ComparisonPredicate( + self, + "eq", + Literal(self, 1, inline=True), + Literal(self, 0, inline=True), + ), + ), + ).to_sql() + + def format_trigger_info_query_statement(self, expr) -> Tuple[str, tuple]: + """Snowflake does not support triggers; return empty result.""" + return self.format_trigger_list_query_statement(expr) + + # ------------------------------------------------------------------ # + # Account-level objects: databases, schemas, warehouses, storage + # + # Each formatter names the official page its columns were read from. None + # of them ran against an account: this repository has no Snowflake + # instance, so what the tests pin is the statement and the parser, never + # the answer. + # ------------------------------------------------------------------ # + + def format_database_list_query_statement( + self, expr: SnowflakeDatabaseListQueryStatement + ) -> Tuple[str, tuple]: + """``INFORMATION_SCHEMA.DATABASES``, one row per database in the account. + + Documentation: https://docs.snowflake.com/en/sql-reference/info-schema/databases + + Columns selected, and why each is read: + + * ``DATABASE_NAME`` -- the name; the view uses "database" where every + other Information Schema view says "catalog". + * ``DATABASE_OWNER``, ``OWNER_ROLE_TYPE`` -- ownership, including the + documented ``APPLICATION`` case for native apps. + * ``IS_TRANSIENT`` -- documented ``VARCHAR``; ``'YES'`` for transient. + * ``COMMENT``, ``CREATED``, ``LAST_ALTERED``, ``RETENTION_TIME``, + ``TYPE`` -- the view's remaining columns, all documented on that page. + + Qualified with ``SNOWFLAKE`` on purpose. The page states the view + "contains all of the databases in the account (regardless of the + database's INFORMATION_SCHEMA used to query the view)", so the + qualification chooses nothing about the answer; it only gives the + statement a database to resolve ``INFORMATION_SCHEMA`` against when the + session has no current database, which is exactly the state the CLI is + in when it is given ``--account`` but no ``--database``. The manual's + own example writes ``SNOWFLAKE.ACCOUNT_USAGE.DATABASES`` in the same + unquoted spelling (https://docs.snowflake.com/en/sql-reference/account-usage). + + ``SELECT *`` is avoided per the Information Schema topic's own note that + "The Snowflake-specific views are subject to change. Avoid selecting + all columns from these views. Instead, select the columns that you want" + (https://docs.snowflake.com/en/sql-reference/info-schema). + + The view "only displays objects for which the current role for the + session has been granted access privileges" and "does not honor the + MANAGE GRANTS privilege", so this is the privilege-filtered answer, not + a claim of omniscience. + """ + return self._catalog_query( + [ + self._catalog_column("DATABASE_NAME"), + self._catalog_column("DATABASE_OWNER"), + self._catalog_column("IS_TRANSIENT"), + self._catalog_column("COMMENT"), + self._catalog_column("CREATED"), + self._catalog_column("LAST_ALTERED"), + self._catalog_column("RETENTION_TIME"), + self._catalog_column("TYPE"), + self._catalog_column("OWNER_ROLE_TYPE"), + ], + self._information_schema_view( + "DATABASES", catalog="SNOWFLAKE", catalog_need_quote=False + ), + order_by=[self._catalog_column("DATABASE_NAME")], + ) - def format_trigger_info_query( - self, expr + def format_schema_list_query_statement( + self, expr: SnowflakeSchemaListQueryStatement ) -> Tuple[str, tuple]: - """Snowflake does not support triggers; return empty result.""" - return ("SELECT 1 WHERE 1 = 0", ()) \ No newline at end of file + """``INFORMATION_SCHEMA.SCHEMATA`` for one database, or the current one. + + Documentation: https://docs.snowflake.com/en/sql-reference/info-schema/schemata + + Columns selected: ``CATALOG_NAME`` (the database), ``SCHEMA_NAME``, + ``SCHEMA_OWNER``, ``IS_TRANSIENT``, ``IS_MANAGED_ACCESS``, + ``RETENTION_TIME``, ``CREATED``, ``LAST_ALTERED``, ``COMMENT`` -- every + column of this view the manual does not mark "Not applicable for + Snowflake" (``DEFAULT_CHARACTER_SET_*``, ``SQL_PATH``). + + The view is a **database-level** one: the page says it "displays a row + for each schema in the specified (or current) database, including the + INFORMATION_SCHEMA schema itself". So listing another database requires + qualifying the view with it; there is no account-wide ``SCHEMATA``. + ``database=None`` therefore means "whatever database the session is + in", and is a legitimate request rather than a missing argument. + + A database name cannot be a bind parameter -- it is part of the object + name, not a value -- so it is rendered by the dialect as a quoted + identifier. Quoting is case-sensitive in Snowflake, so a caller passes + the catalog's own spelling; an unquoted object is stored uppercase and a + lower-case name that was never created with quotes will not match a + quoted one. + """ + return self._catalog_query( + [ + self._catalog_column("CATALOG_NAME"), + self._catalog_column("SCHEMA_NAME"), + self._catalog_column("SCHEMA_OWNER"), + self._catalog_column("IS_TRANSIENT"), + self._catalog_column("IS_MANAGED_ACCESS"), + self._catalog_column("RETENTION_TIME"), + self._catalog_column("CREATED"), + self._catalog_column("LAST_ALTERED"), + self._catalog_column("COMMENT"), + ], + self._information_schema_view("SCHEMATA", catalog=expr.database or None), + order_by=[self._catalog_column("SCHEMA_NAME")], + ) + + def format_warehouse_list_query_statement( + self, expr: SnowflakeWarehouseListQueryStatement + ) -> Tuple[str, tuple]: + """``SHOW WAREHOUSES`` -- there is no Information Schema view for them. + + Documentation: https://docs.snowflake.com/en/sql-reference/sql/show-warehouses + + Checked against the Information Schema view index, which lists no + ``WAREHOUSES`` view (https://docs.snowflake.com/en/sql-reference/info-schema; + the direct ``/info-schema/warehouses`` URL answers 404), and against the + Account Usage view list, which carries only ``WAREHOUSE_LOAD_HISTORY``, + ``WAREHOUSE_METERING_HISTORY`` and ``WAREHOUSE_EVENTS_HISTORY`` + (https://docs.snowflake.com/en/sql-reference/account-usage) -- no object + view for a warehouse in either schema. The documented access path is + therefore the ``SHOW`` command, and + :class:`~...expression.show.SnowflakeShowStatement` renders it so the + ``SHOW`` spelling stays in one place. + + **Row shape.** ``SHOW`` is not a ``SELECT``: its output columns are + documented in *lowercase* -- the page says so outright, "You must use + double-quoted identifiers because the output column names for SHOW + commands are in lowercase" -- unlike Information Schema columns, which + are uppercase. The parser normalizes keys before reading them, so both + shapes work. Values also differ from the views: the page's own sample + output shows ``is_default`` / ``is_current`` as ``Y``/``N`` and + ``auto_resume`` as ``true``, while Information Schema flags are + ``'YES'``/``'NO'``. + + Two more documented facts bound what this answer is: + + * "The command only returns objects for which the current user's + current role has been granted at least one access privilege" -- it is + the privilege-filtered list, like the views. + * "The command returns a maximum of ten thousand records for the + specified object type" -- the page points at the corresponding view + "if one exists" past that limit, and for warehouses none does, so a + 10,000-warehouse account cannot be fully listed this way. The list is + not truncated here; there is nothing to truncate it with. + + **Why not ``RESULT_SCAN``.** ``RESULT_SCAN(LAST_QUERY_ID())`` is the + documented way to post-process ``SHOW`` output inside SQL, and it is + *not* used here. The function scans "the most recent SQL command", + which is session-global state rather than this call's result. Any + statement executed between the ``SHOW`` and the scan -- including a + cache miss elsewhere in the same backend -- makes ``RESULT_SCAN`` read + the wrong query, and it fails silently rather than erroring. The driver + already exposes a ``SHOW`` result set through ``cursor.description``, + which is per-cursor and cannot be displaced, so this backend reads that. + """ + return SnowflakeShowStatement(self, SnowflakeShowObjectType.WAREHOUSES).to_sql() + + def format_table_storage_metrics_query_statement( + self, expr: SnowflakeTableStorageMetricsQueryStatement + ) -> Tuple[str, tuple]: + """Billed storage for one table, joined to its row count and clustering. + + Documentation for the primary view: + https://docs.snowflake.com/en/sql-reference/info-schema/table_storage_metrics + Documentation for the joined view: + https://docs.snowflake.com/en/sql-reference/info-schema/tables + + ``TABLE_STORAGE_METRICS`` documents these columns, all selected from + ``m``: ``TABLE_CATALOG``, ``TABLE_SCHEMA``, ``TABLE_NAME``, ``ID``, + ``CLONE_GROUP_ID``, ``IS_TRANSIENT``, ``ACTIVE_BYTES``, + ``TIME_TRAVEL_BYTES``, ``FAILSAFE_BYTES``, ``RETAINED_FOR_CLONE_BYTES``, + ``TABLE_CREATED``, ``TABLE_DROPPED``, ``TABLE_ENTERED_FAILSAFE`` and + ``COMMENT``. It does **not** document a row count, a table type or a + clustering key -- the page's column table has none of them -- so those + come from ``TABLES``, which documents ``ROW_COUNT`` ("Number of rows in + the table"), ``CLUSTERING_KEY`` ("Clustering key for the table"), + ``AUTO_CLUSTERING_ON`` (``BOOLEAN``) and ``TABLE_TYPE``. The join is on + the three-part name because both views carry the catalog, schema and + table name; it is a ``LEFT`` join because the storage view keeps rows + for dropped tables that ``TABLES`` no longer lists. + + The statement is rendered from a tree, so the two short aliases the + manual's examples use are carried by the row sources and the ``ON`` + condition is a conjunction of three equalities rather than one string. + Both come out as the same query the concatenated form produced. + + **Current version only.** The storage page documents that multiple rows + can share a ``TABLE_NAME``: "A version is created each time a table is + dropped and a new table with the same name is created ... the current + version will have a NULL value for the TABLE_DROPPED column; all other + versions will have a timestamp value." Filtering to the current version + keeps the join honest -- ``TABLES`` no longer lists dropped tables, so + joining a dropped version would attach the live table's row count to a + dead one -- and it is the version "storage metrics for this table" + means. Dropped versions remain queryable by the view itself; this method + deliberately answers for the live table. + + ``TABLES.BYTES`` is deliberately not selected. The page documents it as + "Number of bytes accessed by a scan of the table" and warns that + ``SUM(bytes)`` "does not represent the total storage usage"; the billed + categories above are the authoritative byte numbers, and selecting a + second, differently-meaning byte column would invite them to be + confused. + + **Role requirement.** The storage page states the view "is visible to + other views and can be queried, but the queries will return no rows" + for roles other than ``ACCOUNTADMIN``. An empty result is therefore + indistinguishable from a missing table, and the introspector method's + docstring says so rather than reporting a spurious "not found". + + ``table_name`` and ``schema`` are bound values (``dialect.p()``), not + identifiers: they are compared to catalog columns, not interpolated into + an object name. Information Schema values are uppercase for unquoted + objects (the Information Schema topic says so explicitly), so callers + pass the catalog spelling. + """ + metrics = self._information_schema_view("TABLE_STORAGE_METRICS", "m") + tables = self._information_schema_view("TABLES", "t") + conditions = [ + ComparisonPredicate( + self, + "eq", + self._catalog_column("TABLE_NAME", "m"), + Literal(self, expr.table_name), + ), + IsNullPredicate(self, self._catalog_column("TABLE_DROPPED", "m")), + ] + if expr.schema: + conditions.append( + ComparisonPredicate( + self, + "eq", + self._catalog_column("TABLE_SCHEMA", "m"), + Literal(self, expr.schema), + ) + ) + source = JoinClause( + self, + left_table=metrics, + right_table=tables, + join_type=JoinType.LEFT, + condition=LogicalPredicate( + self, + "AND", + ComparisonPredicate( + self, + "eq", + self._catalog_column("TABLE_CATALOG", "t"), + self._catalog_column("TABLE_CATALOG", "m"), + ), + ComparisonPredicate( + self, + "eq", + self._catalog_column("TABLE_SCHEMA", "t"), + self._catalog_column("TABLE_SCHEMA", "m"), + ), + ComparisonPredicate( + self, + "eq", + self._catalog_column("TABLE_NAME", "t"), + self._catalog_column("TABLE_NAME", "m"), + ), + ), + ) + return self._catalog_query( + [ + self._catalog_column("TABLE_CATALOG", "m"), + self._catalog_column("TABLE_SCHEMA", "m"), + self._catalog_column("TABLE_NAME", "m"), + self._catalog_column("ID", "m"), + self._catalog_column("CLONE_GROUP_ID", "m"), + self._catalog_column("IS_TRANSIENT", "m"), + self._catalog_column("ACTIVE_BYTES", "m"), + self._catalog_column("TIME_TRAVEL_BYTES", "m"), + self._catalog_column("FAILSAFE_BYTES", "m"), + self._catalog_column("RETAINED_FOR_CLONE_BYTES", "m"), + self._catalog_column("TABLE_CREATED", "m"), + self._catalog_column("TABLE_DROPPED", "m"), + self._catalog_column("TABLE_ENTERED_FAILSAFE", "m"), + self._catalog_column("COMMENT", "m"), + self._catalog_column("TABLE_TYPE", "t"), + self._catalog_column("ROW_COUNT", "t"), + self._catalog_column("CLUSTERING_KEY", "t"), + self._catalog_column("AUTO_CLUSTERING_ON", "t"), + ], + source, + conditions, + # Newest version first, so the row a caller would want -- the one + # this query filters for -- is the first one returned even if the + # current-version filter is ever widened. + [(self._catalog_column("TABLE_CREATED", "m"), "DESC")], + ) diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/literal.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/literal.py new file mode 100644 index 0000000..83ef889 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/literal.py @@ -0,0 +1,96 @@ +# src/rhosocial/activerecord/backend/impl/snowflake/mixins/literal.py +"""Snowflake'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 Snowflake's second tier, and it spells the two types Snowflake's +grammar spells for itself: + +* ``bytes`` becomes ``TO_BINARY('0102', 'HEX')``, the two-argument conversion. + The one-argument ``TO_BINARY`` reads the ``BINARY_INPUT_FORMAT`` session + parameter, which a dialect must neither read nor assume; naming ``'HEX'`` + makes the literal deterministic across sessions. Upper-case hex is + Snowflake's own binary-to-string convention. +* ``UUID`` becomes the bare quoted canonical text. Snowflake's UUID page says + "The ANSI literal form of UUID is supported as input" and inserts a quoted + canonical string; there is no ``::uuid`` suffix to add. + +**INTERVAL stays unimplemented on purpose.** ``timedelta`` has no branch here, +so the base's explicit ``TypeError`` is the answer. Snowflake's interval +*constant* (the ``INTERVAL '...'`` keyword form) is a date-arithmetic operand +only -- the page says such constants "can't be inserted into a column that has +an interval data type" -- and the interval *data types* take a string whose +shape depends on the column's qualifier (``+Y-MM`` for year-month, +``+D HH24:MI:SS.F`` for day-time), a qualifier a value node does not carry. +A ``timedelta`` also holds days, seconds and microseconds, never years or +months, and Snowflake's two interval families cannot be mixed, so no single +qualifier could carry an arbitrary ``timedelta`` faithfully. + +The tier-1 forms (NULL / booleans / numbers / strings / dates / times / +timestamps) are the base's and are not overridden here: Snowflake's grammar +spells them exactly as core does -- ``TRUE`` / ``FALSE`` keywords, numeric +constants taken verbatim (``repr`` for floats, ``str`` for ``Decimal``), +strings with doubled single quotes, and quoted ISO 8601 text for the three +temporal types. The base's refusal of NaN and the infinities is Snowflake's +answer too: the FLOAT special values ``'NaN'`` / ``'inf'`` require quotes, so +they are never a bare numeric literal. + +**not server-verified** -- this repository reaches no Snowflake instance, so +every spelling above is transcribed from the official documentation, quoted +per branch, and checked only as a rendering assertion. When a live server +contradicts a spelling here, change the spelling, not the test. + +Feature Source: Snowflake native (not SQL standard) + +Official Documentation: +- TO_BINARY: https://docs.snowflake.com/en/sql-reference/functions/to_binary +- Binary input and output: + https://docs.snowflake.com/en/sql-reference/binary-input-output +- String & binary data types: + https://docs.snowflake.com/en/sql-reference/data-types-text +- Numeric data types: https://docs.snowflake.com/en/sql-reference/data-types-numeric +- UUID data type: https://docs.snowflake.com/en/sql-reference/data-types-uuid +- Date & time data types: + https://docs.snowflake.com/en/sql-reference/data-types-datetime +""" + +import uuid +from typing import Any + + +class SnowflakeLiteralValueMixin: + """Snowflake's tier-2 branches of :meth:`format_literal`. + + Stands alone rather than inheriting the base, and the dialect composes it + ahead of :class:`SQLDialectBase` -- the one base that defines + ``format_literal`` -- because a mixin placed after the class it overrides + never wins. + """ + + def format_literal(self, value: Any) -> str: + """Render *value* as inline SQL text, Snowflake'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 Snowflake has no + literal form for -- ``timedelta`` among them, which is the + documented answer rather than a gap. + """ + if isinstance(value, (bytes, bytearray)): + # Rendered from the bytes rather than from the base64 spelling an + # artifact carries: only the dialect layer knows which text form + # its engine accepts. The digits go through ``format_literal`` so + # the quoting is the base's, never a second copy of it. + hex_text = bytes(value).hex().upper() + return f"TO_BINARY({self.format_literal(hex_text)}, 'HEX')" + if isinstance(value, uuid.UUID): + # The canonical 8-4-4-4-12 text is exactly the pattern the UUID + # page describes, and quoting stays the base's escaping. + return self.format_literal(str(value)) + return super().format_literal(value) diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/materialized_view.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/materialized_view.py index d69dcc7..b171b1d 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/materialized_view.py @@ -1,14 +1,18 @@ # src/rhosocial/activerecord/backend/impl/snowflake/mixins/materialized_view.py """SnowflakeMaterializedViewMixin — materialized view DDL support.""" -from typing import Any, Tuple, TYPE_CHECKING +from typing import Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.objects import MaterializedView +from ..expression.ddl.materialized_view import ( + SnowflakeCreateMaterializedViewStatement, +) + if TYPE_CHECKING: - from ..expression.ddl.materialized_view import ( - SnowflakeCreateMaterializedViewExpression, + from rhosocial.activerecord.backend.expression.statements.ddl_view import ( + CreateMaterializedViewStatement, ) @@ -49,13 +53,13 @@ def supports_with_data_clause(self) -> bool: return False def format_create_materialized_view_statement( - self, expr: "SnowflakeCreateMaterializedViewExpression" + self, expr: "SnowflakeCreateMaterializedViewStatement" ) -> Tuple[str, tuple]: """Format CREATE [OR REPLACE] MATERIALIZED VIEW statement. - Accepts both ``SnowflakeCreateMaterializedViewExpression`` and the core - ``CreateMaterializedViewExpression``; the defining query may be a - ``QueryExpression`` or a raw SQL string. + Accepts both ``SnowflakeCreateMaterializedViewStatement`` and the core + ``CreateMaterializedViewStatement``; the defining query may be a + ``QueryStatement`` or a raw SQL string. Args: expr: Materialized view create expression. @@ -85,7 +89,7 @@ def format_create_materialized_view_statement( raise UnsupportedFeatureError( self.name, "MATERIALIZED VIEW STORAGE PARAMETERS" ) - if getattr(expr, "with_data", False) or getattr(expr, "no_data", False): + if expr.with_data or expr.no_data: # The clause is absent from Snowflake's grammar entirely: the view # is created empty and filled in the background. An explicitly # requested spelling is refused by name rather than dropped. @@ -103,10 +107,15 @@ def format_create_materialized_view_statement( raise ValueError("CREATE MATERIALIZED VIEW requires a query") parts = ["CREATE"] - if getattr(expr, "or_replace", False): + # ``or_replace`` / ``if_not_exists`` / ``cluster_by`` / ``comment`` + # are the Snowflake expression's own fields: the core statement this + # formatter also accepts declares none of them, so the subclass is + # asked by name rather than probed for attributes its contract does + # not carry. + if isinstance(expr, SnowflakeCreateMaterializedViewStatement) and expr.or_replace: parts.append("OR REPLACE") parts.append("MATERIALIZED VIEW") - if getattr(expr, "if_not_exists", False): + if isinstance(expr, SnowflakeCreateMaterializedViewStatement) and expr.if_not_exists: parts.append("IF NOT EXISTS") # The statement already holds the named view, so it renders itself # through its own format_materialized_view_object rather than through @@ -114,19 +123,19 @@ def format_create_materialized_view_statement( view_sql, _view_params = expr.view.to_sql() parts.append(view_sql) - column_aliases = getattr(expr, "column_aliases", None) + column_aliases = expr.column_aliases if column_aliases: cols = ", ".join(self.format_identifier(col) for col in column_aliases) parts.append(f"({cols})") - cluster_by = getattr(expr, "cluster_by", None) - if cluster_by: - cols = ", ".join(self.format_identifier(col) for col in cluster_by) - parts.append(f"CLUSTER BY ({cols})") - - comment = getattr(expr, "comment", None) - if comment is not None: - parts.append(f"COMMENT = '{self._escape_sql_string(comment)}'") + if isinstance(expr, SnowflakeCreateMaterializedViewStatement): + if expr.cluster_by: + cols = ", ".join( + self.format_identifier(col) for col in expr.cluster_by + ) + parts.append(f"CLUSTER BY ({cols})") + if expr.comment is not None: + parts.append(f"COMMENT = '{self.escape_sql_string(expr.comment)}'") # Snowflake has no WITH [NO] DATA: the view is created empty and filled # in the background. An explicit request for either spelling was refused @@ -134,13 +143,15 @@ def format_create_materialized_view_statement( parts.extend(["AS", query_sql]) return " ".join(parts), () - def _materialized_view_query_sql(self, expr: Any): + def _materialized_view_query_sql(self, expr: "CreateMaterializedViewStatement"): """Return the defining query as SQL text, or None when absent. - Snowflake's own expression carries a raw ``as_query`` string; the core - expression carries a ``QueryExpression``. + Snowflake's own expression coerces the query from either vocabulary + but stores it in the core ``query`` field, so the declared field is + read directly: the core statement declares it too, and a Snowflake + expression may hold ``None`` when no query was given. """ - query = getattr(expr, "query", None) + query = expr.query if query is None: return None if isinstance(query, str): diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/pipe.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/pipe.py index 8f29c40..5b85bc3 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/pipe.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/pipe.py @@ -7,9 +7,9 @@ if TYPE_CHECKING: from ..expression.ddl.pipe import ( - SnowflakeAlterPipeExpression, - SnowflakeCreatePipeExpression, - SnowflakeDropPipeExpression, + SnowflakeAlterPipeStatement, + SnowflakeCreatePipeStatement, + SnowflakeDropPipeStatement, ) @@ -21,12 +21,12 @@ def supports_pipes(self) -> bool: return True def format_create_pipe_statement( - self, expr: "SnowflakeCreatePipeExpression" + self, expr: "SnowflakeCreatePipeStatement" ) -> Tuple[str, tuple]: """Format CREATE [OR REPLACE] PIPE statement. Args: - expr: :class:`SnowflakeCreatePipeExpression`. + expr: :class:`SnowflakeCreatePipeStatement`. Returns: Tuple of (SQL string, empty params tuple). @@ -52,21 +52,21 @@ def format_create_pipe_statement( ) if expr.aws_sns_topic is not None: parts.append( - f"AWS_SNS_TOPIC = '{self._escape_sql_string(expr.aws_sns_topic)}'" + f"AWS_SNS_TOPIC = '{self.escape_sql_string(expr.aws_sns_topic)}'" ) if expr.integration is not None: parts.append( - f"INTEGRATION = '{self._escape_sql_string(expr.integration)}'" + f"INTEGRATION = '{self.escape_sql_string(expr.integration)}'" ) if expr.comment is not None: parts.append( - f"COMMENT = '{self._escape_sql_string(expr.comment)}'" + f"COMMENT = '{self.escape_sql_string(expr.comment)}'" ) parts.extend(["AS", str(expr.copy_sql)]) return " ".join(parts), () def format_alter_pipe_statement( - self, expr: "SnowflakeAlterPipeExpression" + self, expr: "SnowflakeAlterPipeStatement" ) -> Tuple[str, tuple]: """Format ALTER PIPE statement. @@ -74,7 +74,7 @@ def format_alter_pipe_statement( :attr:`expr.mode`. Args: - expr: :class:`SnowflakeAlterPipeExpression`. + expr: :class:`SnowflakeAlterPipeStatement`. Returns: Tuple of (SQL string, empty params tuple). @@ -90,7 +90,7 @@ def format_alter_pipe_statement( if mode is SnowflakeAlterPipeMode.REFRESH: parts.append("REFRESH") if expr.modified_after is not None: - value = self._escape_sql_string(str(expr.modified_after)) + value = self.escape_sql_string(str(expr.modified_after)) parts.append(f"MODIFIED_AFTER => '{value}'") return " ".join(parts), () if mode is SnowflakeAlterPipeMode.PAUSE: @@ -107,7 +107,7 @@ def format_alter_pipe_statement( ) if expr.comment is not None: options.append( - f"COMMENT = '{self._escape_sql_string(expr.comment)}'" + f"COMMENT = '{self.escape_sql_string(expr.comment)}'" ) if not options: raise ValueError("ALTER PIPE SET requires at least one property") @@ -116,12 +116,12 @@ def format_alter_pipe_statement( return " ".join(parts), () def format_drop_pipe_statement( - self, expr: "SnowflakeDropPipeExpression" + self, expr: "SnowflakeDropPipeStatement" ) -> Tuple[str, tuple]: """Format DROP PIPE statement. Args: - expr: :class:`SnowflakeDropPipeExpression`. + expr: :class:`SnowflakeDropPipeStatement`. Returns: Tuple of (SQL string, empty params tuple). diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/pivot.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/pivot.py index e919254..7e92f80 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/pivot.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/pivot.py @@ -21,7 +21,7 @@ def supports_unpivot(self) -> bool: """Snowflake supports the UNPIVOT clause.""" return True - def format_pivot_clause( + def format_pivot_expression( self, expr: "SnowflakePivotExpression" ) -> Tuple[str, tuple]: """Format a PIVOT clause. @@ -46,7 +46,7 @@ def format_pivot_clause( sql += f" {self.format_identifier(expr.alias)}" return sql, () - def format_unpivot_clause( + def format_unpivot_expression( self, expr: "SnowflakeUnpivotExpression" ) -> Tuple[str, tuple]: """Format an UNPIVOT clause. diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/routine.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/routine.py index b81de37..8614cf2 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/routine.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/routine.py @@ -5,9 +5,9 @@ if TYPE_CHECKING: from ..expression.ddl.routine import ( - SnowflakeCreateFunctionExpression, - SnowflakeCreateProcedureExpression, - SnowflakeDropRoutineExpression, + SnowflakeCreateFunctionStatement, + SnowflakeCreateProcedureStatement, + SnowflakeDropRoutineStatement, ) @@ -28,12 +28,12 @@ def supports_functions(self) -> Dict[str, bool]: return {} def format_create_procedure_statement( - self, expr: "SnowflakeCreateProcedureExpression" + self, expr: "SnowflakeCreateProcedureStatement" ) -> Tuple[str, tuple]: """Format CREATE [OR REPLACE] PROCEDURE statement. Args: - expr: :class:`SnowflakeCreateProcedureExpression`. + expr: :class:`SnowflakeCreateProcedureStatement`. Returns: Tuple of (SQL string, empty params tuple). @@ -44,20 +44,28 @@ def format_create_procedure_statement( parts.append("OR REPLACE") parts.append("PROCEDURE") parts.append(self.format_identifier(expr.name)) + from rhosocial.activerecord.backend.expression.types import DataType + if not expr.args: parts.append("()") else: rendered = [] for arg in expr.args: arg_name, arg_type = arg + if arg_type is not None: + arg_type, _ = DataType.as_type(self, arg_type).to_sql() + arg_type_sql = arg_type if arg_name: rendered.append( - f"{self.format_identifier(arg_name)} {arg_type}" + f"{self.format_identifier(arg_name)} {arg_type_sql}" ) else: - rendered.append(str(arg_type)) + rendered.append(arg_type_sql) parts.append("(" + ", ".join(rendered) + ")") - parts.append(f"RETURNS {expr.returns}") + returns = expr.returns + if returns is not None: + returns, _ = DataType.as_type(self, returns).to_sql() + parts.append(f"RETURNS {returns}") parts.append(f"LANGUAGE {expr.language.value}") if expr.execute_as is not None: parts.append(f"EXECUTE AS {expr.execute_as.value}") @@ -65,17 +73,17 @@ def format_create_procedure_statement( parts.append(f"AS $$ {expr.body} $$") if expr.comment is not None: parts.append( - f"COMMENT = '{self._escape_sql_string(expr.comment)}'" + f"COMMENT = '{self.escape_sql_string(expr.comment)}'" ) return " ".join(parts), () def format_create_function_statement( - self, expr: "SnowflakeCreateFunctionExpression" + self, expr: "SnowflakeCreateFunctionStatement" ) -> Tuple[str, tuple]: """Format CREATE [OR REPLACE] FUNCTION statement. Args: - expr: :class:`SnowflakeCreateFunctionExpression`. + expr: :class:`SnowflakeCreateFunctionStatement`. Returns: Tuple of (SQL string, empty params tuple). @@ -86,20 +94,28 @@ def format_create_function_statement( parts.append("OR REPLACE") parts.append("FUNCTION") parts.append(self.format_identifier(expr.name)) + from rhosocial.activerecord.backend.expression.types import DataType + if not expr.args: parts.append("()") else: rendered = [] for arg in expr.args: arg_name, arg_type = arg + if arg_type is not None: + arg_type, _ = DataType.as_type(self, arg_type).to_sql() + arg_type_sql = arg_type if arg_name: rendered.append( - f"{self.format_identifier(arg_name)} {arg_type}" + f"{self.format_identifier(arg_name)} {arg_type_sql}" ) else: - rendered.append(str(arg_type)) + rendered.append(arg_type_sql) parts.append("(" + ", ".join(rendered) + ")") - parts.append(f"RETURNS {expr.returns}") + returns = expr.returns + if returns is not None: + returns, _ = DataType.as_type(self, returns).to_sql() + parts.append(f"RETURNS {returns}") parts.append(f"LANGUAGE {expr.language.value}") if expr.immutable: parts.append("IMMUTABLE") @@ -111,17 +127,17 @@ def format_create_function_statement( parts.append(f"AS $$ {expr.body} $$") if expr.comment is not None: parts.append( - f"COMMENT = '{self._escape_sql_string(expr.comment)}'" + f"COMMENT = '{self.escape_sql_string(expr.comment)}'" ) return " ".join(parts), () def format_drop_routine_statement( - self, expr: "SnowflakeDropRoutineExpression" + self, expr: "SnowflakeDropRoutineStatement" ) -> Tuple[str, tuple]: """Format DROP PROCEDURE / FUNCTION statement. Args: - expr: :class:`SnowflakeDropRoutineExpression`. + expr: :class:`SnowflakeDropRoutineStatement`. Returns: Tuple of (SQL string, empty params tuple). diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/sample.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/sample.py index 55881a1..619f9db 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/sample.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/sample.py @@ -18,7 +18,7 @@ def supports_tablesample(self) -> bool: """Snowflake supports the TABLESAMPLE clause.""" return True - def format_sample_clause( + def format_sample_expression( self, expr: "SnowflakeSampleExpression" ) -> Tuple[str, tuple]: """Format a ``SAMPLE`` clause. diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/schema.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/schema.py index 72eb1de..003b4e6 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/schema.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/schema.py @@ -9,8 +9,8 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements.ddl_schema import ( - CreateSchemaExpression, - DropSchemaExpression, + CreateSchemaStatement, + DropSchemaStatement, ) @@ -77,7 +77,7 @@ def supports_alter_schema(self) -> bool: """Whether ``ALTER SCHEMA`` is supported. Snowflake natively supports ALTER SCHEMA, but there is no - ``AlterSchemaExpression`` / formatter yet, so the capability is + ``ALTER SCHEMA`` statement node or formatter yet, so the capability is advertised as unsupported to avoid a dead, unrenderable switch. """ return False @@ -102,7 +102,7 @@ def supports_undrop_schema(self) -> bool: """Snowflake supports UNDROP SCHEMA.""" return True - def format_create_schema_statement(self, expr: CreateSchemaExpression) -> Tuple[str, tuple]: + def format_create_schema_statement(self, expr: CreateSchemaStatement) -> Tuple[str, tuple]: # The object kind is checked here rather than in the constructor: an # object renders itself through its own ``format_method``, so a Table # handed here would produce a well-formed ``CREATE SCHEMA "users"``. @@ -129,7 +129,7 @@ def format_create_schema_statement(self, expr: CreateSchemaExpression) -> Tuple[ 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]: if not isinstance(expr.schema, Schema): raise TypeError( f"{type(expr).__name__}.schema must be a Schema, " diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/sequence.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/sequence.py index f83d614..c20b215 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/sequence.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/sequence.py @@ -16,12 +16,12 @@ * ``ALTER SEQUENCE`` cannot change the initial value. The reference's usage notes say the first value cannot be changed after the sequence is created, and the command has neither a ``START`` nor a ``RESTART`` clause. An - ``AlterSequenceExpression`` asking for either is refused rather than + ``AlterSequenceStatement`` asking for either is refused rather than rendered. * The order spelling is ``NOORDER``, one word, where core's shared renderer writes ``NO ORDER``. -``COMMENT`` is a Snowflake-only option that core's ``CreateSequenceExpression`` +``COMMENT`` is a Snowflake-only option that core's ``CreateSequenceStatement`` has no field for. It is recorded here rather than carried: adding a field to a core expression is core's decision, not this backend's. When core grows one, the clause belongs after ``ORDER`` / ``NOORDER``, as the reference grammar @@ -43,17 +43,18 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.expression.statements import ( - AlterSequenceExpression, - CreateSequenceExpression, - DropSequenceExpression, + AlterSequenceStatement, + CreateSequenceStatement, + DropSequenceStatement, ) + from ..expression.sequence import SnowflakeNextvalExpression class SnowflakeSequenceMixin: """Snowflake's own ``CREATE`` / ``ALTER`` / ``DROP SEQUENCE`` renderers.""" def format_create_sequence_statement( - self, expr: "CreateSequenceExpression" + self, expr: "CreateSequenceStatement" ) -> Tuple[str, tuple]: """Render ``CREATE SEQUENCE`` in Snowflake's grammar. @@ -67,7 +68,7 @@ def format_create_sequence_statement( [ COMMENT = '' ] ``OR REPLACE`` and ``COMMENT`` have no field on core's - ``CreateSequenceExpression`` and so are never emitted here; the class + ``CreateSequenceStatement`` and so are never emitted here; the class docstring above says where ``COMMENT`` belongs when core grows one. Every option is answered by its own capability probe: ``start`` by @@ -189,7 +190,7 @@ def format_create_sequence_statement( return " ".join(parts), () def format_drop_sequence_statement( - self, expr: "DropSequenceExpression" + self, expr: "DropSequenceStatement" ) -> Tuple[str, tuple]: """Render ``DROP SEQUENCE`` in Snowflake's grammar. @@ -198,7 +199,7 @@ def format_drop_sequence_statement( DROP SEQUENCE [ IF EXISTS ] [ CASCADE | RESTRICT ] ``CASCADE`` / ``RESTRICT`` have no field on core's - ``DropSequenceExpression`` and are not emitted; Snowflake accepts the + ``DropSequenceStatement`` and are not emitted; Snowflake accepts the words but does not act on them, so omitting them changes nothing. Raises: @@ -222,7 +223,7 @@ def format_drop_sequence_statement( return " ".join(parts), () def format_alter_sequence_statement( - self, expr: "AlterSequenceExpression" + self, expr: "AlterSequenceStatement" ) -> Tuple[str, tuple]: """Render ``ALTER SEQUENCE`` in Snowflake's grammar. @@ -240,7 +241,7 @@ def format_alter_sequence_statement( ALTER SEQUENCE [ IF EXISTS ] UNSET COMMENT ``RENAME TO`` and ``UNSET COMMENT`` have no field on core's - ``AlterSequenceExpression``; ``IF EXISTS`` has none either, so it is + ``AlterSequenceStatement``; ``IF EXISTS`` has none either, so it is never emitted. ``ALTER SEQUENCE`` cannot change the initial value: the reference's @@ -380,5 +381,24 @@ def format_alter_sequence_statement( parts.append("ORDER" if expr.order else "NOORDER") return " ".join(parts), () + def format_snowflake_nextval_expression( + self, expr: "SnowflakeNextvalExpression" + ) -> Tuple[str, tuple]: + """Render ``.NEXTVAL [AS ]``. + + Snowflake reads a sequence's next value as a *pseudo-column* on the + sequence (``seq.nextval``), not through the SQL-standard + ``NEXT VALUE FOR`` and not through PostgreSQL's ``nextval('seq')`` + function form. The sequence name goes through + :meth:`format_identifier`, so a quoted or namespaced name is written + once, in the one place that knows how. + + https://docs.snowflake.com/en/sql-reference/functions/nextval + """ + sql = f"{self.format_identifier(expr.sequence)}.NEXTVAL" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, () + __all__ = ["SnowflakeSequenceMixin"] diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/show.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/show.py index acd955f..668d228 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/show.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/show.py @@ -4,7 +4,7 @@ from typing import Tuple, TYPE_CHECKING if TYPE_CHECKING: - from ..expression.show import SnowflakeShowExpression + from ..expression.show import SnowflakeShowStatement class SnowflakeShowMixin: @@ -15,7 +15,7 @@ def supports_show(self) -> bool: return True def format_show_statement( - self, expr: "SnowflakeShowExpression" + self, expr: "SnowflakeShowStatement" ) -> Tuple[str, tuple]: """Format a SHOW statement. @@ -23,7 +23,7 @@ def format_show_statement( [name]] [LIMIT n]`` (clause order follows the Snowflake docs). Args: - expr: :class:`SnowflakeShowExpression`. + expr: :class:`SnowflakeShowStatement`. Returns: Tuple of (SQL string, empty params tuple). @@ -31,7 +31,7 @@ def format_show_statement( """ parts = ["SHOW", expr.object_type.value] if expr.like is not None: - parts.append(f"LIKE '{self._escape_sql_string(expr.like)}'") + parts.append(f"LIKE '{self.escape_sql_string(expr.like)}'") if expr.in_scope is not None: clause = f"IN {expr.in_scope.value}" if expr.in_name: diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/stage.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/stage.py index 04825d4..e43856d 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/stage.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/stage.py @@ -7,10 +7,10 @@ if TYPE_CHECKING: from ..expression.ddl.stage import ( - SnowflakeAlterStageExpression, - SnowflakeCopyIntoExpression, - SnowflakeCreateStageExpression, - SnowflakeDropStageExpression, + SnowflakeAlterStageStatement, + SnowflakeCopyIntoStatement, + SnowflakeCreateStageStatement, + SnowflakeDropStageStatement, ) @@ -22,12 +22,12 @@ def supports_stages(self) -> bool: return True def format_copy_into_statement( - self, expr: "SnowflakeCopyIntoExpression" + self, expr: "SnowflakeCopyIntoStatement" ) -> Tuple[str, tuple]: """Format a full COPY INTO statement (load or unload). Args: - expr: :class:`SnowflakeCopyIntoExpression`. + expr: :class:`SnowflakeCopyIntoStatement`. Returns: Tuple of (SQL string, empty params tuple). @@ -38,18 +38,18 @@ def format_copy_into_statement( return self.format_copy_into_load(expr) def format_copy_into_load( - self, expr: "SnowflakeCopyIntoExpression" + self, expr: "SnowflakeCopyIntoStatement" ) -> Tuple[str, tuple]: """Format COPY INTO
FROM @ (load direction).""" parts = [f"COPY INTO {expr.table} FROM @{expr.stage}"] if expr.files: files = ", ".join( - f"'{self._escape_sql_string(name)}'" for name in expr.files + f"'{self.escape_sql_string(name)}'" for name in expr.files ) parts.append(f"FILES = ({files})") if expr.pattern is not None: parts.append( - f"PATTERN = '{self._escape_sql_string(expr.pattern)}'" + f"PATTERN = '{self.escape_sql_string(expr.pattern)}'" ) if expr.file_format is not None: ff = expr.file_format @@ -65,7 +65,7 @@ def format_copy_into_load( parts.append(f"FILE_FORMAT = ({ff})") if expr.on_error is not None: parts.append( - f"ON_ERROR = '{self._escape_sql_string(expr.on_error)}'" + f"ON_ERROR = '{self.escape_sql_string(expr.on_error)}'" ) if expr.force is not None: parts.append(f"FORCE = {str(bool(expr.force)).upper()}") @@ -74,12 +74,12 @@ def format_copy_into_load( if expr.validation_mode is not None: parts.append( f"VALIDATION_MODE = " - f"'{self._escape_sql_string(expr.validation_mode)}'" + f"'{self.escape_sql_string(expr.validation_mode)}'" ) return " ".join(parts), () def format_copy_into_unload( - self, expr: "SnowflakeCopyIntoExpression" + self, expr: "SnowflakeCopyIntoStatement" ) -> Tuple[str, tuple]: """Format COPY INTO @ FROM
(unload direction).""" parts = [f"COPY INTO @{expr.stage} FROM {expr.table}"] @@ -107,12 +107,12 @@ def format_copy_into_unload( return " ".join(parts), () def format_create_stage_statement( - self, expr: "SnowflakeCreateStageExpression" + self, expr: "SnowflakeCreateStageStatement" ) -> Tuple[str, tuple]: """Format CREATE STAGE statement. Args: - expr: :class:`SnowflakeCreateStageExpression`. + expr: :class:`SnowflakeCreateStageStatement`. Returns: Tuple of (SQL string, empty params tuple). @@ -127,7 +127,7 @@ def format_create_stage_statement( parts.append(self.format_identifier(expr.name)) options = [] if expr.url is not None: - options.append(f"URL = '{self._escape_sql_string(expr.url)}'") + options.append(f"URL = '{self.escape_sql_string(expr.url)}'") if expr.storage_integration is not None: options.append( f"STORAGE_INTEGRATION = " @@ -144,7 +144,7 @@ def format_create_stage_statement( for key, value in enc.items(): if isinstance(value, str): items.append( - f"{key} = '{self._escape_sql_string(value)}'" + f"{key} = '{self.escape_sql_string(value)}'" ) else: items.append(f"{key} = {str(value).upper()}") @@ -158,12 +158,12 @@ def format_create_stage_statement( return " ".join(parts), () def format_alter_stage_statement( - self, expr: "SnowflakeAlterStageExpression" + self, expr: "SnowflakeAlterStageStatement" ) -> Tuple[str, tuple]: """Format ALTER STAGE ... SET statement. Args: - expr: :class:`SnowflakeAlterStageExpression`. + expr: :class:`SnowflakeAlterStageStatement`. Returns: Tuple of (SQL string, empty params tuple). @@ -178,7 +178,7 @@ def format_alter_stage_statement( f"FILE_FORMAT = {self.format_identifier(expr.file_format)}" ) if expr.url is not None: - options.append(f"URL = '{self._escape_sql_string(expr.url)}'") + options.append(f"URL = '{self.escape_sql_string(expr.url)}'") if expr.storage_integration is not None: options.append( f"STORAGE_INTEGRATION = " @@ -186,7 +186,7 @@ def format_alter_stage_statement( ) if expr.comment is not None: options.append( - f"COMMENT = '{self._escape_sql_string(expr.comment)}'" + f"COMMENT = '{self.escape_sql_string(expr.comment)}'" ) if not options: raise ValueError("ALTER STAGE SET requires at least one property") @@ -195,12 +195,12 @@ def format_alter_stage_statement( return " ".join(parts), () def format_drop_stage_statement( - self, expr: "SnowflakeDropStageExpression" + self, expr: "SnowflakeDropStageStatement" ) -> Tuple[str, tuple]: """Format DROP STAGE statement. Args: - expr: :class:`SnowflakeDropStageExpression`. + expr: :class:`SnowflakeDropStageStatement`. Returns: Tuple of (SQL string, empty params tuple). diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/stream.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/stream.py index e6d698e..d8379f3 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/stream.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/stream.py @@ -5,8 +5,8 @@ if TYPE_CHECKING: from ..expression.ddl.stream import ( - SnowflakeCreateStreamExpression, - SnowflakeDropStreamExpression, + SnowflakeCreateStreamStatement, + SnowflakeDropStreamStatement, ) @@ -18,12 +18,12 @@ def supports_stream(self) -> bool: return True def format_create_stream_statement( - self, expr: "SnowflakeCreateStreamExpression" + self, expr: "SnowflakeCreateStreamStatement" ) -> Tuple[str, tuple]: """Format CREATE [OR REPLACE] STREAM statement. Args: - expr: :class:`SnowflakeCreateStreamExpression`. + expr: :class:`SnowflakeCreateStreamStatement`. Returns: Tuple of (SQL string, empty params tuple). @@ -65,17 +65,17 @@ def format_create_stream_statement( parts.append(f"COPY_GRANTS = {str(bool(expr.copy_grants)).upper()}") if expr.comment is not None: parts.append( - f"COMMENT = '{self._escape_sql_string(expr.comment)}'" + f"COMMENT = '{self.escape_sql_string(expr.comment)}'" ) return " ".join(parts), () def format_drop_stream_statement( - self, expr: "SnowflakeDropStreamExpression" + self, expr: "SnowflakeDropStreamStatement" ) -> Tuple[str, tuple]: """Format DROP STREAM statement. Args: - expr: :class:`SnowflakeDropStreamExpression`. + expr: :class:`SnowflakeDropStreamStatement`. Returns: Tuple of (SQL string, empty params tuple). diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/task.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/task.py index ae12af6..9c569c3 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/task.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/task.py @@ -7,10 +7,10 @@ if TYPE_CHECKING: from ..expression.ddl.task import ( - SnowflakeAlterTaskExpression, - SnowflakeCreateTaskExpression, - SnowflakeDropTaskExpression, - SnowflakeExecuteTaskExpression, + SnowflakeAlterTaskStatement, + SnowflakeCreateTaskStatement, + SnowflakeDropTaskStatement, + SnowflakeExecuteTaskStatement, ) @@ -22,12 +22,12 @@ def supports_tasks(self) -> bool: return True def format_create_task_statement( - self, expr: "SnowflakeCreateTaskExpression" + self, expr: "SnowflakeCreateTaskStatement" ) -> Tuple[str, tuple]: """Format CREATE [OR REPLACE] TASK statement. Args: - expr: :class:`SnowflakeCreateTaskExpression`. + expr: :class:`SnowflakeCreateTaskStatement`. Returns: Tuple of (SQL string, empty params tuple). @@ -53,10 +53,10 @@ def format_create_task_statement( if expr.using_cron is not None: cron = f"USING CRON {expr.using_cron}" if expr.timezone is not None: - cron += f" {self._escape_sql_string(str(expr.timezone))}" + cron += f" {self.escape_sql_string(str(expr.timezone))}" schedule = f"SCHEDULE = '{cron}'" elif expr.schedule is not None: - value = self._escape_sql_string(str(expr.schedule)) + value = self.escape_sql_string(str(expr.schedule)) schedule = f"SCHEDULE = '{value}'" if schedule: parts.append(schedule) @@ -67,7 +67,7 @@ def format_create_task_statement( ) if expr.comment is not None: parts.append( - f"COMMENT = '{self._escape_sql_string(expr.comment)}'" + f"COMMENT = '{self.escape_sql_string(expr.comment)}'" ) if expr.after: after = ", ".join(self.format_identifier(t) for t in expr.after) @@ -79,7 +79,7 @@ def format_create_task_statement( return " ".join(parts), () def format_alter_task_statement( - self, expr: "SnowflakeAlterTaskExpression" + self, expr: "SnowflakeAlterTaskStatement" ) -> Tuple[str, tuple]: """Format ALTER TASK statement. @@ -87,7 +87,7 @@ def format_alter_task_statement( ``SET ...`` based on :attr:`expr.mode`. Args: - expr: :class:`SnowflakeAlterTaskExpression`. + expr: :class:`SnowflakeAlterTaskStatement`. Returns: Tuple of (SQL string, empty params tuple). @@ -128,10 +128,10 @@ def format_alter_task_statement( if expr.using_cron is not None: cron = f"USING CRON {expr.using_cron}" if expr.timezone is not None: - cron += f" {self._escape_sql_string(str(expr.timezone))}" + cron += f" {self.escape_sql_string(str(expr.timezone))}" schedule = f"SCHEDULE = '{cron}'" elif expr.schedule is not None: - value = self._escape_sql_string(str(expr.schedule)) + value = self.escape_sql_string(str(expr.schedule)) schedule = f"SCHEDULE = '{value}'" if schedule: options.append(schedule) @@ -142,12 +142,12 @@ def format_alter_task_statement( return " ".join(parts), () def format_execute_task_statement( - self, expr: "SnowflakeExecuteTaskExpression" + self, expr: "SnowflakeExecuteTaskStatement" ) -> Tuple[str, tuple]: """Format EXECUTE TASK statement. Args: - expr: :class:`SnowflakeExecuteTaskExpression`. + expr: :class:`SnowflakeExecuteTaskStatement`. Returns: Tuple of (SQL string, empty params tuple). @@ -159,12 +159,12 @@ def format_execute_task_statement( return " ".join(parts), () def format_drop_task_statement( - self, expr: "SnowflakeDropTaskExpression" + self, expr: "SnowflakeDropTaskStatement" ) -> Tuple[str, tuple]: """Format DROP TASK statement. Args: - expr: :class:`SnowflakeDropTaskExpression`. + expr: :class:`SnowflakeDropTaskStatement`. Returns: Tuple of (SQL string, empty params tuple). diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/time_travel.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/time_travel.py index 5666710..1c49758 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/time_travel.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/time_travel.py @@ -25,7 +25,7 @@ class SnowflakeTimeTravelMixin: """Mixin for Snowflake time travel query support. - Implements ``format_temporal_options``, the clause hook that the shared + Implements ``format_temporal_options_expression``, the clause hook that the shared ``format_named_relation`` calls when a reference carries temporal options. Snowflake deliberately does not claim the generic :class:`~...dialect.protocols.TemporalTableSupport`: that protocol is the @@ -77,17 +77,17 @@ def format_time_travel_point(self, keyword: str, point: Tuple[str, Any]) -> str: kind = str(kind).upper() if kind == "OFFSET": return f"{clause}(OFFSET => {int(value)})" - escaped = self._escape_sql_string(str(value)) + escaped = self.escape_sql_string(str(value)) return f"{clause}({kind} => '{escaped}')" - def format_temporal_options( + def format_temporal_options_expression( self, expr: "TemporalOptionsExpression" ) -> Tuple[str, tuple]: """Render a relation's time-travel clause. The options mapping is a single ``at`` or ``before`` key whose value is a ``(kind, value)`` pair -- the same shape - :class:`~...expression.ddl.stream.SnowflakeCreateStreamExpression` + :class:`~...expression.ddl.stream.SnowflakeCreateStreamStatement` takes, so one point definition serves both statements. Args: @@ -100,11 +100,11 @@ def format_temporal_options( ValueError: no temporal options were given. UnsupportedFeatureError: an option Snowflake cannot express. """ - options: Dict[str, Any] = getattr(expr, "options", None) or {} + options: Dict[str, Any] = expr.options or {} if not options: raise ValueError( "Temporal options cannot be empty. If no temporal options are needed, " - "don't call format_temporal_options." + "don't call format_temporal_options_expression." ) if len(options) > 1: raise UnsupportedFeatureError( diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/truncate.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/truncate.py index 5b98457..cdc1f89 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/truncate.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/truncate.py @@ -7,7 +7,7 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements.ddl_truncate import ( - TruncateExpression, + TruncateStatement, ) @@ -31,7 +31,7 @@ def supports_truncate(self) -> bool: """ return True - def format_truncate_statement(self, expr: "TruncateExpression") -> Tuple[str, tuple]: + def format_truncate_statement(self, expr: "TruncateStatement") -> Tuple[str, tuple]: """Format Snowflake TRUNCATE TABLE . Raises: diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/types.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/types.py index 8d8f9ad..e15efd7 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/types.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/types.py @@ -375,8 +375,24 @@ def _refuse_unsigned_integer( lost by dropping the *concept* on this backend; what would be lost is the caller's statement that these particular columns hold no negative value, and that belongs in a ``CHECK`` constraint. + + The shape check comes first because this method is the **only** place + that sees the field: the expression's constructor collects ``unsigned`` + without judging it — only the formatter knows which backend has to honour + it — so a non-boolean reaches here instead of being rejected at + construction. A truth test cannot see it: ``unsigned=0``, + ``unsigned=""`` and ``unsigned=None`` are all falsy, so they would sail + past the test below, render a signed column and report success, which is + the same silent loss this method exists to prevent. Naming the shape is + what a truth test cannot do. """ - if not expr.unsigned: + unsigned = expr.unsigned + if not isinstance(unsigned, bool): + raise TypeError( + f"unsigned must be a bool, got {type(unsigned).__name__}; it " + f"becomes a type modifier in the rendered DDL." + ) + if not unsigned: return raise UnsupportedFeatureError( self.name, @@ -455,14 +471,30 @@ def _refuse_unsigned_numeric( ``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. This runs **before** - ``_check_spelling`` and before every precision and scale check, so a + ``check_spelling`` and before every precision and scale check, so a request wrong in two ways is told about the signedness first — that one is a stronger and less recoverable statement than a misspelled word or an out-of-range number. Every one of those existing checks still fires unchanged for a signed declaration, which is what ``test_snowflake_type_protocol.py`` pins. + + The shape check is first for the reason given in + :meth:`_refuse_unsigned_integer`, and it matters **twice** over here + because this is the gate for two class hierarchies: a non-boolean + reaches the formatter unjudged (the constructors collect ``unsigned`` + without validating it), and a falsy one — ``0``, ``""``, ``None``, an + empty container — is invisible to the truth test that follows, so it + would render a signed ``NUMBER`` / ``FLOAT`` / ``DOUBLE`` and report + success whether it arrived through ``DecimalType`` or through + :class:`~...expression.types.SnowflakeNumberType`. """ - if not getattr(expr, "unsigned", False): + unsigned = expr.unsigned + if not isinstance(unsigned, bool): + raise TypeError( + f"unsigned must be a bool, got {type(unsigned).__name__}; it " + f"becomes a type modifier in the rendered DDL." + ) + if not unsigned: return raise UnsupportedFeatureError( self.name, @@ -489,7 +521,7 @@ def format_data_type_integer(self, expr: IntegerType) -> Tuple[str, tuple]: :meth:`_refuse_unsigned_integer`, which is also where the reason for the refusal is written down. """ - self._check_spelling(expr, IntegerType) + self.check_spelling(expr, IntegerType) self._refuse_unsigned_integer(expr, "INTEGER") return "INTEGER", () @@ -512,7 +544,7 @@ def format_data_type_tinyint(self, expr: TinyIntType) -> Tuple[str, tuple]: ``unsigned`` is refused as well; see :meth:`_refuse_unsigned_integer`. """ - self._check_spelling(expr, ("tinyint",)) + self.check_spelling(expr, ("tinyint",)) self._refuse_unsigned_integer(expr, "TINYINT") return "TINYINT", () @@ -522,7 +554,7 @@ def format_data_type_bigint(self, expr: BigIntType) -> Tuple[str, tuple]: ``unsigned`` is refused; see :meth:`_refuse_unsigned_integer`. """ - self._check_spelling(expr, BigIntType) + self.check_spelling(expr, BigIntType) self._refuse_unsigned_integer(expr, "BIGINT") return "BIGINT", () @@ -534,7 +566,7 @@ def format_data_type_smallint(self, expr: SmallIntType) -> Tuple[str, tuple]: ``unsigned`` is refused; see :meth:`_refuse_unsigned_integer`. """ - self._check_spelling(expr, SmallIntType) + self.check_spelling(expr, SmallIntType) self._refuse_unsigned_integer(expr, "SMALLINT") return "SMALLINT", () @@ -576,7 +608,7 @@ def format_data_type_double(self, expr: DoubleType) -> Tuple[str, tuple]: ``unsigned`` is refused rather than ignored, **before** the spelling check; see :meth:`_refuse_unsigned_numeric`.""" self._refuse_unsigned_numeric(expr, "DOUBLE") - self._check_spelling(expr, DoubleType) + self.check_spelling(expr, DoubleType) return "DOUBLE", () def format_data_type_decimal(self, expr: DecimalType) -> Tuple[str, tuple]: @@ -588,18 +620,18 @@ def format_data_type_decimal(self, expr: DecimalType) -> Tuple[str, tuple]: both halves of the ``NUMBER(p, s)`` syntax: it renders a declared scale, and refuses one declared without a precision. - ``unsigned`` is refused rather than ignored, **before** ``_check_spelling`` + ``unsigned`` is refused rather than ignored, **before** ``check_spelling`` and before :meth:`_format_number`'s "scale without a precision" refusal and its two range checks; see :meth:`_refuse_unsigned_numeric`. All three of those still fire unchanged for a signed declaration.""" self._refuse_unsigned_numeric(expr, "NUMBER") - self._check_spelling(expr, DecimalType) + self.check_spelling(expr, DecimalType) return self._format_number(expr.precision, expr.scale) def format_data_type_boolean(self, expr: BooleanType) -> Tuple[str, tuple]: """``BOOL`` is a Snowflake-accepted spelling of ``BOOLEAN``, so both render as ``BOOLEAN``.""" - self._check_spelling(expr, BooleanType) + self.check_spelling(expr, BooleanType) return "BOOLEAN", () def format_data_type_varchar(self, expr: VarCharType) -> Tuple[str, tuple]: @@ -634,7 +666,7 @@ def format_data_type_varchar(self, expr: VarCharType) -> Tuple[str, tuple]: caller declared a bounded concept rather than text; the *server* does not distinguish them and does not pretend to. """ - self._check_spelling(expr, VarCharType) + self.check_spelling(expr, VarCharType) if expr.length is not None: return f"VARCHAR({expr.length})", () return "VARCHAR", () @@ -650,7 +682,7 @@ def format_data_type_char(self, expr: CharType) -> Tuple[str, tuple]: was already ``CHAR(1)`` for a bare declaration, and the manual's "except the default length is ``VARCHAR(1)``" is what that 1 is. """ - self._check_spelling(expr, CharType) + self.check_spelling(expr, CharType) return (f"CHAR({expr.length})" if expr.length is not None else "CHAR(1)"), () def format_data_type_text(self, expr: TextType) -> Tuple[str, tuple]: @@ -662,7 +694,7 @@ def format_data_type_text(self, expr: TextType) -> Tuple[str, tuple]: word, but it is the concept's second spelling and both denote the same unbounded string, so it is accepted and normalised rather than refused. """ - self._check_spelling(expr, TextType) + self.check_spelling(expr, TextType) return "VARCHAR(16777216)", () def format_data_type_blob(self, expr: BlobType) -> Tuple[str, tuple]: @@ -674,7 +706,7 @@ def format_data_type_blob(self, expr: BlobType) -> Tuple[str, tuple]: spelling, which is the one every plain ``BlobType(dialect)`` carries, so the spelling is normalised instead. """ - self._check_spelling(expr, BlobType) + self.check_spelling(expr, BlobType) return "BINARY", () def format_data_type_datetime(self, expr: DateTimeType) -> Tuple[str, tuple]: @@ -762,7 +794,7 @@ def format_data_type_snowflake_varchar(self, expr: SnowflakeVarcharType) -> Tupl :class:`~...impl.sqlserver.expression.types.SQLServerNVarCharMaxType` is not handed SQL Server's ``VARCHAR`` width merely for deriving from one. """ - self._check_spelling(expr, VarCharType) + self.check_spelling(expr, VarCharType) if expr.length is not None: return f"VARCHAR({expr.length})", () return "VARCHAR", () @@ -784,7 +816,7 @@ def format_data_type_snowflake_number(self, expr: SnowflakeNumberType) -> Tuple[ :meth:`_refuse_unsigned_numeric`, which is also where the reasoning about the two class hierarchies is written down.""" self._refuse_unsigned_numeric(expr, "NUMBER") - self._check_spelling(expr, DecimalType) + self.check_spelling(expr, DecimalType) return self._format_number(expr.precision, expr.scale) def format_data_type_snowflake_float(self, expr: SnowflakeFloatType) -> Tuple[str, tuple]: @@ -793,7 +825,7 @@ def format_data_type_snowflake_float(self, expr: SnowflakeFloatType) -> Tuple[st ``unsigned`` is refused rather than ignored, before the precision check — see :meth:`_refuse_unsigned_numeric`. ``spelling`` is not a field of this - class, so :meth:`_check_spelling` is deliberately **not** called here: + class, so :meth:`check_spelling` is deliberately **not** called here: there is no word on the concept to gate.""" self._refuse_unsigned_numeric(expr, "FLOAT") if expr.precision is not None: @@ -834,7 +866,7 @@ def format_data_type_snowflake_time(self, expr: SnowflakeTimeType) -> Tuple[str, def format_data_type_snowflake_blob(self, expr: SnowflakeBinaryType) -> Tuple[str, tuple]: """``BINARY(n)`` states the maximum byte count; without one, Snowflake's default of 8,388,608 applies and is left implicit.""" - self._check_spelling(expr, ("blob",)) + self.check_spelling(expr, ("blob",)) if expr.length is not None: return f"BINARY({expr.length})", () return "BINARY", () diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/undrop.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/undrop.py index cb27f8a..bc10db1 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/undrop.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/undrop.py @@ -4,7 +4,7 @@ from typing import Tuple, TYPE_CHECKING if TYPE_CHECKING: - from ..expression.ddl.undrop import SnowflakeUndropExpression + from ..expression.ddl.undrop import SnowflakeUndropStatement class SnowflakeUndropMixin: @@ -15,12 +15,12 @@ def supports_undrop(self) -> bool: return True def format_undrop_statement( - self, expr: "SnowflakeUndropExpression" + self, expr: "SnowflakeUndropStatement" ) -> Tuple[str, tuple]: """Format UNDROP statement. Args: - expr: :class:`SnowflakeUndropExpression`. + expr: :class:`SnowflakeUndropStatement`. Returns: Tuple of (SQL string, empty params tuple). diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/variant.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/variant.py index f4702b7..8d83306 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/variant.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/variant.py @@ -17,7 +17,7 @@ def supports_variant_type(self) -> bool: """Snowflake supports VARIANT type.""" return True - def format_variant_path_access( + def format_variant_path_access_expression( self, expr: "SnowflakeVariantPathAccessExpression" ) -> Tuple[str, tuple]: """Format VARIANT path access expression using colon notation. @@ -31,7 +31,7 @@ def format_variant_path_access( """ return f'{expr.column}:{expr.path}', () - def format_variant_cast( + def format_variant_cast_expression( self, expr: "SnowflakeVariantCastExpression" ) -> Tuple[str, tuple]: """Format VARIANT path access with explicit cast. @@ -43,4 +43,7 @@ def format_variant_cast( Tuple of (SQL string, empty params tuple). """ - return f'{expr.column}:{expr.path}::{expr.target_type}', () + from rhosocial.activerecord.backend.expression.types import DataType + + target_sql, _ = DataType.as_type(self, expr.target_type).to_sql() + return f'{expr.column}:{expr.path}::{target_sql}', () diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/warehouse.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/warehouse.py index 64a2872..520c788 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/warehouse.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/warehouse.py @@ -7,9 +7,12 @@ if TYPE_CHECKING: from ..expression.ddl.warehouse import ( - SnowflakeAlterWarehouseExpression, - SnowflakeCreateWarehouseExpression, - SnowflakeDropWarehouseExpression, + SnowflakeAlterWarehouseStatement, + SnowflakeCreateWarehouseStatement, + SnowflakeDropWarehouseStatement, + ) + from ..expression.ddl.warehouse_options import ( + SnowflakeWarehouseOptionsExpression, ) @@ -20,13 +23,54 @@ def supports_warehouse(self) -> bool: """Snowflake supports virtual warehouses.""" return True + def format_warehouse_options_expression( + self, expr: "SnowflakeWarehouseOptionsExpression" + ) -> Tuple[str, tuple]: + """Render the shared warehouse property set as ``KEY = value`` tokens. + + The same nine properties appear on ``CREATE WAREHOUSE`` and on + ``ALTER WAREHOUSE ... SET``, in the same spelling, so they are rendered + once here and the two statements ask for this clause. A property left + ``None`` is omitted rather than written as a default -- the server's own + default is the answer, and inventing one would silently override it. + + ``AUTO_SUSPEND`` takes a number of seconds *or* the ``FALSE`` / ``TRUE`` + keyword, which is why it is the one property whose value decides its + own spelling; Snowflake documents both forms. + + https://docs.snowflake.com/en/sql-reference/sql/create-warehouse + https://docs.snowflake.com/en/sql-reference/sql/alter-warehouse + """ + parts: list[str] = [] + if expr.warehouse_size is not None: + parts.append(f"WAREHOUSE_SIZE = {self.format_literal(expr.warehouse_size)}") + if expr.max_cluster_count is not None: + parts.append(f"MAX_CLUSTER_COUNT = {int(expr.max_cluster_count)}") + if expr.min_cluster_count is not None: + parts.append(f"MIN_CLUSTER_COUNT = {int(expr.min_cluster_count)}") + if expr.scaling_policy is not None: + parts.append(f"SCALING_POLICY = {self.format_literal(expr.scaling_policy)}") + if expr.auto_suspend is not None: + if isinstance(expr.auto_suspend, bool): + parts.append(f"AUTO_SUSPEND = {str(expr.auto_suspend).upper()}") + else: + parts.append(f"AUTO_SUSPEND = {int(expr.auto_suspend)}") + if expr.auto_resume is not None: + parts.append(f"AUTO_RESUME = {str(bool(expr.auto_resume)).upper()}") + if expr.initially_suspended is not None: + value = str(bool(expr.initially_suspended)).upper() + parts.append(f"INITIALLY_SUSPENDED = {value}") + if expr.comment is not None: + parts.append(f"COMMENT = {self.format_literal(expr.comment)}") + return " ".join(parts), () + def format_create_warehouse_statement( - self, expr: "SnowflakeCreateWarehouseExpression" + self, expr: "SnowflakeCreateWarehouseStatement" ) -> Tuple[str, tuple]: """Format CREATE WAREHOUSE statement. Args: - expr: :class:`SnowflakeCreateWarehouseExpression`. + expr: :class:`SnowflakeCreateWarehouseStatement`. Returns: Tuple of (SQL string, empty params tuple). @@ -41,7 +85,7 @@ def format_create_warehouse_statement( if expr.warehouse_size is not None: options.append( f"WAREHOUSE_SIZE = " - f"'{self._escape_sql_string(expr.warehouse_size)}'" + f"'{self.escape_sql_string(expr.warehouse_size)}'" ) if expr.max_cluster_count is not None: options.append(f"MAX_CLUSTER_COUNT = {int(expr.max_cluster_count)}") @@ -50,7 +94,7 @@ def format_create_warehouse_statement( if expr.scaling_policy is not None: options.append( f"SCALING_POLICY = " - f"'{self._escape_sql_string(expr.scaling_policy)}'" + f"'{self.escape_sql_string(expr.scaling_policy)}'" ) if expr.auto_suspend is not None: if isinstance(expr.auto_suspend, bool): @@ -68,7 +112,7 @@ def format_create_warehouse_statement( ) if expr.comment is not None: options.append( - f"COMMENT = '{self._escape_sql_string(expr.comment)}'" + f"COMMENT = '{self.escape_sql_string(expr.comment)}'" ) if options: parts.append("WITH") @@ -76,7 +120,7 @@ def format_create_warehouse_statement( return " ".join(parts), () def format_alter_warehouse_statement( - self, expr: "SnowflakeAlterWarehouseExpression" + self, expr: "SnowflakeAlterWarehouseStatement" ) -> Tuple[str, tuple]: """Format ALTER WAREHOUSE statement. @@ -84,7 +128,7 @@ def format_alter_warehouse_statement( based on :attr:`expr.mode`. Args: - expr: :class:`SnowflakeAlterWarehouseExpression`. + expr: :class:`SnowflakeAlterWarehouseStatement`. Returns: Tuple of (SQL string, empty params tuple). @@ -112,7 +156,7 @@ def format_alter_warehouse_statement( if expr.warehouse_size is not None: options.append( f"WAREHOUSE_SIZE = " - f"'{self._escape_sql_string(expr.warehouse_size)}'" + f"'{self.escape_sql_string(expr.warehouse_size)}'" ) if expr.max_cluster_count is not None: options.append(f"MAX_CLUSTER_COUNT = {int(expr.max_cluster_count)}") @@ -121,7 +165,7 @@ def format_alter_warehouse_statement( if expr.scaling_policy is not None: options.append( f"SCALING_POLICY = " - f"'{self._escape_sql_string(expr.scaling_policy)}'" + f"'{self.escape_sql_string(expr.scaling_policy)}'" ) if expr.auto_suspend is not None: if isinstance(expr.auto_suspend, bool): @@ -134,7 +178,7 @@ def format_alter_warehouse_statement( ) if expr.comment is not None: options.append( - f"COMMENT = '{self._escape_sql_string(expr.comment)}'" + f"COMMENT = '{self.escape_sql_string(expr.comment)}'" ) if not options: raise ValueError( @@ -145,12 +189,12 @@ def format_alter_warehouse_statement( return " ".join(parts), () def format_drop_warehouse_statement( - self, expr: "SnowflakeDropWarehouseExpression" + self, expr: "SnowflakeDropWarehouseStatement" ) -> Tuple[str, tuple]: """Format DROP WAREHOUSE statement. Args: - expr: :class:`SnowflakeDropWarehouseExpression`. + expr: :class:`SnowflakeDropWarehouseStatement`. Returns: Tuple of (SQL string, empty params tuple). diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/wildcard.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/wildcard.py new file mode 100644 index 0000000..d876d25 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/wildcard.py @@ -0,0 +1,140 @@ +# src/rhosocial/activerecord/backend/impl/snowflake/mixins/wildcard.py +"""Snowflake's wildcard formatters: the two asterisk positions, both spellable. + +Core split the asterisk by grammar position (spec 4.10) and gave each position +its own formatter: ``format_all_columns_wildcard`` for the select list and +RETURNING, ``format_row_count_wildcard`` for ``COUNT``'s argument. A dialect +overrides the one whose *spelling* differs, and Snowflake's differs in two ways, +both on the ``COUNT`` reference page and in the ``SELECT`` grammar: + +* ``COUNT(.*)`` counts the records that contain no NULL, so the row-count + position accepts a qualifier here where the standard spelling never does; +* both positions accept a filter -- ``* ILIKE ''`` or ``* EXCLUDE + | ( , ... )``. + +Neither override changes the plain forms: a bare ``*`` and a bare ``COUNT(*)`` +render exactly as core renders them, and the qualifier/alias rule is core's (the +name the query itself uses), so it is delegated rather than restated. + +**No capability probe sits behind the filter, and that is deliberate.** The +nodes that carry it (``impl/snowflake/expression/wildcard.py``) exist only in +this backend, so there is no dialect that has the node and lacks the spelling -- +a probe would answer a question nobody can ask, and registering one in a +protocol would add public surface for it. What is gated by a probe is the +*core* wildcard contract, and that is core's ``WildcardSupport``, which this +dialect already satisfies. + +**Evidence level: not server-verified.** Transcribed from +https://docs.snowflake.com/en/sql-reference/functions/count and +https://docs.snowflake.com/en/sql-reference/sql/select ; nothing here was +measured. +""" + +from typing import Tuple, TYPE_CHECKING + +from ..expression.wildcard import ( + SnowflakeAllColumnsWildcard, + SnowflakeRowCountWildcard, +) + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.expression.bases import BaseExpression + + +class SnowflakeWildcardMixin: + """Render Snowflake's filtered wildcards, and delegate everything else. + + Both methods test the *class*, never for a missing attribute: core's + ``AllColumnsWildcard`` has no ``ilike`` at all, and probing for one anyway is + the reflection the project's rule 9.8 forbids. Anything that is not the + Snowflake node has no filter to render, so ``super()`` answers it -- which + keeps core's bare ``*`` and ``rel.*`` paths byte-identical. + """ + + def format_all_columns_wildcard( + self, expr: "BaseExpression" + ) -> Tuple[str, tuple]: + """Render ``*`` / ``rel.*``, optionally followed by ``ILIKE`` / ``EXCLUDE``. + + The asterisk half is core's, including the rule that the qualifier is the + spelling the query uses (the alias when aliased); this method only appends + the filter, so the two spellings cannot drift apart. + + Args: + expr: The wildcard being rendered. + + Returns: + A ``(sql, params)`` tuple; ``params`` is empty (the pattern is + inlined, because a SELECT-list filter is not a bind parameter). + """ + if not isinstance(expr, SnowflakeAllColumnsWildcard): + return super().format_all_columns_wildcard(expr) + return self._render_snowflake_wildcard(expr) + + def format_row_count_wildcard(self, expr: "BaseExpression") -> Tuple[str, tuple]: + """Render ``COUNT``'s argument: the bare ``*``, or Snowflake's qualified form. + + Core's node renders ``*`` and carries no relation; the Snowflake node adds + one, and the reference defines the result as a different number. Both + forms go through the same body renderer, so the qualifier and the filter + are spelled once for the two positions. + + Args: + expr: The wildcard being rendered. + + Returns: + A ``(sql, params)`` tuple; ``params`` is empty. + """ + if not isinstance(expr, SnowflakeRowCountWildcard): + return super().format_row_count_wildcard(expr) + return self._render_snowflake_wildcard(expr) + + # ------------------------------------------------------------------ + # The two pieces both positions share. + # ------------------------------------------------------------------ + + def _render_snowflake_wildcard(self, expr) -> Tuple[str, tuple]: + """The ``*`` / ``rel.*`` body plus whichever filter the node carries. + + The node refuses ``ilike`` and ``exclude`` together at construction (the + reference says they cannot be combined), so this reads at most one. + """ + sql, params = self._render_asterisk_body(expr) + if expr.ilike is not None: + pattern = self.inline_sql_literal(expr.ilike) + return f"{sql} ILIKE {pattern}", params + if expr.exclude is not None: + return f"{sql}{self._render_exclude_suffix(expr.exclude)}", params + return sql, params + + def _render_asterisk_body(self, expr) -> Tuple[str, tuple]: + """The ``*`` or ``rel.*`` half. + + Delegated to core's all-columns renderer, because "which name does this + query call the relation" is the same question in both positions and core + already answers it (alias when aliased, otherwise the qualified name). + The node is handed over as an ``AllColumnsWildcard``, so the rule is not + restated here where it could drift. + """ + from rhosocial.activerecord.backend.expression.core import ( + AllColumnsWildcard, + ) + + view = AllColumnsWildcard(self, relation=expr.relation) + sql, params = super().format_all_columns_wildcard(view) + return sql, tuple(params) + + def _render_exclude_suffix(self, exclude) -> str: + """`` EXCLUDE `` or `` EXCLUDE (, )``. + + The reference shows both shapes -- ``* EXCLUDE col`` and ``* EXCLUDE + (col1, col2)`` -- and writes a single name without parentheses. Names + are identifier-quoted, because that is what they are. + """ + if isinstance(exclude, str): + return f" EXCLUDE {self.format_identifier(exclude)}" + rendered = ", ".join(self.format_identifier(name) for name in exclude) + return f" EXCLUDE ({rendered})" + + +__all__ = ["SnowflakeWildcardMixin"] diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/dml.py b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/dml.py index 648946f..eb7675f 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/dml.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/dml.py @@ -14,7 +14,7 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements import ( - InsertExpression, + InsertStatement, ) @@ -27,13 +27,13 @@ def supports_insert_overwrite(self) -> bool: ... def format_insert_statement( - self, expr: "InsertExpression" + self, expr: "InsertStatement" ) -> Tuple[str, tuple]: """Format an INSERT statement (honours the ``overwrite`` option).""" ... def format_insert_overwrite_statement( - self, expr: "InsertExpression" + self, expr: "InsertStatement" ) -> Tuple[str, tuple]: """Format an INSERT OVERWRITE statement.""" ... diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/file_format.py b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/file_format.py index 092aa69..1cac962 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/file_format.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/file_format.py @@ -14,9 +14,9 @@ if TYPE_CHECKING: from ..expression.ddl.file_format import ( - SnowflakeAlterFileFormatExpression, - SnowflakeCreateFileFormatExpression, - SnowflakeDropFileFormatExpression, + SnowflakeAlterFileFormatStatement, + SnowflakeCreateFileFormatStatement, + SnowflakeDropFileFormatStatement, ) @@ -29,19 +29,19 @@ def supports_file_formats(self) -> bool: ... def format_create_file_format_statement( - self, expr: "SnowflakeCreateFileFormatExpression" + self, expr: "SnowflakeCreateFileFormatStatement" ) -> Tuple[str, tuple]: """Format CREATE [OR REPLACE] FILE FORMAT statement.""" ... def format_alter_file_format_statement( - self, expr: "SnowflakeAlterFileFormatExpression" + self, expr: "SnowflakeAlterFileFormatStatement" ) -> Tuple[str, tuple]: """Format ALTER FILE FORMAT ... SET statement.""" ... def format_drop_file_format_statement( - self, expr: "SnowflakeDropFileFormatExpression" + self, expr: "SnowflakeDropFileFormatStatement" ) -> Tuple[str, tuple]: """Format DROP FILE FORMAT statement.""" ... diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/materialized_view.py b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/materialized_view.py index a176ea9..dba5ecf 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/materialized_view.py @@ -14,7 +14,7 @@ if TYPE_CHECKING: from ..expression.ddl.materialized_view import ( - SnowflakeCreateMaterializedViewExpression, + SnowflakeCreateMaterializedViewStatement, ) @@ -27,7 +27,7 @@ def supports_materialized_view(self) -> bool: ... def format_create_materialized_view_statement( - self, expr: "SnowflakeCreateMaterializedViewExpression" + self, expr: "SnowflakeCreateMaterializedViewStatement" ) -> Tuple[str, tuple]: """Format CREATE [OR REPLACE] MATERIALIZED VIEW statement.""" ... diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/pipe.py b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/pipe.py index 9beca1b..f39f0de 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/pipe.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/pipe.py @@ -15,9 +15,9 @@ if TYPE_CHECKING: from ..expression.ddl.pipe import ( - SnowflakeAlterPipeExpression, - SnowflakeCreatePipeExpression, - SnowflakeDropPipeExpression, + SnowflakeAlterPipeStatement, + SnowflakeCreatePipeStatement, + SnowflakeDropPipeStatement, ) @@ -30,19 +30,19 @@ def supports_pipes(self) -> bool: ... def format_create_pipe_statement( - self, expr: "SnowflakeCreatePipeExpression" + self, expr: "SnowflakeCreatePipeStatement" ) -> Tuple[str, tuple]: """Format CREATE [OR REPLACE] PIPE statement.""" ... def format_alter_pipe_statement( - self, expr: "SnowflakeAlterPipeExpression" + self, expr: "SnowflakeAlterPipeStatement" ) -> Tuple[str, tuple]: """Format ALTER PIPE statement (REFRESH / SET / PAUSE / RESUME).""" ... def format_drop_pipe_statement( - self, expr: "SnowflakeDropPipeExpression" + self, expr: "SnowflakeDropPipeStatement" ) -> Tuple[str, tuple]: """Format DROP PIPE statement.""" ... diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/pivot.py b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/pivot.py index 7e43622..b6f865e 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/pivot.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/pivot.py @@ -31,13 +31,13 @@ def supports_unpivot(self) -> bool: """Whether the UNPIVOT clause is supported.""" ... - def format_pivot_clause( + def format_pivot_expression( self, expr: "SnowflakePivotExpression" ) -> Tuple[str, tuple]: """Format a PIVOT clause.""" ... - def format_unpivot_clause( + def format_unpivot_expression( self, expr: "SnowflakeUnpivotExpression" ) -> Tuple[str, tuple]: """Format an UNPIVOT clause.""" diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/routine.py b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/routine.py index 1ba64b1..ee5b996 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/routine.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/routine.py @@ -15,9 +15,9 @@ if TYPE_CHECKING: from ..expression.ddl.routine import ( - SnowflakeCreateFunctionExpression, - SnowflakeCreateProcedureExpression, - SnowflakeDropRoutineExpression, + SnowflakeCreateFunctionStatement, + SnowflakeCreateProcedureStatement, + SnowflakeDropRoutineStatement, ) @@ -30,19 +30,19 @@ def supports_routines(self) -> bool: ... def format_create_procedure_statement( - self, expr: "SnowflakeCreateProcedureExpression" + self, expr: "SnowflakeCreateProcedureStatement" ) -> Tuple[str, tuple]: """Format CREATE [OR REPLACE] PROCEDURE statement.""" ... def format_create_function_statement( - self, expr: "SnowflakeCreateFunctionExpression" + self, expr: "SnowflakeCreateFunctionStatement" ) -> Tuple[str, tuple]: """Format CREATE [OR REPLACE] FUNCTION statement.""" ... def format_drop_routine_statement( - self, expr: "SnowflakeDropRoutineExpression" + self, expr: "SnowflakeDropRoutineStatement" ) -> Tuple[str, tuple]: """Format DROP PROCEDURE / DROP FUNCTION statement.""" ... diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/sample.py b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/sample.py index b3fe356..d4ad4a9 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/sample.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/sample.py @@ -29,7 +29,7 @@ def supports_tablesample(self) -> bool: """Whether the TABLESAMPLE clause is supported.""" ... - def format_sample_clause( + def format_sample_expression( self, expr: "SnowflakeSampleExpression" ) -> Tuple[str, tuple]: """Format a SAMPLE clause.""" diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/show.py b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/show.py index 6616e24..a3d37ee 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/show.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/show.py @@ -13,7 +13,7 @@ from typing import Protocol, Tuple, runtime_checkable, TYPE_CHECKING if TYPE_CHECKING: - from ..expression.show import SnowflakeShowExpression + from ..expression.show import SnowflakeShowStatement @runtime_checkable @@ -25,7 +25,7 @@ def supports_show(self) -> bool: ... def format_show_statement( - self, expr: "SnowflakeShowExpression" + self, expr: "SnowflakeShowStatement" ) -> Tuple[str, tuple]: """Format a SHOW statement.""" ... diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/stage.py b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/stage.py index 5e1125b..704ca90 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/stage.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/stage.py @@ -18,10 +18,10 @@ if TYPE_CHECKING: from ..expression.ddl.stage import ( - SnowflakeAlterStageExpression, - SnowflakeCopyIntoExpression, - SnowflakeCreateStageExpression, - SnowflakeDropStageExpression, + SnowflakeAlterStageStatement, + SnowflakeCopyIntoStatement, + SnowflakeCreateStageStatement, + SnowflakeDropStageStatement, ) @@ -34,37 +34,37 @@ def supports_stages(self) -> bool: ... def format_copy_into_statement( - self, expr: "SnowflakeCopyIntoExpression" + self, expr: "SnowflakeCopyIntoStatement" ) -> Tuple[str, tuple]: """Format a full COPY INTO statement (load or unload).""" ... def format_create_stage_statement( - self, expr: "SnowflakeCreateStageExpression" + self, expr: "SnowflakeCreateStageStatement" ) -> Tuple[str, tuple]: """Format CREATE STAGE statement.""" ... def format_alter_stage_statement( - self, expr: "SnowflakeAlterStageExpression" + self, expr: "SnowflakeAlterStageStatement" ) -> Tuple[str, tuple]: """Format ALTER STAGE ... SET statement.""" ... def format_drop_stage_statement( - self, expr: "SnowflakeDropStageExpression" + self, expr: "SnowflakeDropStageStatement" ) -> Tuple[str, tuple]: """Format DROP STAGE statement.""" ... def format_copy_into_load( - self, expr: "SnowflakeCopyIntoExpression" + self, expr: "SnowflakeCopyIntoStatement" ) -> Tuple[str, tuple]: """Format COPY INTO
FROM (load).""" ... def format_copy_into_unload( - self, expr: "SnowflakeCopyIntoExpression" + self, expr: "SnowflakeCopyIntoStatement" ) -> Tuple[str, tuple]: """Format COPY INTO FROM
(unload).""" ... diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/stream.py b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/stream.py index 8356746..90388e1 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/stream.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/stream.py @@ -15,8 +15,8 @@ if TYPE_CHECKING: from ..expression.ddl.stream import ( - SnowflakeCreateStreamExpression, - SnowflakeDropStreamExpression, + SnowflakeCreateStreamStatement, + SnowflakeDropStreamStatement, ) @@ -29,13 +29,13 @@ def supports_stream(self) -> bool: ... def format_create_stream_statement( - self, expr: "SnowflakeCreateStreamExpression" + self, expr: "SnowflakeCreateStreamStatement" ) -> Tuple[str, tuple]: """Format CREATE [OR REPLACE] STREAM statement.""" ... def format_drop_stream_statement( - self, expr: "SnowflakeDropStreamExpression" + self, expr: "SnowflakeDropStreamStatement" ) -> Tuple[str, tuple]: """Format DROP STREAM statement.""" ... diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/task.py b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/task.py index c888c43..06e3042 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/task.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/task.py @@ -17,10 +17,10 @@ if TYPE_CHECKING: from ..expression.ddl.task import ( - SnowflakeAlterTaskExpression, - SnowflakeCreateTaskExpression, - SnowflakeDropTaskExpression, - SnowflakeExecuteTaskExpression, + SnowflakeAlterTaskStatement, + SnowflakeCreateTaskStatement, + SnowflakeDropTaskStatement, + SnowflakeExecuteTaskStatement, ) @@ -33,25 +33,25 @@ def supports_tasks(self) -> bool: ... def format_create_task_statement( - self, expr: "SnowflakeCreateTaskExpression" + self, expr: "SnowflakeCreateTaskStatement" ) -> Tuple[str, tuple]: """Format CREATE [OR REPLACE] TASK statement.""" ... def format_alter_task_statement( - self, expr: "SnowflakeAlterTaskExpression" + self, expr: "SnowflakeAlterTaskStatement" ) -> Tuple[str, tuple]: """Format ALTER TASK statement (RESUME / SUSPEND / AFTER / SET).""" ... def format_execute_task_statement( - self, expr: "SnowflakeExecuteTaskExpression" + self, expr: "SnowflakeExecuteTaskStatement" ) -> Tuple[str, tuple]: """Format EXECUTE TASK statement.""" ... def format_drop_task_statement( - self, expr: "SnowflakeDropTaskExpression" + self, expr: "SnowflakeDropTaskStatement" ) -> Tuple[str, tuple]: """Format DROP TASK statement.""" ... diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/time_travel.py b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/time_travel.py index f74181a..8493979 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/time_travel.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/time_travel.py @@ -11,7 +11,7 @@ - BEFORE(STATEMENT => 'uuid'): Query data before a statement - BEFORE(TIMESTAMP => 'timestamp'): Query data before a timestamp -The clause is rendered by ``format_temporal_options``, the same hook the +The clause is rendered by ``format_temporal_options_expression``, the same hook the shared ``format_named_relation`` uses, so a three-level name and its time-travel suffix stay separate concerns. @@ -38,6 +38,6 @@ def format_time_travel_point(self, keyword: str, point: Tuple[str, Any]) -> str: """Render a single AT/BEFORE clause.""" ... - def format_temporal_options(self, expr: "TemporalOptionsExpression") -> Tuple[str, tuple]: + def format_temporal_options_expression(self, expr: "TemporalOptionsExpression") -> Tuple[str, tuple]: """Render the time-travel clause of a relation reference.""" ... diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/undrop.py b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/undrop.py index 62b9fe3..b17a7c4 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/undrop.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/undrop.py @@ -13,7 +13,7 @@ from typing import Protocol, Tuple, runtime_checkable, TYPE_CHECKING if TYPE_CHECKING: - from ..expression.ddl.undrop import SnowflakeUndropExpression + from ..expression.ddl.undrop import SnowflakeUndropStatement @runtime_checkable @@ -25,7 +25,7 @@ def supports_undrop(self) -> bool: ... def format_undrop_statement( - self, expr: "SnowflakeUndropExpression" + self, expr: "SnowflakeUndropStatement" ) -> Tuple[str, tuple]: """Format UNDROP statement.""" ... diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/variant.py b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/variant.py index c6a8977..3d01805 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/variant.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/variant.py @@ -29,13 +29,13 @@ def supports_variant_type(self) -> bool: """Whether VARIANT type is supported.""" ... - def format_variant_path_access( + def format_variant_path_access_expression( self, expr: "SnowflakeVariantPathAccessExpression" ) -> Tuple[str, tuple]: """Format VARIANT path access expression.""" ... - def format_variant_cast( + def format_variant_cast_expression( self, expr: "SnowflakeVariantCastExpression" ) -> Tuple[str, tuple]: """Format VARIANT path access with explicit cast.""" diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/warehouse.py b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/warehouse.py index 0d00cdc..58e742b 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/warehouse.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/warehouse.py @@ -18,9 +18,9 @@ if TYPE_CHECKING: from ..expression.ddl.warehouse import ( - SnowflakeAlterWarehouseExpression, - SnowflakeCreateWarehouseExpression, - SnowflakeDropWarehouseExpression, + SnowflakeAlterWarehouseStatement, + SnowflakeCreateWarehouseStatement, + SnowflakeDropWarehouseStatement, ) @@ -33,13 +33,13 @@ def supports_warehouse(self) -> bool: ... def format_create_warehouse_statement( - self, expr: "SnowflakeCreateWarehouseExpression" + self, expr: "SnowflakeCreateWarehouseStatement" ) -> Tuple[str, tuple]: """Format CREATE WAREHOUSE statement.""" ... def format_alter_warehouse_statement( - self, expr: "SnowflakeAlterWarehouseExpression" + self, expr: "SnowflakeAlterWarehouseStatement" ) -> Tuple[str, tuple]: """Format ALTER WAREHOUSE statement. @@ -49,7 +49,7 @@ def format_alter_warehouse_statement( ... def format_drop_warehouse_statement( - self, expr: "SnowflakeDropWarehouseExpression" + self, expr: "SnowflakeDropWarehouseStatement" ) -> Tuple[str, tuple]: """Format DROP WAREHOUSE statement.""" ... diff --git a/tests/plugins/__init__.py b/tests/plugins/__init__.py new file mode 100644 index 0000000..099a373 --- /dev/null +++ b/tests/plugins/__init__.py @@ -0,0 +1,2 @@ +# tests/plugins/__init__.py +"""Repository-local pytest plugins, registered from ``pyproject.toml``.""" diff --git a/tests/plugins/expression_xdist.py b/tests/plugins/expression_xdist.py new file mode 100644 index 0000000..9cbe87a --- /dev/null +++ b/tests/plugins/expression_xdist.py @@ -0,0 +1,147 @@ +# tests/plugins/expression_xdist.py +"""Default the expression test area to parallel when it is the only target. + +The org rules (expression-dialect §12.1) 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 ``pyproject.toml`` with ``-p plugins.expression_xdist`` and +therefore active for every run in this repository. + +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 scenario-driven live tests must not be 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.1: 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_snowflake_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)) + root = _expression_area_root() + return path == root or root in path.parents + + +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 + + +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 + + # An explicit ``-p no:xdist`` is a caller asking for serial. Injecting + # ``-n`` then would be worse than useless: the option would be unknown and + # the run would die in argument parsing. + for index, arg in enumerate(args): + if arg == "-p" and index + 1 < len(args) and args[index + 1] == "no:xdist": + return + if arg == "-pno:xdist" or arg == "--no-xdist": + 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: + 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.1: 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"]) diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/cli/test_cli_introspect.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/cli/test_cli_introspect.py new file mode 100644 index 0000000..16d620b --- /dev/null +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/cli/test_cli_introspect.py @@ -0,0 +1,596 @@ +# tests/rhosocial/activerecord_snowflake_test/feature/backend/cli/test_cli_introspect.py +"""Offline black-box tests for the ``introspect`` CLI subcommand. + +The real subparser is exercised first: every type ``INTROSPECT_TYPES`` +lists must parse, and a type it does not list must be refused. Then each +dispatch branch runs against a fake backend and introspector -- no +connection is opened and no SQL is run -- so the accepted type list and the +implemented branches cannot drift apart. + +Output serialisation is checked through the real providers for ``json``, +``csv`` and ``table``; the table path is pinned to its no-``rich`` fallback +so the assertion does not depend on whether the optional dependency is +installed. +""" + +import argparse +import csv +import io +import json +from datetime import datetime +from typing import Any, List, Optional + +import pytest +from rhosocial.activerecord.backend.errors import ConnectionError, QueryError +from rhosocial.activerecord.backend.introspection.types import IntrospectionViewInfo + +from rhosocial.activerecord.backend.impl.snowflake.cli import introspect as cli_introspect +from rhosocial.activerecord.backend.impl.snowflake.cli import output as cli_output +from rhosocial.activerecord.backend.impl.snowflake.introspection import ( + SnowflakeDatabaseInfo, + SnowflakeTableStorageMetrics, +) + +#: The types that act on a named table, so the name is required. ``views`` +#: takes an optional view name and is covered separately. +NAME_TYPES = ("columns", "indexes", "foreign-keys", "table-storage-metrics") + +#: The types that answer for a whole family (or for the session's context), +#: so a name is refused rather than ignored. +NO_NAME_TYPES = ("tables", "database", "databases", "schemas", "warehouses") + + +class RecordingProvider: + """An output provider that records instead of printing.""" + + def __init__(self) -> None: + self.results: List[Any] = [] + self.connection_errors: List[Exception] = [] + self.query_errors: List[Exception] = [] + + def display_results(self, data: Any, title: Optional[str] = None) -> None: + self.results.append((title, data)) + + def display_connection_error(self, error: Exception) -> None: + self.connection_errors.append(error) + + def display_query_error(self, error: Exception) -> None: + self.query_errors.append(error) + + +class FakeIntrospector: + """Every method the CLI branches call, with a recorded call log.""" + + def __init__(self) -> None: + self.calls: List[tuple] = [] + + def list_tables(self, schema=None, include_system=False): + self.calls.append(("tables", schema, include_system)) + return [{"TABLE_NAME": "CUSTOMERS"}] + + def list_views(self, schema=None): + self.calls.append(("views", schema)) + return [{"TABLE_NAME": "V_CUSTOMERS"}] + + def get_view_info(self, view_name, schema=None): + self.calls.append(("view-info", view_name, schema)) + if view_name == "missing": + return None + return IntrospectionViewInfo( + name=view_name, + schema=schema or "PUBLIC", + definition="SELECT 1", + is_updatable=True, + ) + + def list_columns(self, name, schema=None): + self.calls.append(("columns", name, schema)) + return [{"COLUMN_NAME": "ID"}] + + def list_indexes(self, name, schema=None): + self.calls.append(("indexes", name, schema)) + return [{"CONSTRAINT_NAME": "PK_CUSTOMERS"}] + + def list_foreign_keys(self, name, schema=None): + self.calls.append(("foreign-keys", name, schema)) + return [{"CONSTRAINT_NAME": "FK_ORDERS"}] + + def get_table_storage_metrics(self, table_name, schema=None): + self.calls.append(("table-storage-metrics", table_name, schema)) + if table_name == "missing": + return None + return SnowflakeTableStorageMetrics( + table_name=table_name, + table_catalog="ANALYTICS", + table_schema=schema or "PUBLIC", + table_id=1, + active_bytes=1024, + time_travel_bytes=16, + failsafe_bytes=0, + retained_for_clone_bytes=0, + row_count=10, + clustering_key=None, + ) + + def get_database_info(self): + self.calls.append(("database",)) + return {"name": "ANALYTICS", "version": "8.0.0", "vendor": "Snowflake"} + + def list_databases(self): + self.calls.append(("databases",)) + return [ + SnowflakeDatabaseInfo( + name="ANALYTICS", + created=datetime(2026, 1, 2, 3, 4, 5), + ) + ] + + def list_schemas(self, database=None): + self.calls.append(("schemas", database)) + return [{"SCHEMA_NAME": "PUBLIC"}] + + def list_warehouses(self): + self.calls.append(("warehouses",)) + return [{"name": "COMPUTE_WH"}] + + +class AsyncFacade: + """Awaitable twins for the sync introspector methods. + + The async manager keeps the sync method names, so the facade exposes the + same names and awaits the sync answer. + """ + + def __init__(self, sync: Any) -> None: + for attr in ( + "list_tables", + "list_views", + "get_view_info", + "list_columns", + "list_indexes", + "list_foreign_keys", + "get_table_storage_metrics", + "get_database_info", + "list_databases", + "list_schemas", + "list_warehouses", + ): + fn = getattr(sync, attr) + + async def call(*a: Any, _fn: Any = fn, **k: Any) -> Any: + return _fn(*a, **k) + + setattr(self, attr, call) + + +def build_parser() -> argparse.ArgumentParser: + """Build the top-level parser with the real introspect subparser.""" + top = argparse.ArgumentParser() + subparsers = top.add_subparsers(dest="command") + cli_introspect.create_parser(subparsers) + return top + + +def patch_sync_backend( + monkeypatch, introspector: Any, connect_error: Optional[Exception] = None +) -> List[Any]: + instances: List[Any] = [] + + class FakeSyncBackend: + def __init__(self, connection_config: Any = None) -> None: + self.connection_config = connection_config + self.introspector = introspector + self._connection: Optional[object] = object() + self.disconnected = False + instances.append(self) + + def connect(self) -> None: + if connect_error is not None: + raise connect_error + + def disconnect(self) -> None: + self.disconnected = True + + monkeypatch.setattr(cli_introspect, "SnowflakeBackend", FakeSyncBackend) + monkeypatch.setattr(cli_introspect, "resolve_connection_config_from_args", lambda args: None) + return instances + + +def patch_async_backend(monkeypatch, introspector: Any) -> List[Any]: + instances: List[Any] = [] + + class FakeAsyncBackend: + def __init__(self, connection_config: Any = None) -> None: + self.connection_config = connection_config + self.introspector = AsyncFacade(introspector) + self._connection: Optional[object] = object() + self.disconnected = False + instances.append(self) + + async def connect(self) -> None: + pass + + async def disconnect(self) -> None: + self.disconnected = True + + monkeypatch.setattr(cli_introspect, "AsyncSnowflakeBackend", FakeAsyncBackend) + monkeypatch.setattr(cli_introspect, "resolve_connection_config_from_args", lambda args: None) + return instances + + +@pytest.fixture +def provider(monkeypatch) -> RecordingProvider: + rec = RecordingProvider() + monkeypatch.setattr(cli_introspect, "create_provider", lambda fmt, ascii_borders=False: rec) + return rec + + +# ================================================================== # +# Argument parsing +# ================================================================== # + + +class TestIntrospectParserContract: + def test_defaults(self): + args = build_parser().parse_args(["introspect", "tables", "--account", "acct"]) + assert args.type == "tables" + assert args.name is None + assert args.include_system is False + assert args.output == "table" + assert args.use_async is False + + def test_name_and_flags(self): + args = build_parser().parse_args( + [ + "introspect", + "columns", + "customers", + "--include-system", + "-o", + "json", + ] + ) + assert args.type == "columns" + assert args.name == "customers" + assert args.include_system is True + assert args.output == "json" + + def test_every_listed_type_is_accepted(self): + for itype in cli_introspect.INTROSPECT_TYPES: + args = build_parser().parse_args(["introspect", itype, "--account", "acct"]) + assert args.type == itype + + def test_the_advertised_types_are_the_capability_mapping(self): + """One source for the list: the mapping, not a second hand-written copy. + + The list used to be written out separately from the capability + switches, so a type could be advertised while its family was not. + """ + assert list(cli_introspect._TYPE_CAPABILITIES) == cli_introspect.INTROSPECT_TYPES + + def test_every_type_names_a_switch_the_dialect_has(self): + """A type whose switch does not exist would be un-gated, not gated.""" + from rhosocial.activerecord.backend.impl.snowflake.dialect import ( + SnowflakeDialect, + ) + + dialect = SnowflakeDialect() + missing = [ + (cli_type, switch) + for cli_type, switch in cli_introspect._TYPE_CAPABILITIES.items() + if not hasattr(dialect, switch) + ] + assert missing == [] + + def test_a_type_that_is_not_listed_is_refused(self): + """``table`` was the removed aggregate and is not a type anymore.""" + with pytest.raises(SystemExit): + build_parser().parse_args(["introspect", "table", "customers", "--account", "acct"]) + + def test_help_lists_every_type_and_no_removed_aggregate(self, capsys): + with pytest.raises(SystemExit): + build_parser().parse_args(["introspect", "--help"]) + out = capsys.readouterr().out + for itype in cli_introspect.INTROSPECT_TYPES: + assert itype in out + assert "table users" not in out + + def test_connection_arguments_flow_through(self): + args = build_parser().parse_args( + [ + "introspect", + "tables", + "--account", + "acct", + "--database", + "ANALYTICS", + "--schema", + "PUBLIC", + "--warehouse", + "WH", + "--role", + "SYSADMIN", + "--user", + "u", + "--password", + "p", + ] + ) + assert (args.account, args.database) == ("acct", "ANALYTICS") + assert (args.schema, args.warehouse, args.role) == ( + "PUBLIC", + "WH", + "SYSADMIN", + ) + assert (args.user, args.password) == ("u", "p") + + +# ================================================================== # +# Synchronous dispatch +# ================================================================== # + + +class TestIntrospectSyncDispatch: + @pytest.mark.parametrize( + "itype,name,title", + [ + ("tables", None, "Tables"), + ("views", None, "Views"), + ("views", "v_customers", "View v_customers"), + ("columns", "customers", "Columns of customers"), + ("indexes", "customers", "Indexes of customers"), + ("foreign-keys", "orders", "Foreign Keys of orders"), + ( + "table-storage-metrics", + "orders", + "Storage Metrics of orders", + ), + ("database", None, "Database Info"), + ("databases", None, "Databases"), + ("schemas", None, "Schemas"), + ("warehouses", None, "Warehouses"), + ], + ) + def test_every_type_reaches_a_branch(self, provider, monkeypatch, itype, name, title): + patch_sync_backend(monkeypatch, FakeIntrospector()) + argv = ["introspect", itype, "--account", "acct"] + if name: + argv.insert(2, name) + cli_introspect.handle(build_parser().parse_args(argv)) + + assert [t for t, _ in provider.results] == [title] + + def test_tables_passes_include_system(self, provider, monkeypatch): + insp = FakeIntrospector() + patch_sync_backend(monkeypatch, insp) + cli_introspect.handle( + build_parser().parse_args( + ["introspect", "tables", "--account", "acct", "--include-system"] + ) + ) + assert insp.calls[-1] == ("tables", None, True) + + def test_schemas_passes_the_database_flag(self, provider, monkeypatch): + insp = FakeIntrospector() + patch_sync_backend(monkeypatch, insp) + cli_introspect.handle( + build_parser().parse_args( + [ + "introspect", + "schemas", + "--account", + "acct", + "--database", + "ANALYTICS", + ] + ) + ) + assert insp.calls[-1] == ("schemas", "ANALYTICS") + + @pytest.mark.parametrize("itype", NAME_TYPES) + def test_missing_name_exits_nonzero(self, provider, capsys, monkeypatch, itype): + patch_sync_backend(monkeypatch, FakeIntrospector()) + with pytest.raises(SystemExit) as excinfo: + cli_introspect.handle( + build_parser().parse_args(["introspect", itype, "--account", "acct"]) + ) + assert excinfo.value.code == 1 + assert "Table name is required" in capsys.readouterr().err + + def test_a_named_view_asks_for_that_view(self, provider, monkeypatch): + insp = FakeIntrospector() + patch_sync_backend(monkeypatch, insp) + cli_introspect.handle( + build_parser().parse_args(["introspect", "views", "v_customers", "--account", "acct"]) + ) + assert insp.calls[-1] == ("view-info", "v_customers", None) + title, rows = provider.results[0] + assert title == "View v_customers" + assert rows[0]["name"] == "v_customers" + + def test_a_named_view_that_is_not_there_exits_one(self, provider, capsys, monkeypatch): + patch_sync_backend(monkeypatch, FakeIntrospector()) + with pytest.raises(SystemExit) as excinfo: + cli_introspect.handle( + build_parser().parse_args(["introspect", "views", "missing", "--account", "acct"]) + ) + assert excinfo.value.code == 1 + assert "View 'missing' not found" in capsys.readouterr().err + + def test_storage_metrics_without_a_row_exits_one(self, provider, capsys, monkeypatch): + """``None`` is "no metrics row", not "no such table".""" + patch_sync_backend(monkeypatch, FakeIntrospector()) + with pytest.raises(SystemExit) as excinfo: + cli_introspect.handle( + build_parser().parse_args( + [ + "introspect", + "table-storage-metrics", + "missing", + "--account", + "acct", + ] + ) + ) + assert excinfo.value.code == 1 + assert "has no storage metrics" in capsys.readouterr().err + assert provider.results == [] + + def test_storage_metrics_answers_with_the_metrics_row(self, provider, monkeypatch): + patch_sync_backend(monkeypatch, FakeIntrospector()) + cli_introspect.handle( + build_parser().parse_args( + ["introspect", "table-storage-metrics", "orders", "--account", "acct"] + ) + ) + title, rows = provider.results[0] + assert title == "Storage Metrics of orders" + assert rows[0]["active_bytes"] == 1024 + assert rows[0]["row_count"] == 10 + + @pytest.mark.parametrize("itype", NO_NAME_TYPES) + def test_a_name_on_a_family_type_is_refused(self, provider, capsys, monkeypatch, itype): + """Exit 2: the pair does not exist, not a runtime failure.""" + patch_sync_backend(monkeypatch, FakeIntrospector()) + with pytest.raises(SystemExit) as excinfo: + cli_introspect.handle( + build_parser().parse_args(["introspect", itype, "analytics", "--account", "acct"]) + ) + assert excinfo.value.code == 2 + err = capsys.readouterr().err + assert "takes no name" in err + assert "analytics" in err + assert provider.results == [] + + def test_database_branch_wraps_one_row(self, provider, monkeypatch): + patch_sync_backend(monkeypatch, FakeIntrospector()) + cli_introspect.handle( + build_parser().parse_args(["introspect", "database", "--account", "acct"]) + ) + title, rows = provider.results[0] + assert title == "Database Info" + assert rows[0]["name"] == "ANALYTICS" + assert rows[0]["vendor"] == "Snowflake" + + def test_connection_is_required(self, capsys, monkeypatch): + monkeypatch.delenv("SNOWFLAKE_ACCOUNT", raising=False) + monkeypatch.delenv("SNOWFLAKE_DATABASE", raising=False) + patch_sync_backend(monkeypatch, FakeIntrospector()) + with pytest.raises(SystemExit) as excinfo: + cli_introspect.handle(build_parser().parse_args(["introspect", "tables"])) + assert excinfo.value.code == 1 + assert "--account and/or --database" in capsys.readouterr().err + + def test_connection_error_exits_nonzero(self, provider, monkeypatch): + patch_sync_backend( + monkeypatch, + FakeIntrospector(), + connect_error=ConnectionError("390100"), + ) + with pytest.raises(SystemExit) as excinfo: + cli_introspect.handle( + build_parser().parse_args(["introspect", "tables", "--account", "acct"]) + ) + assert excinfo.value.code == 1 + assert provider.connection_errors + + def test_query_error_exits_nonzero(self, provider, monkeypatch): + class Exploding(FakeIntrospector): + def list_tables(self, schema=None, include_system=False): + raise QueryError("002003") + + patch_sync_backend(monkeypatch, Exploding()) + with pytest.raises(SystemExit) as excinfo: + cli_introspect.handle( + build_parser().parse_args(["introspect", "tables", "--account", "acct"]) + ) + assert excinfo.value.code == 1 + assert provider.query_errors + + def test_unexpected_error_writes_stderr(self, capsys, provider, monkeypatch): + class Exploding(FakeIntrospector): + def list_tables(self, schema=None, include_system=False): + raise RuntimeError("boom") + + patch_sync_backend(monkeypatch, Exploding()) + with pytest.raises(SystemExit) as excinfo: + cli_introspect.handle( + build_parser().parse_args(["introspect", "tables", "--account", "acct"]) + ) + assert excinfo.value.code == 1 + assert "Error during introspection" in capsys.readouterr().err + + +# ================================================================== # +# Asynchronous dispatch +# ================================================================== # + + +class TestIntrospectAsyncDispatch: + @pytest.mark.parametrize( + "itype,name,title", + [ + ("tables", None, "Tables"), + ("views", None, "Views"), + ("views", "v_customers", "View v_customers"), + ("columns", "customers", "Columns of customers"), + ("indexes", "customers", "Indexes of customers"), + ("foreign-keys", "orders", "Foreign Keys of orders"), + ( + "table-storage-metrics", + "orders", + "Storage Metrics of orders", + ), + ("database", None, "Database Info"), + ("databases", None, "Databases"), + ("schemas", None, "Schemas"), + ("warehouses", None, "Warehouses"), + ], + ) + def test_every_type_reaches_a_branch(self, provider, monkeypatch, itype, name, title): + patch_async_backend(monkeypatch, FakeIntrospector()) + argv = ["introspect", itype, "--account", "acct", "--use-async"] + if name: + argv.insert(2, name) + cli_introspect.handle(build_parser().parse_args(argv)) + + assert [t for t, _ in provider.results] == [title] + + +# ================================================================== # +# Output serialisation, through the real providers +# ================================================================== # + + +class TestIntrospectOutput: + def run_handle(self, monkeypatch, argv): + patch_sync_backend(monkeypatch, FakeIntrospector()) + cli_introspect.handle(build_parser().parse_args(argv)) + + def test_json_output_is_the_serialised_rows(self, capsys, monkeypatch): + self.run_handle( + monkeypatch, + ["introspect", "databases", "--account", "acct", "-o", "json"], + ) + payload = json.loads(capsys.readouterr().out) + assert payload[0]["name"] == "ANALYTICS" + assert payload[0]["created"] == "2026-01-02 03:04:05" + + def test_csv_output_is_the_serialised_rows(self, capsys, monkeypatch): + self.run_handle( + monkeypatch, + ["introspect", "databases", "--account", "acct", "-o", "csv"], + ) + reader = csv.DictReader(io.StringIO(capsys.readouterr().out)) + [row] = list(reader) + assert row["name"] == "ANALYTICS" + assert row["created"] == "2026-01-02 03:04:05" + + def test_table_output_falls_back_to_json_without_rich(self, capsys, monkeypatch): + monkeypatch.setattr(cli_output, "RICH_AVAILABLE", False) + self.run_handle( + monkeypatch, + ["introspect", "databases", "--account", "acct", "-o", "table"], + ) + payload = json.loads(capsys.readouterr().out) + assert payload[0]["name"] == "ANALYTICS" diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/cli/test_introspect_account_objects.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/cli/test_introspect_account_objects.py new file mode 100644 index 0000000..4f9ea92 --- /dev/null +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/cli/test_introspect_account_objects.py @@ -0,0 +1,216 @@ +# tests/rhosocial/activerecord_snowflake_test/feature/backend/cli/test_introspect_account_objects.py +"""CLI dispatch for ``databases`` / ``schemas`` / ``warehouses``. + +The backend is mocked: these tests assert that the parsed subcommand reaches +the right introspector method with the right argument and that the serialized +rows reach the output provider. No connection is opened and no SQL is run. +""" +import argparse +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from rhosocial.activerecord.backend.impl.snowflake.cli import introspect +from rhosocial.activerecord.backend.impl.snowflake.introspection import ( + SnowflakeDatabaseInfo, + SnowflakeSchemaInfo, + SnowflakeWarehouseInfo, +) + + +def parse(argv): + """Parse *argv* through the real introspect subparser.""" + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command") + introspect.create_parser(subparsers) + return parser.parse_args(argv) + + +# ================================================================== # +# Argument parsing +# ================================================================== # + +class TestIntrospectAccountObjectArguments: + @pytest.mark.parametrize( + "type_name", ["databases", "schemas", "warehouses"] + ) + def test_account_object_types_are_accepted(self, type_name): + assert type_name in introspect.INTROSPECT_TYPES + args = parse(["introspect", type_name, "--account", "acct"]) + assert args.type == type_name + assert args.use_async is False + + def test_schemas_takes_database_from_the_connection_flag(self): + args = parse( + [ + "introspect", + "schemas", + "--account", + "acct", + "--database", + "ANALYTICS", + "-o", + "json", + ] + ) + assert args.database == "ANALYTICS" + assert args.output == "json" + assert args.name is None + + def test_databases_does_not_require_a_name(self): + args = parse(["introspect", "databases", "--account", "acct"]) + assert args.name is None + + +# ================================================================== # +# Synchronous dispatch +# ================================================================== # + +class TestSyncIntrospectDispatch: + def run_handle(self, argv, introspector): + args = parse(argv) + with patch.object( + introspect, + "resolve_connection_config_from_args", + return_value=MagicMock(), + ), patch.object(introspect, "SnowflakeBackend") as backend_cls, \ + patch.object(introspect, "create_provider") as provider_factory: + backend = backend_cls.return_value + backend.introspector = introspector + provider = provider_factory.return_value + introspect.handle(args) + return backend, provider + + def test_databases_reaches_list_databases(self): + introspector = MagicMock() + introspector.list_databases.return_value = [ + SnowflakeDatabaseInfo( + name="ANALYTICS", + owner="SYSADMIN", + is_transient=False, + retention_time=1, + database_type="STANDARD", + owner_role_type="ROLE", + created=datetime(2026, 1, 2, 3, 4, 5), + ) + ] + backend, provider = self.run_handle( + ["introspect", "databases", "--account", "acct"], introspector + ) + + backend.connect.assert_called_once_with() + introspector.list_databases.assert_called_once_with() + provider.display_results.assert_called_once_with( + [ + { + "name": "ANALYTICS", + "owner": "SYSADMIN", + "is_transient": False, + "comment": None, + "created": "2026-01-02 03:04:05", + "last_altered": None, + "retention_time": 1, + "database_type": "STANDARD", + "owner_role_type": "ROLE", + } + ], + title="Databases", + ) + + def test_schemas_reaches_list_schemas_with_database(self): + introspector = MagicMock() + introspector.list_schemas.return_value = [ + SnowflakeSchemaInfo( + name="PUBLIC", + catalog_name="ANALYTICS", + is_managed_access=True, + ) + ] + _, provider = self.run_handle( + [ + "introspect", + "schemas", + "--account", + "acct", + "--database", + "ANALYTICS", + ], + introspector, + ) + + introspector.list_schemas.assert_called_once_with( + database="ANALYTICS" + ) + [payload] = provider.display_results.call_args.args[0] + assert payload["name"] == "PUBLIC" + assert payload["catalog_name"] == "ANALYTICS" + assert payload["is_managed_access"] is True + assert provider.display_results.call_args.kwargs == {"title": "Schemas"} + + def test_warehouses_reaches_list_warehouses(self): + introspector = MagicMock() + introspector.list_warehouses.return_value = [ + SnowflakeWarehouseInfo( + name="COMPUTE_WH", + state="STARTED", + size="X-Small", + is_current=True, + ) + ] + _, provider = self.run_handle( + ["introspect", "warehouses", "--account", "acct"], introspector + ) + + introspector.list_warehouses.assert_called_once_with() + [payload] = provider.display_results.call_args.args[0] + assert payload["name"] == "COMPUTE_WH" + assert payload["is_current"] is True + assert provider.display_results.call_args.kwargs == { + "title": "Warehouses" + } + + +# ================================================================== # +# Asynchronous dispatch +# ================================================================== # + +class TestAsyncIntrospectDispatch: + async def test_async_warehouses_reaches_list_warehouses(self): + args = parse( + ["introspect", "warehouses", "--account", "acct", "--use-async"] + ) + backend = MagicMock() + backend._connection = None + backend.connect = AsyncMock() + backend.introspector.list_warehouses = AsyncMock( + return_value=[SnowflakeWarehouseInfo(name="COMPUTE_WH")] + ) + provider = MagicMock() + + await introspect._handle_introspect_async(args, backend, provider) + + backend.connect.assert_awaited_once_with() + backend.introspector.list_warehouses.assert_awaited_once_with() + provider.display_results.assert_called_once() + [payload] = provider.display_results.call_args.args[0] + assert payload["name"] == "COMPUTE_WH" + + async def test_async_databases_reaches_list_databases(self): + args = parse( + ["introspect", "databases", "--account", "acct", "--use-async"] + ) + backend = MagicMock() + backend._connection = None + backend.connect = AsyncMock() + backend.introspector.list_databases = AsyncMock( + return_value=[SnowflakeDatabaseInfo(name="ANALYTICS")] + ) + provider = MagicMock() + + await introspect._handle_introspect_async(args, backend, provider) + + backend.introspector.list_databases.assert_awaited_once_with() + assert provider.display_results.call_args.kwargs == { + "title": "Databases" + } diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_alter_table_if_exists.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_alter_table_if_exists.py index 6bd6a23..d44fa75 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_alter_table_if_exists.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_alter_table_if_exists.py @@ -11,13 +11,14 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.statements import ( ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, ColumnDefinition, + ColumnNotNullConstraint, ) from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( - AddColumn, - DropColumn, - DropTableConstraint, + AddColumnAction, + DropColumnAction, + DropTableConstraintAction, ) from rhosocial.activerecord.backend.expression.types import TextType, VarCharType from rhosocial.activerecord.backend.impl.snowflake.dialect import SnowflakeDialect @@ -37,7 +38,7 @@ def test_supports_switches(self, dialect): class TestSnowflakeAddColumnIfNotExists: def test_if_not_exists_renders_qualifier(self, dialect): - action = AddColumn( + action = AddColumnAction( dialect, ColumnDefinition(dialect, "content", TextType(dialect)), if_not_exists=True, @@ -47,13 +48,17 @@ def test_if_not_exists_renders_qualifier(self, dialect): assert params == () def test_if_not_exists_with_not_null_allowed(self, dialect): - action = AddColumn( + action = AddColumnAction( dialect, ColumnDefinition( dialect, "content", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)], + constraints=[ + ColumnConstraint( + dialect, branch=ColumnNotNullConstraint(dialect) + ) + ], ), if_not_exists=True, ) @@ -62,14 +67,16 @@ def test_if_not_exists_with_not_null_allowed(self, dialect): assert params == () def test_if_not_exists_with_default_raises(self, dialect): - action = AddColumn( + action = AddColumnAction( dialect, ColumnDefinition( dialect, "content", VarCharType(dialect, length=50), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="x") + ColumnConstraint( + dialect, branch=ColumnDefaultConstraint(dialect, "x") + ) ], ), if_not_exists=True, @@ -78,7 +85,7 @@ def test_if_not_exists_with_default_raises(self, dialect): action.to_sql() def test_none_renders_plain_form(self, dialect): - action = AddColumn(dialect, ColumnDefinition(dialect, "content", TextType(dialect))) + action = AddColumnAction(dialect, ColumnDefinition(dialect, "content", TextType(dialect))) sql, params = action.to_sql() assert 'ADD COLUMN "content" VARCHAR(16777216)' == sql assert "IF NOT EXISTS" not in sql @@ -87,13 +94,13 @@ def test_none_renders_plain_form(self, dialect): class TestSnowflakeDropColumnIfExists: 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 @@ -102,14 +109,14 @@ def test_none_renders_plain_form(self, dialect): class TestSnowflakeDropConstraint: def test_if_exists_raises(self, dialect): - action = DropTableConstraint( + action = DropTableConstraintAction( dialect, constraint_name="fk", if_exists=True ) with pytest.raises(UnsupportedFeatureError): action.to_sql() 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_snowflake_test/feature/backend/ddl/test_create_table_family.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_create_table_family.py index 831a1fe..d771667 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_create_table_family.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_create_table_family.py @@ -12,13 +12,13 @@ SnowflakeCreateTableOptions, ) from rhosocial.activerecord.backend.expression import ( - CreateTableLikeExpression, - CreateTableCloneExpression, + CreateTableLikeStatement, + CreateTableCloneStatement, CreateTableCloneMode, - CreateTableFromTemplateExpression, + CreateTableFromTemplateStatement, CreateTableOptions, - CreateTableExpression, - QueryExpression, + CreateTableStatement, + QueryStatement, Literal, ) from rhosocial.activerecord.backend.expression.objects import Table @@ -38,21 +38,21 @@ def test_capabilities(self, dialect): assert dialect.supports_create_table_using_template() is True def test_create_table_like(self, dialect): - sql, params = CreateTableLikeExpression( + sql, params = CreateTableLikeStatement( dialect, Table(dialect, "copy"), Table(dialect, "src") ).to_sql() assert sql == 'CREATE TABLE "copy" LIKE "src"' assert params == () def test_create_table_clone(self, dialect): - sql, params = CreateTableCloneExpression( + sql, params = CreateTableCloneStatement( dialect, Table(dialect, "clone_t"), Table(dialect, "src"), copy_grants=True ).to_sql() assert sql == 'CREATE TABLE "clone_t" CLONE "src" COPY GRANTS' assert params == () def test_create_table_copy(self, dialect): - sql, params = CreateTableCloneExpression( + sql, params = CreateTableCloneStatement( dialect, Table(dialect, "copy_t"), Table(dialect, "src"), @@ -62,15 +62,15 @@ def test_create_table_copy(self, dialect): assert params == () def test_create_table_using_template(self, dialect): - template = QueryExpression(dialect, select=[Literal(dialect, 1)]) - sql, params = CreateTableFromTemplateExpression( + template = QueryStatement(dialect, select=[Literal(dialect, 1)]) + sql, params = CreateTableFromTemplateStatement( dialect, Table(dialect, "t"), template ).to_sql() assert "USING TEMPLATE" in sql assert params == (1,) def test_create_or_replace(self, dialect): - expr = CreateTableExpression( + expr = CreateTableStatement( dialect, table=Table(dialect, "t"), columns=[], @@ -80,7 +80,7 @@ def test_create_or_replace(self, dialect): assert sql.startswith("CREATE OR REPLACE TABLE") def test_create_transient(self, dialect): - expr = CreateTableExpression( + expr = CreateTableStatement( dialect, table=Table(dialect, "t"), columns=[], @@ -90,7 +90,7 @@ def test_create_transient(self, dialect): assert sql.startswith("CREATE TRANSIENT TABLE") def test_create_or_replace_transient(self, dialect): - expr = CreateTableExpression( + expr = CreateTableStatement( dialect, table=Table(dialect, "t"), columns=[], diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_ddl_source_mixin.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_ddl_source_mixin.py index 34318f0..0de0262 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_ddl_source_mixin.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_ddl_source_mixin.py @@ -12,13 +12,19 @@ from rhosocial.activerecord.backend.expression.core import Column from rhosocial.activerecord.backend.expression import ComparisonPredicate, Literal from rhosocial.activerecord.backend.expression.statements import ( - ColumnConstraintType, + ColumnCheckConstraint, ColumnDefinition, - CreateTableExpression, + ColumnDefaultConstraint, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, + CreateTableStatement, IndexDefinition, StorageOptionsExpression, + TableCheckConstraint, TableConstraint, - TableConstraintType, + TablePrimaryKeyConstraint, + TableUniqueConstraint, ) from rhosocial.activerecord.backend.expression.statements.ddl_partition import ( PartitionClause, @@ -48,21 +54,21 @@ SQL_TYPE = UseSqlType(*NATIVE_TYPES) VALUE_COLUMN = UseColumn("physical_value") DEFAULT_CONSTRAINT = UseConstraint( - ColumnConstraintType.DEFAULT, + ColumnDefaultConstraint, name="dflt_value", - default_value=17, + value=17, ) UNIQUE_CONSTRAINT = UseConstraint( - ColumnConstraintType.UNIQUE, + ColumnUniqueConstraint, name="uq_value", ) CHECK_CONDITION = ComparisonPredicate( None, "=", Column(None, "physical_value"), Literal(None, "active") ) CHECK_CONSTRAINT = UseConstraint( - ColumnConstraintType.CHECK, + ColumnCheckConstraint, name="ck_value", - check_condition=CHECK_CONDITION, + condition=CHECK_CONDITION, ) FIRST_INDEX_CONDITION = object() FIRST_INCLUDE_COLUMNS = ["id"] @@ -92,17 +98,18 @@ def generated_expression(dialect): TABLE_CONSTRAINT_ONE = TableConstraint( None, - TableConstraintType.UNIQUE, + branch=TableUniqueConstraint(None, ["physical_value", "quantity"]), name="uq_physical_quantity", - columns=["physical_value", "quantity"], ) TABLE_CONSTRAINT_TWO = TableConstraint( None, - TableConstraintType.CHECK, - name="ck_physical_quantity", - check_condition=ComparisonPredicate( - None, "=", Column(None, "physical_value"), Literal(None, "active") + branch=TableCheckConstraint( + None, + ComparisonPredicate( + None, "=", Column(None, "physical_value"), Literal(None, "active") + ), ), + name="ck_physical_quantity", ) TABLE_CONSTRAINTS = [TABLE_CONSTRAINT_ONE, TABLE_CONSTRAINT_TWO] TABLE_INDEX_ONE = IndexDefinition( @@ -130,9 +137,13 @@ def generated_expression(dialect): TABLE_TABLESPACE = "ts_data" COMPOSITE_CONSTRAINT = TableConstraint( None, - TableConstraintType.CHECK, + branch=TableCheckConstraint( + None, + ComparisonPredicate( + None, "=", Column(None, "tenant_id"), Literal(None, "active") + ), + ), name="ck_composite", - columns=["tenant_id", "order_id"], ) @@ -159,7 +170,7 @@ def table_tablespace(cls): @classmethod def create_table_statement_classes(cls): - return CreateTableExpression + return CreateTableStatement class DeclarationModel(TableDeclarationOverrides, ActiveRecord): @@ -181,7 +192,7 @@ class DeclarationModel(TableDeclarationOverrides, ActiveRecord): VALUE_COMMENT, VALUE_GENERATED, ] = None - quantity: Annotated[int, UseConstraint(ColumnConstraintType.DEFAULT, default_value=7)] = 1 + quantity: Annotated[int, UseConstraint(ColumnDefaultConstraint, value=7)] = 1 __table_constraints__ = TABLE_CONSTRAINTS __table_indexes__ = TABLE_INDEXES @@ -206,7 +217,7 @@ class AsyncDeclarationModel(TableDeclarationOverrides, AsyncActiveRecord): VALUE_COMMENT, VALUE_GENERATED, ] = None - quantity: Annotated[int, UseConstraint(ColumnConstraintType.DEFAULT, default_value=7)] = 1 + quantity: Annotated[int, UseConstraint(ColumnDefaultConstraint, value=7)] = 1 __table_constraints__ = TABLE_CONSTRAINTS __table_indexes__ = TABLE_INDEXES @@ -299,15 +310,15 @@ def _assert_declaration_model(model): assert _same_objects(model.column_data_type("value").data_types, NATIVE_TYPES) value_constraints = model.column_constraints("value") - assert [item.constraint_type for item in value_constraints] == [ - ColumnConstraintType.DEFAULT, - ColumnConstraintType.UNIQUE, - ColumnConstraintType.CHECK, + assert [type(item.branch) for item in value_constraints] == [ + ColumnDefaultConstraint, + ColumnUniqueConstraint, + ColumnCheckConstraint, ] assert value_constraints[0] is DEFAULT_CONSTRAINT.constraint assert value_constraints[1] is UNIQUE_CONSTRAINT.constraint assert value_constraints[2] is CHECK_CONSTRAINT.constraint - assert value_constraints[2].check_condition is CHECK_CONDITION + assert value_constraints[2].branch.condition is CHECK_CONDITION value_indexes = model.column_indexes("value") assert [item.name for item in value_indexes] == [ @@ -332,15 +343,15 @@ def _assert_declaration_model(model): assert model.column_options("value") is None id_constraints = model.column_constraints("id") - assert [item.constraint_type for item in id_constraints] == [ - ColumnConstraintType.PRIMARY_KEY, - ColumnConstraintType.NOT_NULL, + assert [type(item.branch) for item in id_constraints] == [ + ColumnPrimaryKeyConstraint, + ColumnNotNullConstraint, ] quantity_constraints = model.column_constraints("quantity") assert model.model_fields["quantity"].default == 1 - assert quantity_constraints[0].constraint_type == ColumnConstraintType.DEFAULT - assert quantity_constraints[0].default_value == 7 - assert quantity_constraints[1].constraint_type == ColumnConstraintType.NOT_NULL + assert isinstance(quantity_constraints[0].branch, ColumnDefaultConstraint) + assert quantity_constraints[0].branch.value == 7 + assert isinstance(quantity_constraints[1].branch, ColumnNotNullConstraint) assert model.table_options() is TABLE_OPTIONS assert [item.transient for item in model.table_options()] == [False, True] @@ -349,7 +360,7 @@ def _assert_declaration_model(model): assert model.table_partition().keys[0].name == "physical_value" assert model.table_inherits() is TABLE_INHERITS assert model.table_tablespace() == TABLE_TABLESPACE - assert model.create_table_statement_classes() is CreateTableExpression + assert model.create_table_statement_classes() is CreateTableStatement assert model.table_constraints() == TABLE_CONSTRAINTS assert _same_objects(model.table_constraints(), TABLE_CONSTRAINTS) assert model.table_indexes() == TABLE_INDEXES @@ -414,16 +425,16 @@ def _assert_composite_model(model): } for field in ("tenant_ref", "order_ref"): constraints = model.column_constraints(field) - assert [item.constraint_type for item in constraints] == [ - ColumnConstraintType.NOT_NULL, + assert [type(item.branch) for item in constraints] == [ + ColumnNotNullConstraint, ] constraints = model.table_constraints() assert constraints[0] is COMPOSITE_CONSTRAINT - assert constraints[-1].constraint_type == TableConstraintType.PRIMARY_KEY - assert constraints[-1].columns == ["tenant_id", "order_id"] + assert isinstance(constraints[-1].branch, TablePrimaryKeyConstraint) + assert constraints[-1].branch.columns == ["tenant_id", "order_id"] assert all( - item.constraint_type != TableConstraintType.PRIMARY_KEY + not isinstance(item.branch, TablePrimaryKeyConstraint) for item in constraints[:-1] ) diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_object_kind_refused.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_object_kind_refused.py index 81ab340..959db28 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_object_kind_refused.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_object_kind_refused.py @@ -10,7 +10,7 @@ what removed the old accidental guard. Names used to be strings, so ``format_identifier`` accepted anything and a statement given a table where a sequence belonged raised on the spot. Nothing replaced it. So -``CreateSequenceExpression(dialect, Table(dialect, "users"))`` renders +``CreateSequenceStatement(dialect, Table(dialect, "users"))`` renders ``CREATE SEQUENCE "users"`` -- well-formed, and naming a table -- and the mistake is invisible from outside. @@ -42,32 +42,32 @@ ) from rhosocial.activerecord.backend.expression.statements.ddl_database import ( AlterDatabaseAction, - AlterDatabaseExpression, - CreateDatabaseExpression, - DropDatabaseExpression, + AlterDatabaseStatement, + CreateDatabaseStatement, + DropDatabaseStatement, ) from rhosocial.activerecord.backend.expression.statements.ddl_schema import ( - CreateSchemaExpression, - DropSchemaExpression, + CreateSchemaStatement, + DropSchemaStatement, ) from rhosocial.activerecord.backend.expression.statements.ddl_truncate import ( - TruncateExpression, + TruncateStatement, ) from rhosocial.activerecord.backend.expression.statements.ddl_type import ( - AlterTypeExpression, - CreateTypeExpression, - DropTypeExpression, + AlterTypeStatement, + CreateTypeStatement, + DropTypeStatement, ) from rhosocial.activerecord.backend.expression.statements.ddl_view import ( - CreateMaterializedViewExpression, + CreateMaterializedViewStatement, ) -from rhosocial.activerecord.backend.expression.statements.dql import QueryExpression +from rhosocial.activerecord.backend.expression.statements.dql import QueryStatement from rhosocial.activerecord.backend.impl.snowflake.dialect import SnowflakeDialect from rhosocial.activerecord.backend.impl.snowflake.expression.database import ( SnowflakeCreateDatabaseExpression, ) from rhosocial.activerecord.backend.impl.snowflake.expression.ddl.materialized_view import ( # noqa: E501 - SnowflakeCreateMaterializedViewExpression, + SnowflakeCreateMaterializedViewStatement, ) from rhosocial.activerecord.backend.impl.snowflake.expression.ddl.type import ( SnowflakeScalarTypeDefinition, @@ -86,7 +86,7 @@ def dialect(): def _query(dialect): - return QueryExpression( + return QueryStatement( dialect=dialect, select=[Column(dialect, "id")], from_=Table(dialect, "t") ) @@ -100,27 +100,27 @@ def _query(dialect): CASES = [ ( "truncate", - lambda d, o: TruncateExpression(d, o), + lambda d, o: TruncateStatement(d, o), lambda d: Schema(d, "reporting"), - "TruncateExpression.table must be a Table, got Schema", + "TruncateStatement.table must be a Table, got Schema", ), ( "create schema", - lambda d, o: CreateSchemaExpression(d, o), + lambda d, o: CreateSchemaStatement(d, o), lambda d: Database(d, "analytics"), - "CreateSchemaExpression.schema must be a Schema, got Database", + "CreateSchemaStatement.schema must be a Schema, got Database", ), ( "drop schema", - lambda d, o: DropSchemaExpression(d, o), + lambda d, o: DropSchemaStatement(d, o), lambda d: Table(d, "users"), - "DropSchemaExpression.schema must be a Schema, got Table", + "DropSchemaStatement.schema must be a Schema, got Table", ), ( "create database", - lambda d, o: CreateDatabaseExpression(d, o), + lambda d, o: CreateDatabaseStatement(d, o), lambda d: Table(d, "users"), - "CreateDatabaseExpression.database must be a Database, got Table", + "CreateDatabaseStatement.database must be a Database, got Table", ), ( "snowflake create database", @@ -130,54 +130,54 @@ def _query(dialect): ), ( "drop database", - lambda d, o: DropDatabaseExpression(d, o), + lambda d, o: DropDatabaseStatement(d, o), lambda d: Schema(d, "reporting"), - "DropDatabaseExpression.database must be a Database, got Schema", + "DropDatabaseStatement.database must be a Database, got Schema", ), ( "alter database", - lambda d, o: AlterDatabaseExpression( + lambda d, o: AlterDatabaseStatement( d, o, action=AlterDatabaseAction.RENAME_TO, target="renamed" ), lambda d: Schema(d, "reporting"), - "AlterDatabaseExpression.database must be a Database, got Schema", + "AlterDatabaseStatement.database must be a Database, got Schema", ), ( "create materialized view", - lambda d, o: CreateMaterializedViewExpression(d, o, _query(d)), + lambda d, o: CreateMaterializedViewStatement(d, o, _query(d)), lambda d: Table(d, "users"), - "CreateMaterializedViewExpression.view must be a MaterializedView, got Table", + "CreateMaterializedViewStatement.view must be a MaterializedView, got Table", ), ( "snowflake create materialized view", - lambda d, o: SnowflakeCreateMaterializedViewExpression(d, o, _query(d)), + lambda d, o: SnowflakeCreateMaterializedViewStatement(d, o, _query(d)), lambda d: Table(d, "users"), ( - "SnowflakeCreateMaterializedViewExpression.view must be a " + "SnowflakeCreateMaterializedViewStatement.view must be a " "MaterializedView, got Table" ), ), ( "create type", - lambda d, o: CreateTypeExpression( + lambda d, o: CreateTypeStatement( d, o, SnowflakeScalarTypeDefinition(d, IntegerType(d)) ), lambda d: Table(d, "users"), - "CreateTypeExpression.type must be a Type, got Table", + "CreateTypeStatement.type must be a Type, got Table", ), ( "alter type", - lambda d, o: AlterTypeExpression( + lambda d, o: AlterTypeStatement( d, o, [SnowflakeSetTypeCommentAction(d, "a note")] ), lambda d: Table(d, "users"), - "AlterTypeExpression.type must be a Type, got Table", + "AlterTypeStatement.type must be a Type, got Table", ), ( "drop type", - lambda d, o: DropTypeExpression(d, o), + lambda d, o: DropTypeStatement(d, o), lambda d: Table(d, "users"), - "DropTypeExpression.type must be a Type, got Table", + "DropTypeStatement.type must be a Type, got Table", ), ] @@ -234,24 +234,24 @@ class TestRightObjectKindStillRenders: """ def test_truncate_renders(self, dialect): - sql, params = TruncateExpression(dialect, Table(dialect, "users")).to_sql() + sql, params = TruncateStatement(dialect, Table(dialect, "users")).to_sql() assert sql == 'TRUNCATE TABLE "users"' assert params == () def test_create_and_drop_schema_render(self, dialect): - create_sql, _ = CreateSchemaExpression( + create_sql, _ = CreateSchemaStatement( dialect, Schema(dialect, "reporting") ).to_sql() - drop_sql, _ = DropSchemaExpression(dialect, Schema(dialect, "reporting")).to_sql() + drop_sql, _ = DropSchemaStatement(dialect, Schema(dialect, "reporting")).to_sql() assert create_sql.startswith("CREATE SCHEMA") assert '"reporting"' in create_sql assert drop_sql.startswith("DROP SCHEMA") def test_database_ddl_renders(self, dialect): database = Database(dialect, "analytics") - create_sql, _ = CreateDatabaseExpression(dialect, database).to_sql() - drop_sql, _ = DropDatabaseExpression(dialect, database).to_sql() - alter_sql, _ = AlterDatabaseExpression( + create_sql, _ = CreateDatabaseStatement(dialect, database).to_sql() + drop_sql, _ = DropDatabaseStatement(dialect, database).to_sql() + alter_sql, _ = AlterDatabaseStatement( dialect, database, action=AlterDatabaseAction.RENAME_TO, target="renamed" ).to_sql() assert create_sql.startswith("CREATE DATABASE") @@ -260,7 +260,7 @@ def test_database_ddl_renders(self, dialect): assert "RENAME TO" in alter_sql def test_materialized_view_renders(self, dialect): - sql, _ = CreateMaterializedViewExpression( + sql, _ = CreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv"), _query(dialect) ).to_sql() assert sql.startswith('CREATE MATERIALIZED VIEW "mv" AS SELECT') @@ -273,7 +273,7 @@ def test_snowflake_materialized_view_accepts_a_bare_name(self, dialect): the coercion is a feature of the expression, and a check placed in the shared constructor would have made it impossible. """ - sql, _ = SnowflakeCreateMaterializedViewExpression( + sql, _ = SnowflakeCreateMaterializedViewStatement( dialect, "mv", as_query="SELECT 1" ).to_sql() assert sql == 'CREATE MATERIALIZED VIEW "mv" AS SELECT 1' @@ -284,15 +284,15 @@ def test_type_ddl_renders(self, dialect): ) type_object = TypeObject(dialect, "age") - create_sql, _ = CreateTypeExpression( + create_sql, _ = CreateTypeStatement( dialect, type_object, SnowflakeScalarTypeDefinition(dialect, IntegerType(dialect)) ).to_sql() - alter_sql, _ = AlterTypeExpression( + alter_sql, _ = AlterTypeStatement( dialect, type_object, [SnowflakeSetTypeCommentAction(dialect, "a note")], ).to_sql() - drop_sql, _ = DropTypeExpression(dialect, type_object).to_sql() + drop_sql, _ = DropTypeStatement(dialect, type_object).to_sql() assert create_sql.startswith("CREATE TYPE") assert '"age"' in create_sql assert alter_sql.startswith("ALTER TYPE") @@ -311,7 +311,7 @@ class TestTheCheckLivesInTheFormatter: """ def test_construction_with_the_wrong_kind_succeeds(self): - expression = CreateDatabaseExpression(None, "analytics") + expression = CreateDatabaseStatement(None, "analytics") assert expression.database == "analytics" def test_rendering_it_without_a_dialect_fails(self): @@ -321,6 +321,6 @@ def test_rendering_it_without_a_dialect_fails(self): wrong kind, which is what it means for the kind check to live in the formatter. A constructor could not have made this distinction. """ - expression = CreateDatabaseExpression(None, "analytics") + expression = CreateDatabaseStatement(None, "analytics") with pytest.raises(ValueError, match="no dialect bound"): expression.to_sql() \ No newline at end of file diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_sequence_ddl.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_sequence_ddl.py index dafd8d2..a322dc4 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_sequence_ddl.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_sequence_ddl.py @@ -27,9 +27,9 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.objects import Sequence, Table from rhosocial.activerecord.backend.expression.statements.ddl_sequence import ( - AlterSequenceExpression, - CreateSequenceExpression, - DropSequenceExpression, + AlterSequenceStatement, + CreateSequenceStatement, + DropSequenceStatement, ) from rhosocial.activerecord.backend.impl.snowflake.dialect import SnowflakeDialect @@ -47,13 +47,13 @@ class TestCreateSequenceRendering: """``CREATE SEQUENCE`` in Snowflake's clause order.""" def test_plain(self, dialect): - sql, params = CreateSequenceExpression(dialect, _seq(dialect)).to_sql() + sql, params = CreateSequenceStatement(dialect, _seq(dialect)).to_sql() assert sql == 'CREATE SEQUENCE "s"' assert params == () def test_every_supported_option(self, dialect): """The maximal Snowflake-legal option set, in grammar order.""" - sql, params = CreateSequenceExpression( + sql, params = CreateSequenceStatement( dialect, _seq(dialect), if_not_exists=True, @@ -66,16 +66,16 @@ def test_every_supported_option(self, dialect): def test_no_order_renders_noorder(self, dialect): """``no_order`` is the parameter that spells Snowflake's ``NOORDER``.""" - sql, _ = CreateSequenceExpression(dialect, _seq(dialect), no_order=True).to_sql() + sql, _ = CreateSequenceStatement(dialect, _seq(dialect), no_order=True).to_sql() assert sql == 'CREATE SEQUENCE "s" NOORDER' def test_order_pair_is_mutually_exclusive(self, dialect): with pytest.raises(ValueError, match="order and no_order are mutually exclusive"): - CreateSequenceExpression(dialect, _seq(dialect), order=True, no_order=True) + CreateSequenceStatement(dialect, _seq(dialect), order=True, no_order=True) def test_namespaced_name(self, dialect): """The sequence object renders its own namespace; the formatter does not.""" - sql, _ = CreateSequenceExpression( + sql, _ = CreateSequenceStatement( dialect, Sequence(dialect, "s", schema_name="reporting", catalog_name="analytics"), ).to_sql() @@ -86,12 +86,12 @@ class TestDropSequenceRendering: """``DROP SEQUENCE`` in Snowflake's grammar.""" def test_plain(self, dialect): - sql, params = DropSequenceExpression(dialect, _seq(dialect)).to_sql() + sql, params = DropSequenceStatement(dialect, _seq(dialect)).to_sql() assert sql == 'DROP SEQUENCE "s"' assert params == () def test_if_exists(self, dialect): - sql, _ = DropSequenceExpression(dialect, _seq(dialect), if_exists=True).to_sql() + sql, _ = DropSequenceStatement(dialect, _seq(dialect), if_exists=True).to_sql() assert sql == 'DROP SEQUENCE IF EXISTS "s"' @@ -99,44 +99,44 @@ class TestAlterSequenceRendering: """``ALTER SEQUENCE`` in Snowflake's grammar.""" def test_increment(self, dialect): - sql, _ = AlterSequenceExpression(dialect, _seq(dialect), increment=2).to_sql() + sql, _ = AlterSequenceStatement(dialect, _seq(dialect), increment=2).to_sql() assert sql == 'ALTER SEQUENCE "s" INCREMENT BY 2' def test_order(self, dialect): - sql, _ = AlterSequenceExpression(dialect, _seq(dialect), order=True).to_sql() + sql, _ = AlterSequenceStatement(dialect, _seq(dialect), order=True).to_sql() assert sql == 'ALTER SEQUENCE "s" ORDER' def test_noorder_is_one_word(self, dialect): """Snowflake spells it ``NOORDER``; core's shared renderer writes ``NO ORDER``.""" - sql, _ = AlterSequenceExpression(dialect, _seq(dialect), no_order=True).to_sql() + sql, _ = AlterSequenceStatement(dialect, _seq(dialect), no_order=True).to_sql() assert sql == 'ALTER SEQUENCE "s" NOORDER' assert "NO ORDER" not in sql def test_increment_and_order(self, dialect): - sql, _ = AlterSequenceExpression( + sql, _ = AlterSequenceStatement( dialect, _seq(dialect), increment=2, no_order=True ).to_sql() assert sql == 'ALTER SEQUENCE "s" INCREMENT BY 2 NOORDER' def test_cycle_false_is_unspecified_and_emits_nothing(self, dialect): """``cycle=False`` is no longer a spelling: the unset pair emits nothing.""" - sql, _ = AlterSequenceExpression(dialect, _seq(dialect), cycle=False).to_sql() + sql, _ = AlterSequenceStatement(dialect, _seq(dialect), cycle=False).to_sql() assert sql == 'ALTER SEQUENCE "s"' def test_no_cycle_is_refused_by_name(self, dialect): """``NO CYCLE`` is the words Snowflake rejects, so its parameter raises.""" with pytest.raises(UnsupportedFeatureError) as exc_info: - AlterSequenceExpression(dialect, _seq(dialect), no_cycle=True).to_sql() + AlterSequenceStatement(dialect, _seq(dialect), no_cycle=True).to_sql() assert exc_info.value.feature_name == "ALTER SEQUENCE CYCLE" def test_no_cache_is_refused_by_name(self, dialect): with pytest.raises(UnsupportedFeatureError) as exc_info: - AlterSequenceExpression(dialect, _seq(dialect), no_cache=True).to_sql() + AlterSequenceStatement(dialect, _seq(dialect), no_cache=True).to_sql() assert exc_info.value.feature_name == "ALTER SEQUENCE CACHE" def test_order_pair_is_mutually_exclusive(self, dialect): with pytest.raises(ValueError, match="order and no_order are mutually exclusive"): - AlterSequenceExpression(dialect, _seq(dialect), order=True, no_order=True) + AlterSequenceStatement(dialect, _seq(dialect), order=True, no_order=True) #: ``(label, create_kwargs, alter_kwargs)`` for each clause Snowflake lacks. @@ -171,7 +171,7 @@ def test_create_refuses(self, dialect, label, create_kwargs, alter_kwargs): if create_kwargs is None: pytest.skip("no CREATE-side option for this row") with pytest.raises(UnsupportedFeatureError): - CreateSequenceExpression(dialect, _seq(dialect), **create_kwargs).to_sql() + CreateSequenceStatement(dialect, _seq(dialect), **create_kwargs).to_sql() @pytest.mark.parametrize( "label,create_kwargs,alter_kwargs", @@ -180,7 +180,7 @@ def test_create_refuses(self, dialect, label, create_kwargs, alter_kwargs): ) def test_alter_refuses(self, dialect, label, create_kwargs, alter_kwargs): with pytest.raises(UnsupportedFeatureError): - AlterSequenceExpression(dialect, _seq(dialect), **alter_kwargs).to_sql() + AlterSequenceStatement(dialect, _seq(dialect), **alter_kwargs).to_sql() class TestAlterSequenceStartProbe: @@ -199,7 +199,7 @@ def test_probes_disagree(self, dialect): def test_alter_start_is_refused_by_the_probe(self, dialect): with pytest.raises(UnsupportedFeatureError) as exc_info: - AlterSequenceExpression(dialect, _seq(dialect), start=5).to_sql() + AlterSequenceStatement(dialect, _seq(dialect), start=5).to_sql() assert "ALTER SEQUENCE START" in str(exc_info.value) @@ -209,9 +209,9 @@ class TestWrongObjectKindIsRefused: @pytest.mark.parametrize( "build", [ - lambda d, o: CreateSequenceExpression(d, o), - lambda d, o: DropSequenceExpression(d, o), - lambda d, o: AlterSequenceExpression(d, o), + lambda d, o: CreateSequenceStatement(d, o), + lambda d, o: DropSequenceStatement(d, o), + lambda d, o: AlterSequenceStatement(d, o), ], ids=["create", "drop", "alter"], ) @@ -348,8 +348,8 @@ class TestEverySequenceOptionProbeIsLoadBearing: @pytest.mark.parametrize( "statement,expression_cls", [ - ("CREATE", CreateSequenceExpression), - ("ALTER", AlterSequenceExpression), + ("CREATE", CreateSequenceStatement), + ("ALTER", AlterSequenceStatement), ], ids=["create", "alter"], ) diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_type_ddl.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_type_ddl.py index c4ac6b1..ebc1ed0 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_type_ddl.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_type_ddl.py @@ -11,7 +11,7 @@ ) from rhosocial.activerecord.backend.expression.objects import Type from rhosocial.activerecord.backend.expression.serialization import ( - ExpressionRegistry, + default_registry, deserialize, deserialize_json, deserialize_xml, @@ -20,9 +20,10 @@ serialize_xml, ) from rhosocial.activerecord.backend.expression.statements.ddl_type import ( - AlterTypeExpression, - CreateTypeExpression, - DropTypeExpression, + AlterTypeStatement, + CreateTypeStatement, + DropTypeStatement, + TypeAlterAction, TypeDefinition, ) from rhosocial.activerecord.backend.impl.snowflake.expression import ( @@ -61,6 +62,43 @@ def _dialect(version=(10, 8, 0)): return SnowflakeDialect(version=version) +#: Every expression class this backend's type DDL defines. Deserialising one +#: of these kinds needs it in the registry the read goes through, and the +#: framework's default registry holds only the packages the framework itself +#: ships -- nothing registers this backend's classes on import any more. This +#: file is the reader, so it assembles the classes it round-trips into that +#: registry once; the resolution test below then pins the assembly. +_SNOWFLAKE_TYPE_EXPRESSIONS = ( + SnowflakeVarcharType, + SnowflakeNumberType, + SnowflakeFloatType, + SnowflakeBooleanType, + SnowflakeTimestampLtzType, + SnowflakeTimestampNtzType, + SnowflakeTimestampTzType, + SnowflakeDateType, + SnowflakeTimeType, + SnowflakeBinaryType, + SnowflakeVariantType, + SnowflakeObjectType, + SnowflakeArrayType, + SnowflakeGeographyType, + SnowflakeGeometryType, + SnowflakeUserDefinedType, + SnowflakeTypeField, + SnowflakeScalarTypeDefinition, + SnowflakeObjectTypeDefinition, + SnowflakeSetTypeCommentAction, + SnowflakeUnsetTypeCommentAction, + SnowflakeCreateTypeExpression, + SnowflakeAlterTypeExpression, + SnowflakeDropTypeExpression, +) + +for _cls in _SNOWFLAKE_TYPE_EXPRESSIONS: + default_registry().register_node(_cls) + + def _snowflake_data_types(dialect): return ( SnowflakeVarcharType(dialect, length=64), @@ -135,9 +173,9 @@ def test_version_boundary_and_low_version_fail_fast(): set_action = SnowflakeSetTypeCommentAction(dialect, "comment") reference = SnowflakeUserDefinedType(dialect, type_name="age") statements = [ - CreateTypeExpression(dialect, Type(dialect, "age"), scalar), - AlterTypeExpression(dialect, Type(dialect, "age"), [set_action]), - DropTypeExpression(dialect, Type(dialect, "age")), + CreateTypeStatement(dialect, Type(dialect, "age"), scalar), + AlterTypeStatement(dialect, Type(dialect, "age"), [set_action]), + DropTypeStatement(dialect, Type(dialect, "age")), ] assert dialect.supports_type_objects() is False @@ -166,7 +204,7 @@ def test_scalar_create_type_renders_schema_qualification(): SnowflakeNumberType(dialect, precision=3, scale=0), ) - expression = CreateTypeExpression( + expression = CreateTypeStatement( dialect, Type(dialect, "age", schema_name="app"), definition, @@ -316,7 +354,7 @@ def test_alter_type_set_and_unset_comment_actions(): [set_action], if_exists=True, ) - unset_expression = AlterTypeExpression( + unset_expression = AlterTypeStatement( dialect, Type(dialect, "age", schema_name="APP"), [SnowflakeUnsetTypeCommentAction(dialect)], @@ -335,7 +373,7 @@ def test_alter_type_set_and_unset_comment_actions(): def test_alter_type_rejects_multiple_or_unknown_actions(): dialect = _dialect() - expression = AlterTypeExpression( + expression = AlterTypeStatement( dialect, Type(dialect, "age"), [ @@ -345,7 +383,21 @@ def test_alter_type_rejects_multiple_or_unknown_actions(): ) with pytest.raises(UnsupportedFeatureError, match="multiple ALTER TYPE actions"): expression.to_sql() - with pytest.raises(UnsupportedFeatureError, match="ALTER TYPE action"): + + class UnsupportedAction(TypeAlterAction): + """A well-formed action whose kind this dialect never registered.""" + + action_kind = "test.unsupported" + + with pytest.raises( + UnsupportedFeatureError, match=r"ALTER TYPE action test\.unsupported" + ): + dialect.format_type_alter_action(UnsupportedAction(dialect)) + + # The kind is a declared field of the contract, read directly now: an + # input that is not a TypeAlterAction at all surfaces the missing + # attribute instead of the old probe's class-name fallback. + with pytest.raises(AttributeError, match="action_kind"): dialect.format_type_alter_action(object()) @@ -362,7 +414,7 @@ def test_drop_type_has_no_if_exists_cascade_or_restrict(): ) with pytest.raises(UnsupportedFeatureError, match="IF EXISTS"): - DropTypeExpression(dialect, Type(dialect, "age"), if_exists=True).to_sql() + DropTypeStatement(dialect, Type(dialect, "age"), if_exists=True).to_sql() with pytest.raises(UnsupportedFeatureError, match="CASCADE"): SnowflakeDropTypeExpression( dialect, Type(dialect, "age"), cascade=True @@ -377,37 +429,18 @@ def test_drop_type_has_no_if_exists_cascade_or_restrict(): expression.to_sql() -def test_udt_expressions_are_explicitly_registered_for_serialization(): - expression_types = ( - SnowflakeVarcharType, - SnowflakeNumberType, - SnowflakeFloatType, - SnowflakeBooleanType, - SnowflakeTimestampLtzType, - SnowflakeTimestampNtzType, - SnowflakeTimestampTzType, - SnowflakeDateType, - SnowflakeTimeType, - SnowflakeBinaryType, - SnowflakeVariantType, - SnowflakeObjectType, - SnowflakeArrayType, - SnowflakeGeographyType, - SnowflakeGeometryType, - SnowflakeUserDefinedType, - SnowflakeTypeField, - SnowflakeScalarTypeDefinition, - SnowflakeObjectTypeDefinition, - SnowflakeSetTypeCommentAction, - SnowflakeUnsetTypeCommentAction, - SnowflakeCreateTypeExpression, - SnowflakeAlterTypeExpression, - SnowflakeDropTypeExpression, - ) +def test_udt_expressions_resolve_through_the_readers_registry(): + """Every type-DDL class answers to its kind in the assembled registry. - for expression_type in expression_types: - fqn = f"{expression_type.__module__}.{expression_type.__name__}" - assert ExpressionRegistry.lookup(fqn) is expression_type + Deserialisation resolves a kind, never a dotted name, so what a reader + gets back is decided by ``kind()`` -- derived from the class name -- and + by the assembly at module import. A kind two classes claim would have + raised there; this pins that each class's own kind resolves to the class + itself. + """ + registry = default_registry() + for expression_type in _SNOWFLAKE_TYPE_EXPRESSIONS: + assert registry.resolve_node(expression_type.kind()) is expression_type def test_udt_expression_dict_json_and_xml_round_trips(): diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/dialect/test_dialect.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/dialect/test_dialect.py index 4a95302..0f1ca0c 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/dialect/test_dialect.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/dialect/test_dialect.py @@ -43,7 +43,7 @@ def test_get_parameter_placeholder(self, dialect): class TestSnowflakeQualifyClauseAssembly: - """Test QUALIFY clause rendering within QueryExpression.""" + """Test QUALIFY clause rendering within QueryStatement.""" def test_qualify_before_order_by(self, dialect): """QUALIFY must be emitted once and placed before ORDER BY.""" @@ -53,11 +53,11 @@ def test_qualify_before_order_by(self, dialect): Literal, OrderByClause, QualifyClause, - QueryExpression, + QueryStatement, ) from rhosocial.activerecord.backend.expression.objects import Table - query = QueryExpression( + query = QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=Table(dialect, "users"), @@ -198,6 +198,18 @@ def test_returning_clause_version_dependent(self, dialect): assert boundary_dialect.supports_returning_update() is True assert boundary_dialect.supports_returning_delete() is True + def test_returning_predicates_do_not_raise_without_a_version(self): + """An unconfirmed version is "not supported", not an exception. + + A capability probe is asked precisely when the caller cannot assume + the answer is yes, so an unadapted dialect answers False instead of + raising DialectNotAdaptedException. + """ + unknown = SnowflakeDialect(version=None) + assert unknown.supports_returning_insert() is False + assert unknown.supports_returning_update() is False + assert unknown.supports_returning_delete() is False + def test_supports_offset_without_limit(self, dialect): assert dialect.supports_offset_without_limit() is True @@ -219,8 +231,11 @@ def test_supports_set_operation_order_by(self, dialect): def test_supports_set_operation_limit_offset(self, dialect): assert dialect.supports_set_operation_limit_offset() is True - def test_supports_set_operation_for_update(self, dialect): - assert dialect.supports_set_operation_for_update() is False + # ``supports_set_operation_for_update`` is deliberately absent. It gated the + # ``for_update_clause`` trailing parameter the core ``SetOperation`` node used + # to carry; that parameter is abolished, so the probe lost its subject and + # core no longer declares a default for it. Snowflake's mixin never declared + # its own, so there is nothing left to assert. class TestSnowflakeSpecificCapabilities: diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/dialect/test_identifier_dynamic.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/dialect/test_identifier_dynamic.py index f26200a..57b473d 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/dialect/test_identifier_dynamic.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/dialect/test_identifier_dynamic.py @@ -43,7 +43,7 @@ def test_identifier_expression_other_name(self, dialect): assert sql == "IDENTIFIER(%s)" assert params == ("my_schema.my_table",) - def test_format_identifier_dynamic_uses_placeholder(self, dialect): + def test_format_snowflake_identifier_expression_uses_placeholder(self, dialect): expr = SnowflakeIdentifierExpression(dialect, "whatever") sql, params = expr.to_sql() assert sql == "IDENTIFIER(%s)" diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/dml/test_insert_overwrite.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/dml/test_insert_overwrite.py index 83eb832..57917d6 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/dml/test_insert_overwrite.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/dml/test_insert_overwrite.py @@ -8,9 +8,9 @@ from rhosocial.activerecord.backend.expression import ( Column, - InsertExpression, + InsertStatement, Literal, - QueryExpression, + QueryStatement, SelectSource, ValuesSource, ) @@ -31,7 +31,7 @@ def dialect(): @pytest.fixture def select_source(dialect): - query = QueryExpression( + query = QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=Table(dialect, "src"), @@ -68,14 +68,14 @@ def test_insert_overwrite_via_dialect_option(self, dialect, select_source): assert params == () def test_insert_overwrite_standalone(self, dialect, select_source): - expr = InsertExpression( + expr = InsertStatement( dialect, into=Table(dialect, "t"), source=select_source, columns=["id", "name"] ) sql, _ = dialect.format_insert_overwrite_statement(expr) assert sql.startswith("INSERT OVERWRITE INTO ") def test_plain_insert_unchanged(self, dialect, select_source): - expr = InsertExpression( + expr = InsertStatement( dialect, into=Table(dialect, "t"), source=select_source, columns=["id", "name"] ) sql, _ = expr.to_sql() @@ -103,3 +103,47 @@ def test_insert_overwrite_values_source(self, dialect): 'INSERT OVERWRITE INTO "t" VALUES (%s, %s), (%s, %s)' ) assert params == (1, "a", 2, "b") + + +class TestBareHostValueCellsBindThroughDelegation: + """VALUES cells may be bare host values; the override delegates, so they bind. + + Core collects the expression-versus-host-value judgment in one place, and + ``SnowflakeDMLMixin.format_insert_statement`` delegates both of its paths + to the core renderer -- the plain path outright, the overwrite path by + rewriting the prefix of what the core renderer produced. A bare scalar + cell must therefore bind behind %s exactly as a ``Literal`` cell does, on + both paths, without the prefix rewrite disturbing the parameters. + """ + + def test_plain_insert_binds_bare_cells(self, dialect): + expr = InsertStatement( + dialect, + into=Table(dialect, "t"), + source=ValuesSource(dialect, [[1, "a"], [2, "b"]]), + columns=["id", "name"], + ) + sql, params = expr.to_sql() + assert sql == 'INSERT INTO "t" ("id", "name") VALUES (%s, %s), (%s, %s)' + assert params == (1, "a", 2, "b") + + def test_overwrite_insert_binds_bare_cells(self, dialect): + expr = SnowflakeInsertExpression( + dialect, + into=Table(dialect, "t"), + source=ValuesSource(dialect, [[1, "a"], [2, "b"]]), + overwrite=True, + ) + sql, params = expr.to_sql() + assert sql == 'INSERT OVERWRITE INTO "t" VALUES (%s, %s), (%s, %s)' + assert params == (1, "a", 2, "b") + + def test_mixed_cells_bind_in_written_order(self, dialect): + expr = InsertStatement( + dialect, + into=Table(dialect, "t"), + source=ValuesSource(dialect, [[Literal(dialect, 3), "c"]]), + ) + sql, params = expr.to_sql() + assert sql == 'INSERT INTO "t" VALUES (%s, %s)' + assert params == (3, "c") diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/expression/test_expression_discipline.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/expression/test_expression_discipline.py new file mode 100644 index 0000000..dc55666 --- /dev/null +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/expression/test_expression_discipline.py @@ -0,0 +1,138 @@ +# tests/rhosocial/activerecord_snowflake_test/feature/backend/expression/test_expression_discipline.py +"""The expression area's two standing rules, as assertions. + +expression-dialect §12: + +* **No I/O.** An expression test constructs and renders; it starts no + connection, creates no database and touches no file. Anything that needs a + real server belongs to the scenario-driven integration tests, where a + warehouse is part of the setup. +* **Parallel by default.** Independent cases are what makes a module-wide + ``xdist_group`` unjustified, so the only serial mark allowed here would be + one that states its reason. + +The first rule is checked by reading this area's own modules -- the modules +where an import of a backend, a driver or the scenario fixture would show up +-- rather than by trusting the review that wrote them. The second is checked +by reading the mark inventory: no case in this area carries ``xdist_group``, +which is what lets ``--dist=loadgroup`` (set in ``plugins/expression_xdist``) +actually spread them. +""" + +import ast +import pathlib + +AREA = pathlib.Path(__file__).resolve().parent + +#: Names that mean "this module reaches a database", plus the module path +#: fragments a live test would import to reach one. The dialect, the +#: expression nodes and the serialisers are absent on purpose: they are pure +#: data, which is the point of the area. +FORBIDDEN_IMPORTS = ( + "snowflake.connector", + "snowflake_connector", + "aiosqlite", + "fakesnow", +) +FORBIDDEN_NAMES = ( + "SnowflakeBackend", + "AsyncSnowflakeBackend", + "SnowflakeConnectionConfig", +) + +#: Canned SQL a live helper would send. The renderers produce this text +#: themselves, so a literal statement appearing at module level means a module +#: is driving an engine rather than asking a dialect to render. +FORBIDDEN_CALLS = ("connect", "execute", "executemany", "cursor") + + +def _modules(): + return sorted( + path + for path in AREA.glob("*.py") + if path.name != pathlib.Path(__file__).name and path.name != "__init__.py" + ) + + +def test_no_expression_test_imports_a_connection(): + """The dialect and the nodes need no driver, and importing one would say + this area had started measuring a server.""" + offenders = [] + for path in _modules(): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if any( + alias.name == bad or alias.name.startswith(bad + ".") + for bad in FORBIDDEN_IMPORTS + ): + offenders.append((path.name, alias.name)) + elif isinstance(node, ast.ImportFrom): + module = node.module or "" + if any( + module == bad or module.startswith(bad + ".") + for bad in FORBIDDEN_IMPORTS + ): + offenders.append((path.name, module)) + for alias in node.names: + if alias.name in FORBIDDEN_NAMES: + offenders.append((path.name, alias.name)) + assert not offenders, ( + "these expression-area modules import the live side: " + f"{sorted(set(offenders))}. A case that needs a warehouse belongs in " + f"the scenario-driven integration tests (§12)." + ) + + +def test_no_expression_test_drives_a_connection(): + """``.connect()`` / ``.execute()`` / ``.cursor()`` are the calls a live + test makes; rendering is ``.to_sql()`` and stops there.""" + offenders = [] + for path in _modules(): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): + if node.func.attr in FORBIDDEN_CALLS: + offenders.append((path.name, node.lineno, node.func.attr)) + assert not offenders, ( + f"these expression-area calls reach a connection: {sorted(offenders)}" + ) + + +def test_no_case_in_this_area_is_grouped_for_serial_execution(): + """§12.1: parallel is the default, and ``xdist_group`` needs a reason. + + No case here carries one, which is the honest state: every case builds its + own dialect and asserts on a rendered string or an artifact, so none of + them can interfere. A future case that genuinely must run alone can add + the mark -- and this test is where it has to say why. + """ + grouped = [] + for path in _modules(): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): + if node.func.attr == "xdist_group": + grouped.append((path.name, node.lineno)) + if isinstance(node, ast.Constant) and node.value == "xdist_group": + grouped.append((path.name, node.lineno)) + assert not grouped, ( + f"these cases are pinned to one worker: {grouped}. Per §12.1 that needs " + f"a stated reason, and no case in this area has one." + ) + + +def test_the_parallel_default_covers_this_area(): + """The plugin's scoped area is this directory, spelled the same way. + + A path that drifted (a renamed package, a moved directory) would silently + stop matching and the area would fall back to serial without any test + noticing, so the two spellings are compared here. + """ + from plugins.expression_xdist import _expression_area_root + + assert _expression_area_root() == AREA, ( + f"the xdist plugin defaults to parallel for {_expression_area_root()}, " + f"but the expression area is {AREA}" + ) diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/expression/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/expression/test_expression_roundtrip_all.py index 2887294..5b7d4e1 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/expression/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/expression/test_expression_roundtrip_all.py @@ -51,14 +51,16 @@ How the classes are found ========================= -``collect_expression_classes`` walks each package. It does **not** read -``ExpressionRegistry._registry``: that dict is process-global and grows as -sibling test modules import their own backends, so a matrix built from it would -cover a different set of classes depending on which files pytest happened to -import first. ``_auto_register_builtins`` is still called, because deserialising -a core class needs it registered -- a separate concern from deciding what to -test -- and :func:`register_all` registers what was collected so a Snowflake -expression can be found again on the way back. +``collect_expression_classes`` walks each package. It does **not** read a +process-global class index: that dict grows as sibling test modules import their +own backends, so a matrix built from it would cover a different set of classes +depending on which files pytest happened to import first. +Reading is a separate concern from deciding what to test: deserialising a +Snowflake kind needs it in the registry the read goes through, and the +framework's default registry holds only the packages the framework itself +ships -- nothing registers this backend's classes on import. What the walk +collected is therefore assembled into that registry at module level, so a +Snowflake expression can be found again on the way back. And when a class cannot be constructed ===================================== @@ -112,15 +114,32 @@ class that starts needing an exemption goes red until the tuple is updated, and WindowDefinition, WindowSpecification, ) -from rhosocial.activerecord.backend.expression.collation import CollateExpression +from rhosocial.activerecord.backend.expression.collation import CollateFunction from rhosocial.activerecord.backend.expression.core import Column, Literal from rhosocial.activerecord.backend.expression.datetime import ( + ExtractFieldExpression, TemporalOptionsExpression, + _ExtractBase, +) +from rhosocial.activerecord.backend.expression.functions.string import ( + ChrFunction, + LeftFunction, + OverlayExpression, + RightFunction, + SpaceFunction, + SubstringExpression, ) from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.predicates import LikeOperator +from rhosocial.activerecord.backend.expression.predicates import ( + ILikePredicate, + LikePredicate, +) from rhosocial.activerecord.backend.expression.query_parts import JoinClause +from rhosocial.activerecord.backend.expression.operators import ArithmeticOperation +from rhosocial.activerecord.backend.expression.query_sources import SetOperation from rhosocial.activerecord.backend.expression.serialization import ( - ExpressionRegistry, + default_registry, deserialize, deserialize_json, deserialize_xml, @@ -154,12 +173,12 @@ class that starts needing an exemption goes red until the tuple is updated, and ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ( ColumnConstraint, - ColumnConstraintType, + ColumnNotNullConstraint, ForeignKeyConstraint, IndexDefinition, ReferencesClause, TableConstraint, - TableConstraintType, + TablePrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.statements.ddl_trigger import ( TriggerEvent, @@ -169,13 +188,14 @@ class that starts needing an exemption goes red until the tuple is updated, and 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 ( ArrayType, IntegerType, VarCharType, ) from rhosocial.activerecord.backend.expression.xml import ( + PassingClause, XMLAttribute, XMLAttributesExpression, XMLConcatExpression, @@ -184,19 +204,19 @@ class that starts needing an exemption goes red until the tuple is updated, and ) from rhosocial.activerecord.backend.impl.snowflake.dialect import SnowflakeDialect from rhosocial.activerecord.backend.impl.snowflake.expression.ddl.file_format import ( - SnowflakeAlterFileFormatExpression, + SnowflakeAlterFileFormatStatement, ) from rhosocial.activerecord.backend.impl.snowflake.expression.ddl.materialized_view import ( # noqa: E501 - SnowflakeCreateMaterializedViewExpression, + SnowflakeCreateMaterializedViewStatement, ) from rhosocial.activerecord.backend.impl.snowflake.expression.ddl.pipe import ( - SnowflakeCreatePipeExpression, + SnowflakeCreatePipeStatement, ) from rhosocial.activerecord.backend.impl.snowflake.expression.ddl.stage import ( - SnowflakeAlterStageExpression, + SnowflakeAlterStageStatement, ) from rhosocial.activerecord.backend.impl.snowflake.expression.ddl.stream import ( - SnowflakeCreateStreamExpression, + SnowflakeCreateStreamStatement, ) from rhosocial.activerecord.backend.impl.snowflake.expression.ddl.type import ( SnowflakeAlterTypeExpression, @@ -215,11 +235,11 @@ class that starts needing an exemption goes red until the tuple is updated, and SnowflakeSampleExpression, ) from rhosocial.activerecord.backend.impl.snowflake.expression.show import ( - SnowflakeShowExpression, + SnowflakeShowStatement, SnowflakeShowObjectType, SnowflakeShowScope, ) -from rhosocial.activerecord.testsuite.utils.expression import ( +from expression_roundtrip_harness import ( assert_params_equal, collect_expression_classes, make_instance, @@ -251,7 +271,6 @@ def _collect_matrix_classes(): names are keyed by the first name in sorted order, so a class aliased four ways is not tested four times. """ - ExpressionRegistry._auto_register_builtins() collected: Dict[str, type] = {} for package in (SNOWFLAKE_EXPR_PKG, CORE_EXPR_PKG): collected.update(collect_expression_classes(package)) @@ -267,8 +286,15 @@ def _collect_matrix_classes(): REGISTERED = _collect_matrix_classes() -# Deserialising a Snowflake expression needs it registered; nothing registers -# this package's classes on import. +# Deserialising a Snowflake expression needs its kind in the registry the read +# goes through, and the framework's default registry holds only the packages +# the framework itself ships -- nothing registers this backend's classes on +# import. This file is the reader, so it assembles what it collected into that +# default: every deserialize below then resolves every kind the matrix names. +# A kind two classes claim raises here, at assembly time, rather than +# resolving to the wrong class somewhere inside a round-trip. The package's +# enums ride inside artifacts under their short class names, which the +# whitelist resolves separately, so they are registered beside the classes. register_all(REGISTERED) @@ -307,7 +333,7 @@ def _column_predicate(dialect): def _one_column_query(dialect): """A single-column ``SELECT`` over a table.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id")], from_=_table_obj(dialect) ) @@ -324,6 +350,16 @@ def _integer_column(dialect, name="col"): def register_specials(): """Replace every introspective guess that would cost a real assertion.""" + # -- the predicate the whole file is built on ---------------------------- + # ``op`` normalises through the operator enum at construction, so the + # guess's bare ``"x"`` is refused there and the class reported as + # unbuildable. Two columns over a table is the shape every DDL position + # wants as well: it renders with no bind parameters at all. + register_special_constructor( + "predicates.ComparisonPredicate", + lambda d: _column_predicate(d), + ) + # -- the FROM side ------------------------------------------------------- register_special_constructor( @@ -342,100 +378,100 @@ def register_specials(): # -- tables and columns -------------------------------------------------- register_special_constructor( - "statements.ddl_table.CreateTableExpression", - lambda d: ddl_table.CreateTableExpression(d, _table_obj(d), [_integer_column(d)]), + "statements.ddl_table.CreateTableStatement", + lambda d: ddl_table.CreateTableStatement(d, _table_obj(d), [_integer_column(d)]), ) register_special_constructor( - "statements.ddl_table.DropTableExpression", - lambda d: ddl_table.DropTableExpression(d, _table_obj(d)), + "statements.ddl_table.DropTableStatement", + lambda d: ddl_table.DropTableStatement(d, _table_obj(d)), ) register_special_constructor( - "statements.ddl_table.CreateTableLikeExpression", - lambda d: ddl_table.CreateTableLikeExpression(d, _table_obj(d), Table(d, "other")), + "statements.ddl_table.CreateTableLikeStatement", + lambda d: ddl_table.CreateTableLikeStatement(d, _table_obj(d), Table(d, "other")), ) register_special_constructor( - "statements.ddl_table.CreateTableCloneExpression", - lambda d: ddl_table.CreateTableCloneExpression( + "statements.ddl_table.CreateTableCloneStatement", + lambda d: ddl_table.CreateTableCloneStatement( d, _table_obj(d), Table(d, "other") ), ) register_special_constructor( - "statements.ddl_table.CreateTableAsExpression", - lambda d: ddl_table.CreateTableAsExpression(d, _table_obj(d), _one_column_query(d)), + "statements.ddl_table.CreateTableAsStatement", + lambda d: ddl_table.CreateTableAsStatement(d, _table_obj(d), _one_column_query(d)), ) register_special_constructor( - "statements.ddl_table.CreateTableFromTemplateExpression", - lambda d: ddl_table.CreateTableFromTemplateExpression( + "statements.ddl_table.CreateTableFromTemplateStatement", + lambda d: ddl_table.CreateTableFromTemplateStatement( d, _table_obj(d), _one_column_query(d) ), ) register_special_constructor( - "statements.ddl_truncate.TruncateExpression", - lambda d: ddl_truncate.TruncateExpression(d, _table_obj(d)), + "statements.ddl_truncate.TruncateStatement", + lambda d: ddl_truncate.TruncateStatement(d, _table_obj(d)), ) # Overrides the shared testsuite factory, which binds no dialect to its type. register_special_constructor("statements.ddl_table.ColumnDefinition", _integer_column) # -- indexes ------------------------------------------------------------- register_special_constructor( - "statements.ddl_index.CreateIndexExpression", - lambda d: ddl_index.CreateIndexExpression( + "statements.ddl_index.CreateIndexStatement", + lambda d: ddl_index.CreateIndexStatement( d, index=Index(d, "i"), table=_table_obj(d), columns=["a"] ), ) register_special_constructor( - "statements.ddl_index.DropIndexExpression", - lambda d: ddl_index.DropIndexExpression(d, index=Index(d, "i")), + "statements.ddl_index.DropIndexStatement", + lambda d: ddl_index.DropIndexStatement(d, index=Index(d, "i")), ) register_special_constructor( - "statements.ddl_index.CreateFulltextIndexExpression", - lambda d: ddl_index.CreateFulltextIndexExpression( + "statements.ddl_index.CreateFulltextIndexStatement", + lambda d: ddl_index.CreateFulltextIndexStatement( d, index=Index(d, "i"), table=_table_obj(d), columns=["a"] ), ) register_special_constructor( - "statements.ddl_index.DropFulltextIndexExpression", - lambda d: ddl_index.DropFulltextIndexExpression( + "statements.ddl_index.DropFulltextIndexStatement", + lambda d: ddl_index.DropFulltextIndexStatement( d, index=Index(d, "i"), table=_table_obj(d) ), ) register_special_constructor( - "statements.ddl_alter.DropIndex", - lambda d: ddl_alter.DropIndex(d, Index(d, "i")), + "statements.ddl_alter.DropIndexAction", + lambda d: ddl_alter.DropIndexAction(d, Index(d, "i")), ) # -- schemas, sequences, databases, domains, types ----------------------- register_special_constructor( - "statements.ddl_schema.CreateSchemaExpression", - lambda d: ddl_schema.CreateSchemaExpression(d, Schema(d, "s")), + "statements.ddl_schema.CreateSchemaStatement", + lambda d: ddl_schema.CreateSchemaStatement(d, Schema(d, "s")), ) register_special_constructor( - "statements.ddl_schema.DropSchemaExpression", - lambda d: ddl_schema.DropSchemaExpression(d, Schema(d, "s")), + "statements.ddl_schema.DropSchemaStatement", + lambda d: ddl_schema.DropSchemaStatement(d, Schema(d, "s")), ) register_special_constructor( - "statements.ddl_sequence.CreateSequenceExpression", - lambda d: ddl_sequence.CreateSequenceExpression(d, Sequence(d, "s")), + "statements.ddl_sequence.CreateSequenceStatement", + lambda d: ddl_sequence.CreateSequenceStatement(d, Sequence(d, "s")), ) register_special_constructor( - "statements.ddl_sequence.AlterSequenceExpression", - lambda d: ddl_sequence.AlterSequenceExpression(d, Sequence(d, "s"), restart=1), + "statements.ddl_sequence.AlterSequenceStatement", + lambda d: ddl_sequence.AlterSequenceStatement(d, Sequence(d, "s"), restart=1), ) register_special_constructor( - "statements.ddl_sequence.DropSequenceExpression", - lambda d: ddl_sequence.DropSequenceExpression(d, Sequence(d, "s")), + "statements.ddl_sequence.DropSequenceStatement", + lambda d: ddl_sequence.DropSequenceStatement(d, Sequence(d, "s")), ) register_special_constructor( - "statements.ddl_database.CreateDatabaseExpression", - lambda d: ddl_database.CreateDatabaseExpression(d, Database(d, "db")), + "statements.ddl_database.CreateDatabaseStatement", + lambda d: ddl_database.CreateDatabaseStatement(d, Database(d, "db")), ) register_special_constructor( - "statements.ddl_database.DropDatabaseExpression", - lambda d: ddl_database.DropDatabaseExpression(d, Database(d, "db")), + "statements.ddl_database.DropDatabaseStatement", + lambda d: ddl_database.DropDatabaseStatement(d, Database(d, "db")), ) register_special_constructor( - "statements.ddl_database.AlterDatabaseExpression", - lambda d: ddl_database.AlterDatabaseExpression( + "statements.ddl_database.AlterDatabaseStatement", + lambda d: ddl_database.AlterDatabaseStatement( d, Database(d, "db"), action=AlterDatabaseAction.RENAME_TO, @@ -443,16 +479,16 @@ def register_specials(): ), ) register_special_constructor( - "statements.ddl_domain.CreateDomainExpression", - lambda d: ddl_domain.CreateDomainExpression(d, Domain(d, "dom"), IntegerType(d)), + "statements.ddl_domain.CreateDomainStatement", + lambda d: ddl_domain.CreateDomainStatement(d, Domain(d, "dom"), IntegerType(d)), ) register_special_constructor( - "statements.ddl_domain.DropDomainExpression", - lambda d: ddl_domain.DropDomainExpression(d, Domain(d, "dom")), + "statements.ddl_domain.DropDomainStatement", + lambda d: ddl_domain.DropDomainStatement(d, Domain(d, "dom")), ) register_special_constructor( - "statements.ddl_domain.AlterDomainExpression", - lambda d: ddl_domain.AlterDomainExpression( + "statements.ddl_domain.AlterDomainStatement", + lambda d: ddl_domain.AlterDomainStatement( d, Domain(d, "dom"), [RenameDomainAction(d, "other")] ), ) @@ -463,36 +499,36 @@ def register_specials(): lambda d: DomainCheckConstraint(d, _column_predicate(d), name="chk"), ) register_special_constructor( - "statements.ddl_type.CreateTypeExpression", - lambda d: ddl_type.CreateTypeExpression( + "statements.ddl_type.CreateTypeStatement", + lambda d: ddl_type.CreateTypeStatement( d, type=Type(d, "t"), definition=IntegerType(d) ), ) register_special_constructor( - "statements.ddl_type.AlterTypeExpression", - lambda d: ddl_type.AlterTypeExpression( + "statements.ddl_type.AlterTypeStatement", + lambda d: ddl_type.AlterTypeStatement( d, type=Type(d, "t"), actions=[SnowflakeSetTypeCommentAction(d, "a note")] ), ) register_special_constructor( - "statements.ddl_type.DropTypeExpression", - lambda d: ddl_type.DropTypeExpression(d, type=Type(d, "t")), + "statements.ddl_type.DropTypeStatement", + lambda d: ddl_type.DropTypeStatement(d, type=Type(d, "t")), ) # -- routines, triggers, comments ---------------------------------------- register_special_constructor( - "statements.ddl_function.CreateFunctionExpression", - lambda d: ddl_function.CreateFunctionExpression( + "statements.ddl_function.CreateFunctionStatement", + lambda d: ddl_function.CreateFunctionStatement( d, Function(d, "fn"), returns="integer", body="SELECT 1" ), ) register_special_constructor( - "statements.ddl_function.DropFunctionExpression", - lambda d: ddl_function.DropFunctionExpression(d, Function(d, "fn")), + "statements.ddl_function.DropFunctionStatement", + lambda d: ddl_function.DropFunctionStatement(d, Function(d, "fn")), ) register_special_constructor( - "statements.ddl_trigger.CreateTriggerExpression", - lambda d: ddl_trigger.CreateTriggerExpression( + "statements.ddl_trigger.CreateTriggerStatement", + lambda d: ddl_trigger.CreateTriggerStatement( d, trigger=Trigger(d, "trg"), table=_table_obj(d), @@ -502,48 +538,48 @@ def register_specials(): ), ) register_special_constructor( - "statements.ddl_trigger.DropTriggerExpression", - lambda d: ddl_trigger.DropTriggerExpression(d, trigger=Trigger(d, "trg")), + "statements.ddl_trigger.DropTriggerStatement", + lambda d: ddl_trigger.DropTriggerStatement(d, trigger=Trigger(d, "trg")), ) register_special_constructor( - "statements.ddl_comment.CommentOnExpression", - lambda d: ddl_comment.CommentOnExpression(d, "table", _table_obj(d), comment="c"), + "statements.ddl_comment.CommentOnStatement", + lambda d: ddl_comment.CommentOnStatement(d, "table", _table_obj(d), comment="c"), ) # -- views --------------------------------------------------------------- register_special_constructor( - "statements.ddl_view.DropViewExpression", - lambda d: ddl_view.DropViewExpression(d, View(d, "v")), + "statements.ddl_view.DropViewStatement", + lambda d: ddl_view.DropViewStatement(d, View(d, "v")), ) register_special_constructor( - "statements.ddl_view.CreateMaterializedViewExpression", - lambda d: ddl_view.CreateMaterializedViewExpression( + "statements.ddl_view.CreateMaterializedViewStatement", + lambda d: ddl_view.CreateMaterializedViewStatement( d, MaterializedView(d, "mv"), _one_column_query(d) ), ) register_special_constructor( - "statements.ddl_view.DropMaterializedViewExpression", - lambda d: ddl_view.DropMaterializedViewExpression(d, MaterializedView(d, "mv")), + "statements.ddl_view.DropMaterializedViewStatement", + lambda d: ddl_view.DropMaterializedViewStatement(d, MaterializedView(d, "mv")), ) register_special_constructor( - "statements.ddl_view.RefreshMaterializedViewExpression", - lambda d: ddl_view.RefreshMaterializedViewExpression(d, MaterializedView(d, "mv")), + "statements.ddl_view.RefreshMaterializedViewStatement", + lambda d: ddl_view.RefreshMaterializedViewStatement(d, MaterializedView(d, "mv")), ) # -- DML ----------------------------------------------------------------- register_special_constructor( - "statements.dml.InsertExpression", - lambda d: dml.InsertExpression( + "statements.dml.InsertStatement", + lambda d: dml.InsertStatement( d, into=_table_obj(d), source=dml.ValuesSource(d, [[Literal(d, 1)]]) ), ) register_special_constructor( - "statements.dml.DeleteExpression", - lambda d: dml.DeleteExpression(d, _table_obj(d)), + "statements.dml.DeleteStatement", + lambda d: dml.DeleteStatement(d, _table_obj(d)), ) register_special_constructor( - "statements.dml.MergeExpression", - lambda d: dml.MergeExpression( + "statements.dml.MergeStatement", + lambda d: dml.MergeStatement( d, target_table=_table_obj(d), source=NamedRelationRef(d, Table(d, "src")), @@ -573,12 +609,14 @@ def register_specials(): # -- constraints and column pieces --------------------------------------- register_special_constructor( "statements.ddl_table.ColumnConstraint", - lambda d: ColumnConstraint(d, ColumnConstraintType.NOT_NULL, name="c"), + lambda d: ColumnConstraint( + d, branch=ColumnNotNullConstraint(d), name="c" + ), ) register_special_constructor( "statements.ddl_table.TableConstraint", lambda d: TableConstraint( - d, TableConstraintType.PRIMARY_KEY, name="c", columns=["a"] + d, branch=TablePrimaryKeyConstraint(d, ["a"]), name="c" ), ) register_special_constructor( @@ -596,28 +634,107 @@ def register_specials(): lambda d: ReferencesClause(d, Table(d, "other"), ["b"]), ) register_special_constructor( - "statements.ddl_alter.AddColumn", - lambda d: ddl_alter.AddColumn(d, _integer_column(d)), + "statements.ddl_alter.AddColumnAction", + lambda d: ddl_alter.AddColumnAction(d, _integer_column(d)), ) register_special_constructor( - "statements.ddl_alter.ModifyColumn", - lambda d: ddl_alter.ModifyColumn(d, _integer_column(d)), + "statements.ddl_alter.ModifyColumnAction", + lambda d: ddl_alter.ModifyColumnAction(d, _integer_column(d)), ) register_special_constructor( - "statements.ddl_alter.AddIndex", - lambda d: ddl_alter.AddIndex(d, IndexDefinition(d, "i", ["a"])), + "statements.ddl_alter.AddIndexAction", + lambda d: ddl_alter.AddIndexAction(d, IndexDefinition(d, "i", ["a"])), ) register_special_constructor( - "statements.ddl_alter.AddTableConstraint", - lambda d: ddl_alter.AddTableConstraint( + "statements.ddl_alter.AddTableConstraintAction", + lambda d: ddl_alter.AddTableConstraintAction( d, TableConstraint( - d, TableConstraintType.PRIMARY_KEY, name="c", columns=["a"] + d, branch=TablePrimaryKeyConstraint(d, ["a"]), name="c" ), ), ) - # -- expressions that need at least one member -------------------------- + # -- declared-result annotations ---------------------------------------- + # An annotation renders what it annotates, and ``format_declared_result`` + # reads an alias off both the source and the annotation itself. The ten + # surface classes each gain one through their value-type base, so they + # render; the shared base ``_Annotation`` declares no alias and the + # formatter reads one anyway, which is pinned in LEGITIMATE_NON_RENDERS + # rather than papered over here. One aliasable source serves the base and + # every surface class. The guess hands the slot a ``Literal`` (from its + # "Expression"-shaped annotation), and a literal is the one thing in the + # tree with nothing to name. + from rhosocial.activerecord.backend.expression import declared as declared_mod + from rhosocial.activerecord.backend.expression.core import FunctionCall + + def _aliasable_source(d): + return FunctionCall(d, "FUNC", niladic=True) + + register_special_constructor( + "declared._Annotation", + lambda d: declared_mod._Annotation(d, _aliasable_source(d)), + ) + for _name in ( + "ArrayAnnotation", + "BinaryAnnotation", + "BooleanAnnotation", + "IntegerAnnotation", + "JSONAnnotation", + "NumberAnnotation", + "TemporalAnnotation", + "TextAnnotation", + "UUIDAnnotation", + "XMLAnnotation", + ): + register_special_constructor( + f"declared.{_name}", + lambda d, _n=_name: getattr(declared_mod, _n)(d, _aliasable_source(d)), + ) + + # -- predicates whose operands are value nodes -------------------------- + # A LIKE/ILIKE operand is a value node, and the introspective guess fills + # the slot with a bare string; the formatter then asks the string for + # ``to_sql()``. Core's own dummy2 matrix registers both, and core's + # wildcard split notes that these two need it, so the registration is + # copied rather than re-derived from the failure. + 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%"), + ) + + # -- XML PASSING ---------------------------------------------------------- + # The clause carries a list of value expressions; a bare "x" string is not + # one. Snowflake has no SQL/XML, so the class lands in the unmodelled table + # either way -- but it has to construct first for that answer to be reached. + register_special_constructor( + "xml.PassingClause", + lambda d: PassingClause(d, [Column(d, "a")]), + ) + + # -- closed word sets: the guess supplies a bare string ------------------- + # ``op`` on both nodes is a closed set checked at construction, so the + # introspective guess ("x" for every str-annotated parameter) raises + # ValueError before a formatter is ever 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")]), + ), + ) + +# -- expressions that need at least one member -------------------------- register_special_constructor( "advanced_functions.CaseExpression", lambda d: CaseExpression( @@ -700,7 +817,10 @@ def _path_pattern(d): lambda d: XMLConcatExpression(d, [Literal(d, "a"), Literal(d, "b")]), ) register_special_constructor( - "types.array.ArrayType", lambda d: ArrayType(d, VarCharType(d, 10)) + # element_type is keyword-only on the core class: a positional call + # is the pre-keyword spelling and no longer constructs. + "types.array.ArrayType", + lambda d: ArrayType(d, element_type=VarCharType(d, 10)), ) # -- a value the guess cannot know --------------------------------------- @@ -708,8 +828,8 @@ def _path_pattern(d): # the placeholder ``"x"``. This is the only parameter in the two packages # that is a string *belonging to a closed set* rather than free text. register_special_constructor( - "collation.CollateExpression", - lambda d: CollateExpression(d, Column(d, "name"), "en-ci"), + "collation.CollateFunction", + lambda d: CollateFunction(d, Column(d, "name"), "en-ci"), ) # -- Snowflake's own expressions ----------------------------------------- @@ -717,8 +837,8 @@ def _path_pattern(d): # guess skips it, or of a kind the guess fills with a bare string. All are # backend-owned, so their shape is this repository's business. register_special_constructor( - "ddl.materialized_view.SnowflakeCreateMaterializedViewExpression", - lambda d: SnowflakeCreateMaterializedViewExpression( + "ddl.materialized_view.SnowflakeCreateMaterializedViewStatement", + lambda d: SnowflakeCreateMaterializedViewStatement( d, "mv", as_query="SELECT 1" ), ) @@ -743,28 +863,28 @@ def _path_pattern(d): lambda d: SnowflakeExternalPartitionClause(d, [Column(d, "c1")]), ) register_special_constructor( - "ddl.pipe.SnowflakeCreatePipeExpression", - lambda d: SnowflakeCreatePipeExpression(d, "p", copy_sql="COPY INTO t FROM @s"), + "ddl.pipe.SnowflakeCreatePipeStatement", + lambda d: SnowflakeCreatePipeStatement(d, "p", copy_sql="COPY INTO t FROM @s"), ) register_special_constructor( - "ddl.stream.SnowflakeCreateStreamExpression", - lambda d: SnowflakeCreateStreamExpression(d, "s", object_name="t"), + "ddl.stream.SnowflakeCreateStreamStatement", + lambda d: SnowflakeCreateStreamStatement(d, "s", object_name="t"), ) register_special_constructor( - "ddl.file_format.SnowflakeAlterFileFormatExpression", - lambda d: SnowflakeAlterFileFormatExpression(d, "f", options={"TYPE": "CSV"}), + "ddl.file_format.SnowflakeAlterFileFormatStatement", + lambda d: SnowflakeAlterFileFormatStatement(d, "f", options={"TYPE": "CSV"}), ) register_special_constructor( - "ddl.stage.SnowflakeAlterStageExpression", - lambda d: SnowflakeAlterStageExpression(d, "s", url="s3://bucket/path"), + "ddl.stage.SnowflakeAlterStageStatement", + lambda d: SnowflakeAlterStageStatement(d, "s", url="s3://bucket/path"), ) register_special_constructor( "sample.SnowflakeSampleExpression", lambda d: SnowflakeSampleExpression(d, 10), ) register_special_constructor( - "show.SnowflakeShowExpression", - lambda d: SnowflakeShowExpression( + "show.SnowflakeShowStatement", + lambda d: SnowflakeShowStatement( d, SnowflakeShowObjectType.TABLES, in_scope=SnowflakeShowScope.DATABASE, limit=10, @@ -776,21 +896,86 @@ def _path_pattern(d): d, _table_obj(d), dml.ValuesSource(d, [[Literal(d, 1)]]) ), ) - # CUSTOM. Its ``raw`` slot exists to carry the SQL type name and defaults - # to the empty string, which the filler skips and the class refuses while - # constructing. Handed a real name it builds and renders, so it is a - # constructor rather than a skip -- and for that same reason it is absent - # from LEGITIMATE_NON_RENDERS, which would pin a rendering nothing could - # ask for. + # CUSTOM. Its ``raw`` slot carries the SQL type name; core made it a + # required keyword-only parameter (the empty-string default this factory + # used to lean on let a type with no name through, so there is no default + # left for the filler to skip). Handed a real name it builds and renders, + # so it is a constructor rather than a skip -- and for that same reason + # it is absent from LEGITIMATE_NON_RENDERS, which would pin a rendering + # nothing could ask for. def _custom_type(dialect): from rhosocial.activerecord.backend.expression.types.custom import ( CustomType, ) - return CustomType(dialect, "VARCHAR") + return CustomType(dialect, raw="VARCHAR") register_special_constructor("types.custom.CustomType", _custom_type) + # -- the one value node needs no constructor ---------------------------- + # + # A literal carries ``value`` and ``inline`` and nothing else. Its kind is + # read off the host value rather than declared, so the guess's "x" is a + # real ``string_value`` and the row is a real one; the other kinds are the + # same node with a different host value, which is what + # ``expression/test_snowflake_literal_values.py`` pins per kind. There is + # no class per kind left to register here, and no inline position either: + # the matrix exercises the bind path a data position takes. + + # -- the extract family's new member and the shape it shares ------------ + # + # ExtractFieldExpression is the field-named spelling (``YEAR(ts)``) the + # value wave split out of ExtractExpression; ``_ExtractBase`` is the + # field-and-source shape both spellings share. The guess cannot supply + # the field: its "x" is not a datetime field, and the class normalises + # the field before storing it. A real field over a column source is what + # both are constructed from, and both render through the same + # ``format_extract_expression`` the two classed spellings already did. + register_special_constructor( + "datetime.ExtractFieldExpression", + lambda d: ExtractFieldExpression(d, "YEAR", Column(d, "created_at")), + ) + register_special_constructor( + "datetime._ExtractBase", + lambda d: _ExtractBase(d, "YEAR", Column(d, "created_at")), + ) + + # -- the scalar string set: counts are ints, the guess's filler is not -- + # + # Each of these normalises its count operands through IntegerValue, and + # the guess hands a parameter it cannot classify the string "x" -- + # ``IntegerValue takes a int, not a str``. Real operands make each one + # render the spelling Snowflake documents: CHR, LEFT, RIGHT, SPACE, + # SUBSTRING (Snowflake's SUBSTR alias), OVERLAY. + register_special_constructor( + "functions.string.ChrFunction", + lambda d: ChrFunction(d, 65), + ) + register_special_constructor( + "functions.string.LeftFunction", + lambda d: LeftFunction(d, Column(d, "name"), 3), + ) + register_special_constructor( + "functions.string.RightFunction", + lambda d: RightFunction(d, Column(d, "name"), 3), + ) + register_special_constructor( + "functions.string.SpaceFunction", + lambda d: SpaceFunction(d, 4), + ) + register_special_constructor( + "functions.string.SubstringExpression", + lambda d: SubstringExpression( + d, Column(d, "name"), Literal(d, 2), Literal(d, 3) + ), + ) + register_special_constructor( + "functions.string.OverlayExpression", + lambda d: OverlayExpression( + d, Column(d, "name"), Literal(d, "ab"), Literal(d, 2), Literal(d, 3) + ), + ) + register_specials() @@ -818,19 +1003,16 @@ def _custom_type(dialect): #: object and handed the parameter a ``Sequence`` instead of a list of columns. #: The class was fine and the harness was wrong. It constructs now, and what it #: reports is a real answer: Snowflake has no SQL/XML at all, so the dispatch finds -#: no ``format_xmltable_expression``. That is why the method is in +#: no ``format_xml_table_expression``. That is why the method is in #: :data:`UNMODELLED_FORMATTERS` under SQL/XML rather than the class staying pinned #: here -- the matrix could not find that out while the class could not be built. UNCONSTRUCTIBLE = ( - # ALTER CONSTRAINT. ``name`` and ``constraint_type`` sit behind defaulted + # ALTER CONSTRAINT. ``name`` and ``constraint_kind`` 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. The same shape: the required ``name`` is keyword-only - # behind a defaulted positional. - "rhosocial.activerecord.backend.expression.statements.ddl_alter.ValidateConstraint", + "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 *string*: the annotation names a # ``DomainCheckConstraint``, whose ``\bDomain\b`` fragment does not match @@ -843,16 +1025,12 @@ def _custom_type(dialect): # be an ``UnsupportedFeatureError`` whatever it was built from. Building it # would buy no assertion. "rhosocial.activerecord.backend.expression.statements.ddl_domain.AddDomainCheckAction", - # ENUM type. Its ``values`` list is keyword-only behind a defaulted - # positional, so the constructor skips it and the type declares no members - # (``ValueError: EnumType requires values``). - "rhosocial.activerecord.backend.expression.types.enum_.EnumType", # The three UUID nodes refuse in __init__, not in to_sql(): Snowflake # spells no UUID SQL, so the answer is the same for every argument and no # registered constructor can change it. Measured with valid arguments -- # an invalid ``which`` raises ValueError first and hides the dialect's # answer, which is how the nil/max constant was first misread as rescuable. - "rhosocial.activerecord.backend.expression.uuid.UUIDCastExpression", + "rhosocial.activerecord.backend.expression.uuid.UUIDCastFunction", "rhosocial.activerecord.backend.expression.uuid.UUIDConstantExpression", "rhosocial.activerecord.backend.expression.uuid.UUIDGenerationExpression", ) @@ -876,7 +1054,7 @@ def _custom_type(dialect): #: graphs, no triggers, no DOMAIN" says what the dialect is. UNMODELLED_FORMATTERS = { # --- SQL/XML: Snowflake implements no part of SQL/XML ------------------- - # Thirteen. ``format_xmltable_expression`` is the one that arrived last and + # Thirteen. ``format_xml_table_expression`` is the one that arrived last and # the one worth explaining: ``XMLTableExpression`` was pinned in # UNCONSTRUCTIBLE until the testsuite's introspective constructor stopped # reading ``columns: Sequence[XMLTableColumn]`` as a request for a @@ -888,26 +1066,33 @@ def _custom_type(dialect): "SQL/XML (no XMLAGG, XMLATTRIBUTES, XMLCOMMENT, XMLCONCAT, XMLELEMENT, " "XMLEXISTS, XMLFOREST, XMLPARSE, XMLPI, XMLQUERY, XMLROOT, XMLSERIALIZE or " "XMLTABLE)": ( - "format_xmlagg_expression", - "format_xmlattributes_expression", - "format_xmlcomment_expression", - "format_xmlconcat_expression", - "format_xmlelement_expression", - "format_xmlexists_expression", - "format_xmlforest_expression", - "format_xmlparse_expression", + "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_xml_parse_expression", "format_xmlpi_expression", - "format_xmlquery_expression", - "format_xmlroot_expression", - "format_xmlserialize_expression", - "format_xmltable_expression", + "format_xml_query_expression", + "format_xml_root_expression", + "format_xml_serialize_expression", + "format_xml_table_expression", + "format_passing_clause", ), # --- SQL/PGQ property graphs: no graph tables, no MATCH --------------- + # ``format_edge_table_definition`` arrived when core split the edge table's + # declaration out of ``EdgeTable`` into its own ``EdgeTableDefinition`` + # node, which took the formatter need with it: the matrix's classes now + # ask for the definition node's method alone, and ``format_edge_table`` + # is no longer named by anything. The refusal itself is the one Snowflake + # has always given -- no property graphs, no MATCH. "SQL/PGQ property graphs (Snowflake has no GRAPH_TABLE and no Cypher)": ( "format_alter_property_graph_statement", "format_create_property_graph_statement", "format_drop_property_graph_statement", - "format_edge_table", + "format_edge_table_definition", "format_graph_columns_clause", "format_graph_edge", "format_graph_table_expression", @@ -938,7 +1123,7 @@ def _custom_type(dialect): # Snowflake *does* have procedures and functions, and it renders them -- but # through ``SnowflakeRoutineMixin`` and the ``SnowflakeRoutineSupport`` # protocol, whose expressions carry a bare name. The core SQL/PSM statements - # are not what Snowflake's formatters read. ``CreateFunctionExpression`` + # are not what Snowflake's formatters read. ``CreateFunctionStatement`` # is pinned separately below because it fails differently: this dialect's # override shadows the core formatter, so the dispatch finds a formatter, # calls it, and it raises reading an attribute the core statement lacks -- @@ -960,13 +1145,13 @@ def _custom_type(dialect): ), # --- PIVOT / UNPIVOT as core expressions ------------------------------ # Snowflake has PIVOT, and renders it -- through ``SnowflakePivotExpression`` - # and ``SnowflakePivotSupport``. The core ``PivotExpression`` names formatters - # this dialect does not declare, because the Snowflake clause is spelled from - # different fields. + # and ``SnowflakePivotSupport``. The core ``Pivot`` / ``Unpivot`` name + # formatters this dialect does not declare, because the Snowflake clause is + # spelled from different fields. "the core PIVOT / UNPIVOT expressions (Snowflake spells the clause from " "SnowflakePivotExpression instead)": ( - "format_pivot_expression", - "format_unpivot_expression", + "format_pivot", + "format_unpivot", ), # --- the root of the row-source tree ---------------------------------- # Every concrete row source declares its own ``format_method`` and overrides @@ -1056,11 +1241,23 @@ def _unmodelled_methods(): NotImplementedError, _NO_FORMAT_METHOD_NAME, ), - "rhosocial.activerecord.backend.expression.bases.SQLValueExpression": ( + # The shared base of the declared-result annotations (``as_text()`` and + # its siblings). Unlike the roots above it *names* a formatter -- the + # concrete annotations all render through ``format_declared_result`` -- + # but it carries no surface of its own, and that surface is where the + # alias the formatter reads comes from. So the base constructs and its + # render dies on the missing attribute, which is a fact about the base + # and not about the dialect: the concrete annotations are the renderable + # nodes, and each renders by delegating to the source it annotates. + "rhosocial.activerecord.backend.expression.declared._Annotation": ( + AttributeError, + "has no attribute 'alias'", + ), + "rhosocial.activerecord.backend.expression.bases.SQLValue": ( NotImplementedError, _NO_FORMAT_METHOD_NAME, ), - "rhosocial.activerecord.backend.expression.datetime._TemporalValueExpression": ( + "rhosocial.activerecord.backend.expression.datetime._TemporalValue": ( NotImplementedError, _NO_FORMAT_METHOD_NAME, ), @@ -1092,16 +1289,16 @@ def _unmodelled_methods(): NotImplementedError, _NO_FORMAT_METHOD_NAME, ), - "rhosocial.activerecord.backend.expression.transaction.TransactionExpression": ( + "rhosocial.activerecord.backend.expression.transaction.TransactionStatement": ( NotImplementedError, _NO_FORMAT_METHOD_NAME, ), # An introspection query base rather than a DDL or DML one: it names a # category ("ask the server about something") and each concrete subclass # supplies its own query. The message is the base's own, not the shared one. - "rhosocial.activerecord.backend.expression.introspection.TableInfoExpression": ( + "rhosocial.activerecord.backend.expression.introspection.TableInfoQueryStatement": ( NotImplementedError, - "Subclass must implement format_table_info_query", + "Subclass must implement format_table_info_query_statement", ), # ---- a protocol answering where a formatter belongs ------------------- # The root of the type tree answers ``format_domain_object`` through the @@ -1129,7 +1326,7 @@ def _unmodelled_methods(): ), # ---- the core routine statement under this dialect's override --------- # ``SnowflakeRoutineMixin`` overrides ``format_create_function_statement`` - # for ``SnowflakeCreateFunctionExpression``, which carries a bare ``name``. + # for ``SnowflakeCreateFunctionStatement``, which carries a bare ``name``. # The core statement carries a ``Function`` object and has no ``name``, so # the override -- which shadows the core formatter -- reads an attribute that # is not there. Pinned with its exact message so a change in what happens @@ -1142,18 +1339,39 @@ def _unmodelled_methods(): # declares a formatter for the core class and that formatter cannot render # it. Two different bugs would need two different fixes, and only one of them # is here. - "rhosocial.activerecord.backend.expression.statements.ddl_function.CreateFunctionExpression": ( # noqa: E501 + "rhosocial.activerecord.backend.expression.statements.ddl_function.CreateFunctionStatement": ( # noqa: E501 AttributeError, - "'CreateFunctionExpression' object has no attribute 'name'", + "'CreateFunctionStatement' object has no attribute 'name'", + ), + # --- the two constraint branch roots and the column-clause root --------- + # Each names a *level* rather than a renderable construct: the branch roots + # hold the per-kind nodes, and ``ColumnClause`` holds collation, identity, + # the inline comment and the like. They deliberately declare no + # ``format_method`` -- the shared getter raises -- so ``to_sql()`` reports + # that there is nothing to dispatch, which is the same answer every other + # abstract root in this table gives. Pinned per class so one of them + # starting to render fails here instead of passing quietly. + "rhosocial.activerecord.backend.expression.statements.ddl_table.ColumnClause": ( + NotImplementedError, + _NO_FORMAT_METHOD_NAME, + ), + "rhosocial.activerecord.backend.expression.statements.ddl_table.ColumnConstraintBranch": ( + NotImplementedError, + _NO_FORMAT_METHOD_NAME, + ), + "rhosocial.activerecord.backend.expression.statements.ddl_table.TableConstraintBranch": ( + NotImplementedError, + _NO_FORMAT_METHOD_NAME, ), # ---- the root of the type tree: incomplete rather than unsupported ---- # Every concrete type declares its own generic ``name``, which is what - # ``format_data_type`` dispatches on; the root declares none. TypeError, not - # UnsupportedFeatureError, because the class is incomplete rather than the - # dialect being unable. + # ``format_data_type`` dispatches on; the root declares none, so the + # dispatcher is asked for ``format_data_type_None`` and refuses by name. + # TypeError, not UnsupportedFeatureError, because the class is incomplete + # rather than the dialect being unable. "rhosocial.activerecord.backend.expression.types._base.DataType": ( TypeError, - "does not declare a valid generic type name", + _NO_GENERIC_TYPE, ), # ---- generic types Snowflake spells differently ----------------------- # Snowflake's data types carry their own names (``snowflake_varchar``, @@ -1177,6 +1395,14 @@ def _unmodelled_methods(): TypeError, _NO_GENERIC_TYPE, ), + # ENUM. MySQL/MariaDB native; core suggests text elsewhere, and this + # dialect's dispatcher says which spelling to use instead -- the refusal + # names VarCharType. It became reachable when the constructor started + # supplying a real values list. + "rhosocial.activerecord.backend.expression.types.enum_.EnumType": ( + TypeError, + _NO_GENERIC_TYPE, + ), "rhosocial.activerecord.backend.expression.types.json_.JsonBType": ( TypeError, _NO_GENERIC_TYPE, @@ -1192,6 +1418,218 @@ def _unmodelled_methods(): } +# --------------------------------------------------------------------------- +# The artifact gap: a parameter core's writer or reader cannot carry +# --------------------------------------------------------------------------- +# +# Every class is written and read back through the three channels -- dict, +# JSON and XML -- because an artifact that only survives one of them is an +# artifact with three incantations instead of one shape. What fails is core's +# own layer, and it fails in three distinguishable ways: +# +# * **the writer refuses the parameter** -- ``encode_artifact`` asks +# ``LiteralKind.of`` what kind a value belongs to, and that vocabulary is the +# closed set of *value* types. A dataclass is not in it and is refused by +# name. Every channel fails, including dict, because the failure is on the +# write. +# * **the constructor refuses its own wire form** -- the JSON and XML channels +# write the member's *value*, a plain string, and the constructor answers +# "must be a ". The artifact is unreadable by the node that produced it. +# * **the constructor does not normalise on read** -- same write, same read, +# and the value survives while the host type does not: the parameter comes +# back as a ``str`` where an enum member went in. ``assert_params_equal`` +# reports it as a type mismatch naming the member type. +# +# A fourth block used to be here: the XML channel spelled a ``str``-enum member +# as ``str(member)`` -- ``"DateTimeField.YEAR"`` rather than ``"year"`` -- so +# the reader handed the constructor a word that is neither a member's value nor +# a member's name. That was the missing ``__str__ = str.__str__`` on nine +# ``(str, Enum)`` declarations in core, and core has since added it; the nine +# datetime/interval rows it produced are gone, and every one of those classes +# now round-trips through all three channels. For the same reason the bare +# ``Enum`` spellings that made the writer reject an enum outright +# (``GeneratedColumnType``, ``ReferentialAction``, ``TriggerEvent`` and the +# rest) are ``(str, Enum)`` now, so three rows moved from "the writer refuses" +# to "the constructor does not normalise", which is what they measure today. +# +# The writer and the reader are core's, so no row here can be fixed from a +# backend repository. Each row pins exactly the channel(s) that fail and the +# witness they fail with, so that the day core fixes one, +# ``test_pinned_serialization_gap_really_fails`` reports the row as stale and +# it is deleted. A class that starts failing *without* a row fails its own +# assertion instead. When the last row goes, this table, its guards and the +# branches that read it go with it. +# +# This backend's own enums cannot appear in any of the three blocks: they +# inherit ``str`` and their constructors normalise the word back through +# ``expression/coercion.as_member``, which is why no row names a +# ``Snowflake*`` enum. +# +# Shape: ``fqn -> (channels, witness)``. ``channels`` is the tuple, in +# ``("dict", "json", "xml")`` order, of the channels that cannot round-trip; +# every channel not named must round-trip. ``witness`` is either a +# ``(exception type, message fragment)`` pair -- the channel raises it -- or a +# string, which is the fragment of the parameter comparison the channel fails +# instead. +SERIALIZATION_GAP = { + # ---- the writer refuses the parameter (all three channels) ------------- + "rhosocial.activerecord.backend.expression.graph.GraphTableExpression": ( + ("dict", "json", "xml"), + (TypeError, "GraphColumn is not one of the value kinds"), + ), + "rhosocial.activerecord.backend.expression.statements.ddl_view.CreateViewStatement": ( + ("dict", "json", "xml"), + (TypeError, "ViewOptions is not one of the value kinds"), + ), + "rhosocial.activerecord.backend.expression.xml.XMLAttributesExpression": ( + ("dict", "json", "xml"), + (TypeError, "XMLAttribute is not one of the value kinds"), + ), + "rhosocial.activerecord.backend.expression.xml.XMLForestExpression": ( + ("dict", "json", "xml"), + (TypeError, "XMLForestItem is not one of the value kinds"), + ), + # ---- the constructor refuses its own wire form (json and xml) ---------- + "rhosocial.activerecord.backend.expression.statements.ddl_table.CreateTableCloneStatement": ( + ("json", "xml"), + (TypeError, "mode must be a CreateTableCloneMode"), + ), + # ---- the constructor does not normalise on read (json and xml) --------- + # The value survives and the host type does not, so the witness is the + # comparison the harness reports rather than an exception. + "rhosocial.activerecord.backend.expression.statements.ddl_table.ColumnGeneratedConstraint": ( + ("json", "xml"), + "type mismatch str vs GeneratedColumnType", + ), + "rhosocial.activerecord.backend.expression.statements.ddl_table.ForeignKeyConstraint": ( + ("json", "xml"), + "type mismatch str vs ReferentialAction", + ), + "rhosocial.activerecord.backend.expression.statements.ddl_trigger.CreateTriggerStatement": ( + ("json", "xml"), + "type mismatch str vs TriggerTiming", + ), + "rhosocial.activerecord.backend.expression.graph.GraphEdge": ( + ("json", "xml"), + "type mismatch str vs GraphEdgeDirection", + ), + "rhosocial.activerecord.backend.expression.graph.QuantifiedPath": ( + ("json", "xml"), + "type mismatch str vs GraphEdgeDirection", + ), + "rhosocial.activerecord.backend.expression.statements.ddl_database.AlterDatabaseStatement": ( + ("json", "xml"), + "type mismatch str vs AlterDatabaseAction", + ), + "rhosocial.activerecord.backend.expression.statements.dml.MergeAction": ( + ("json", "xml"), + "type mismatch str vs MergeActionType", + ), + "rhosocial.activerecord.backend.expression.statements.dml.MergeStatement": ( + ("json", "xml"), + "type mismatch str vs MergeActionType", + ), + "rhosocial.activerecord.backend.expression.xml.XMLParseExpression": ( + ("json", "xml"), + "type mismatch str vs XMLParseDocumentType", + ), + "rhosocial.activerecord.backend.expression.xml.XMLSerializeExpression": ( + ("json", "xml"), + "type mismatch str vs XMLSerializeDocumentType", + ), +} + +_CHANNELS = ("dict", "json", "xml") + + +def _restore_channel(channel, instance, dialect): + """Serialize *instance* and read it back through one channel.""" + if channel == "dict": + return deserialize(serialize(instance), dialect) + if channel == "json": + return deserialize_json(serialize_json(instance), dialect) + return deserialize_xml(serialize_xml(instance), dialect) + + +def _run_channel(fqn, channel, instance, dialect): + """One channel's round-trip, as an outcome a caller can classify. + + Returns ``("ok", restored)``, ``("raises", exception)`` or + ``("degrades", message)``. A channel that is not named in a class's + :data:`SERIALIZATION_GAP` row must return the first; a channel that is + named must return one of the other two, and the row's witness says which + and what it must look like. + """ + try: + restored = _restore_channel(channel, instance, dialect) + except Exception as exc: # noqa: BLE001 -- classified by the caller + return "raises", exc + try: + assert_params_equal(restored.get_params(), instance.get_params(), fqn) + except AssertionError as exc: + return "degrades", str(exc) + return "ok", restored + + +def _assert_roundtrip_or_gap(fqn, instance, dialect): + """Assert all three channels, against the pinned gap table. + + Returns ``{channel: restored}`` for the channels that round-tripped. A + channel whose class has no pinned gap must round-trip, and a pinned + channel must fail exactly the way its witness says -- so neither a new + failure nor a stale row can pass quietly. + """ + gap = SERIALIZATION_GAP.get(fqn) + restored_ok = {} + failing = [] + for channel in _CHANNELS: + outcome, detail = _run_channel(fqn, channel, instance, dialect) + if outcome == "ok": + restored_ok[channel] = detail + continue + failing.append(channel) + what = ( + f"raised {type(detail).__name__}: {detail}" + if outcome == "raises" + else f"lost a parameter type: {detail}" + ) + assert gap is not None, ( + f"{fqn}: the {channel} channel {what}, and the class is not in " + f"SERIALIZATION_GAP. If core's writer or reader learned to carry " + f"the parameter, the row is no longer needed; if it has not, the " + f"class needs a row naming the channel and the witness." + ) + channels, witness = gap + assert channel in channels, ( + f"{fqn}: the {channel} channel failed but the pinned gap names " + f"{channels}. Either the pin is wrong about which channels fail, or " + f"core's behaviour changed and the row must be re-measured." + ) + if isinstance(witness, tuple): + expected_type, fragment = witness + assert outcome == "raises", ( + f"{fqn}: the pinned gap says the {channel} channel raises " + f"{expected_type.__name__} saying {fragment!r}, but it {what}." + ) + assert type(detail) is expected_type and fragment in str(detail), ( + f"{fqn}: the {channel} channel was pinned to raise " + f"{expected_type.__name__} saying {fragment!r}, but it {what}." + ) + else: + assert outcome == "degrades" and witness in str(detail), ( + f"{fqn}: the pinned gap says the {channel} channel loses the " + f"parameter type reported as {witness!r}, but it {what}." + ) + if gap is not None: + channels, _witness = gap + assert set(failing) == set(channels), ( + f"{fqn}: the pinned gap names {channels} as the channels that fail, " + f"but {failing} failed. A row that names a channel which now works " + f"is stale and must go." + ) + return restored_ok + + # --------------------------------------------------------------------------- # The local SQL assertion: classify the outcome instead of swallowing it # --------------------------------------------------------------------------- @@ -1274,11 +1712,17 @@ def assert_sql_roundtrip_classified(fqn, instance, dialect): ) expected_sql, expected_params = rendered - for channel, decoded in ( - ("dict", deserialize(serialize(instance), dialect)), - ("json", deserialize_json(serialize_json(instance), dialect)), - ("xml", deserialize_xml(serialize_xml(instance), dialect)), - ): + # Only the channels that carry this class's parameters intact can be asked + # for SQL: a channel that loses a parameter type or refuses it on the write + # is already recorded in ``SERIALIZATION_GAP``, and re-deriving SQL from a + # node whose parameter never arrived would fail for the pinned reason + # instead of asserting anything about the rendering. + gap = SERIALIZATION_GAP.get(fqn) + whole = set(_CHANNELS) - set(gap[0] if gap else ()) + for channel in _CHANNELS: + if channel not in whole: + continue + decoded = _restore_channel(channel, instance, dialect) decoded_sql, decoded_params = decoded.to_sql() assert decoded_sql == expected_sql, ( f"{fqn}: {channel} round-trip changed the SQL.\n" @@ -1343,17 +1787,15 @@ class TestExpressionRoundtripAll: """All constructible expression classes round-trip through all encodings.""" def test_get_params_roundtrip_across_encodings(self, expr_case, dialect): - fqn, instance = expr_case - original = instance.get_params() - - restored_dict = deserialize(serialize(instance), dialect) - assert_params_equal(restored_dict.get_params(), original, fqn) - - restored_json = deserialize_json(serialize_json(instance), dialect) - assert_params_equal(restored_json.get_params(), original, fqn) + """Every parameter survives all three channels -- or its gap is pinned. - restored_xml = deserialize_xml(serialize_xml(instance), dialect) - assert_params_equal(restored_xml.get_params(), original, fqn) + A channel that fails for a class with no row in + :data:`SERIALIZATION_GAP` fails here, and so does a row that names a + channel which now round-trips. See the table's comment for what the + rows are and whose defect they record. + """ + fqn, instance = expr_case + _assert_roundtrip_or_gap(fqn, instance, dialect) def test_to_sql_roundtrip_classified(self, expr_case, dialect): """A render must survive the round-trip; a non-render must be classified.""" @@ -1411,6 +1853,52 @@ def test_pinned_entries_are_real_classes(self): assert not unknown, ( f"LEGITIMATE_NON_RENDERS names uncollected classes: {sorted(unknown)}" ) + unknown = set(SERIALIZATION_GAP) - collected + assert not unknown, ( + f"SERIALIZATION_GAP names uncollected classes: {sorted(unknown)}" + ) + + def test_pinned_serialization_gap_really_fails(self, dialect): + """Each gap row still fails, on the channels it names, for that reason. + + Without this, a row could sit in the table for a class that now + round-trips perfectly well, and the artifact coverage it claims to + describe would be a stale statement rather than a measurement. The day + core's writer or reader fixes one of these, this test says so and the + row is deleted. + """ + for fqn, (channels, witness) in SERIALIZATION_GAP.items(): + instance, source = make_instance(REGISTERED[fqn], dialect) + assert instance is not None, ( + f"{fqn} is pinned in SERIALIZATION_GAP but could not be " + f"constructed ({source})" + ) + for channel in _CHANNELS: + outcome, detail = _run_channel(fqn, channel, instance, dialect) + if channel not in channels: + assert outcome == "ok", ( + f"{fqn}: the {channel} channel is not named in the gap " + f"row, so it must round-trip, but it " + f"{'raised' if outcome == 'raises' else 'degraded'}: {detail}" + ) + continue + assert outcome != "ok", ( + f"{fqn}: the gap row says the {channel} channel fails, but " + f"it round-tripped. Core may have fixed it -- delete the row." + ) + if isinstance(witness, tuple): + expected_type, fragment = witness + assert type(detail) is expected_type and fragment in str(detail), ( + f"{fqn}: the {channel} channel was pinned to raise " + f"{expected_type.__name__} saying {fragment!r}, but it " + f"raised {type(detail).__name__}: {detail}" + ) + else: + assert outcome == "degrades" and witness in str(detail), ( + f"{fqn}: the {channel} channel was pinned to lose the " + f"parameter type reported as {witness!r}, but it " + f"{outcome}: {detail}" + ) def test_unmodelled_formatter_list_is_exact(self, dialect): """Pin the unmodelled-formatter table against what the dialect really lacks. @@ -1481,7 +1969,6 @@ class that appeared after import is caught. The walk is used rather than the registry because the registry also holds whatever backends other test modules happened to import. """ - ExpressionRegistry._auto_register_builtins() expected = set(_collect_matrix_classes()) assert expected == set(REGISTERED), ( "the set of classes these packages define changed after collection.\n" @@ -1524,12 +2011,16 @@ def test_each_package_is_actually_covered(self): def test_every_covered_class_is_registered_for_deserialization(self): """A class in the matrix can be found again when deserializing. - Deserialisation 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. + Deserialisation resolves a kind through the registry this file + assembled at import, so a class the matrix renders but that registry + cannot resolve would round-trip into nothing at all. Assembly would + already have raised on a kind two classes claim; this pins that every + collected class's own kind is in the registry at all. """ - ExpressionRegistry._auto_register_builtins() - unresolved = sorted(set(REGISTERED) - set(ExpressionRegistry._registry)) + kinds = set(default_registry().node_kinds()) + unresolved = sorted( + fqn for fqn, cls in REGISTERED.items() if cls.kind() not in kinds + ) assert not unresolved, ( f"the matrix covers classes the registry cannot resolve: {unresolved}" ) @@ -1587,7 +2078,6 @@ def test_row_sources_are_reached(self, dialect): def test_coverage_report(self, dialect): """Surface the matrix's classification, so coverage stays transparent.""" - ExpressionRegistry._auto_register_builtins() tally = {} constructible = 0 for fqn in sorted(REGISTERED): @@ -1611,3 +2101,5 @@ def test_coverage_report(self, dialect): print(f" not modelled: {feature} ({len(methods)} formatters)") for fqn in sorted(LEGITIMATE_NON_RENDERS): print(f" non-render: {fqn}") + for fqn, (channels, _witness) in sorted(SERIALIZATION_GAP.items()): + print(f" artifact gap: {fqn} ({', '.join(channels)})") diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/expression/test_expression_signatures.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/expression/test_expression_signatures.py index 2b3a60b..908ab45 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/expression/test_expression_signatures.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/expression/test_expression_signatures.py @@ -8,10 +8,10 @@ from rhosocial.activerecord.backend.impl.snowflake.dialect import SnowflakeDialect from rhosocial.activerecord.backend.impl.snowflake.expression.ddl.warehouse import ( - SnowflakeCreateWarehouseExpression, - SnowflakeAlterWarehouseExpression, + SnowflakeCreateWarehouseStatement, + SnowflakeAlterWarehouseStatement, SnowflakeAlterWarehouseMode, - SnowflakeDropWarehouseExpression, + SnowflakeDropWarehouseStatement, ) from rhosocial.activerecord.backend.impl.snowflake.expression.ddl.warehouse_options import ( SnowflakeWarehouseOptionsExpression, @@ -21,7 +21,7 @@ SnowflakeVariantCastExpression, ) from rhosocial.activerecord.backend.impl.snowflake.expression.ddl.undrop import ( - SnowflakeUndropExpression, + SnowflakeUndropStatement, SnowflakeUndropObjectType, ) @@ -32,15 +32,15 @@ def dialect(): class TestCreateWarehouseExpression: - """Tests for SnowflakeCreateWarehouseExpression.""" + """Tests for SnowflakeCreateWarehouseStatement.""" def test_create_warehouse_basic(self, dialect): - expr = SnowflakeCreateWarehouseExpression(dialect, "my_wh") + expr = SnowflakeCreateWarehouseStatement(dialect, "my_wh") sql, _ = expr.to_sql() assert sql == 'CREATE WAREHOUSE "my_wh"' def test_create_warehouse_with_options(self, dialect): - expr = SnowflakeCreateWarehouseExpression( + expr = SnowflakeCreateWarehouseStatement( dialect, "my_wh", or_replace=True, @@ -59,17 +59,17 @@ def test_create_warehouse_with_options(self, dialect): class TestAlterWarehouseExpression: - """Tests for SnowflakeAlterWarehouseExpression.""" + """Tests for SnowflakeAlterWarehouseStatement.""" def test_alter_warehouse_suspend(self, dialect): - expr = SnowflakeAlterWarehouseExpression( + expr = SnowflakeAlterWarehouseStatement( dialect, "my_wh", mode=SnowflakeAlterWarehouseMode.SUSPEND ) sql, _ = expr.to_sql() assert sql == 'ALTER WAREHOUSE "my_wh" SUSPEND' def test_alter_warehouse_set(self, dialect): - expr = SnowflakeAlterWarehouseExpression( + expr = SnowflakeAlterWarehouseStatement( dialect, "my_wh", mode=SnowflakeAlterWarehouseMode.SET, @@ -80,15 +80,15 @@ def test_alter_warehouse_set(self, dialect): class TestDropWarehouseExpression: - """Tests for SnowflakeDropWarehouseExpression.""" + """Tests for SnowflakeDropWarehouseStatement.""" def test_drop_warehouse_basic(self, dialect): - expr = SnowflakeDropWarehouseExpression(dialect, "my_wh") + expr = SnowflakeDropWarehouseStatement(dialect, "my_wh") sql, _ = expr.to_sql() assert sql == 'DROP WAREHOUSE "my_wh"' def test_drop_warehouse_if_exists(self, dialect): - expr = SnowflakeDropWarehouseExpression( + expr = SnowflakeDropWarehouseStatement( dialect, "my_wh", if_exists=True ) sql, _ = expr.to_sql() @@ -163,15 +163,15 @@ def test_variant_cast_string(self, dialect): class TestUndropExpression: - """Tests for SnowflakeUndropExpression.""" + """Tests for SnowflakeUndropStatement.""" def test_undrop_table(self, dialect): - expr = SnowflakeUndropExpression(dialect, "my_table") + expr = SnowflakeUndropStatement(dialect, "my_table") sql, _ = expr.to_sql() assert sql == 'UNDROP TABLE "my_table"' def test_undrop_schema(self, dialect): - expr = SnowflakeUndropExpression( + expr = SnowflakeUndropStatement( dialect, "my_schema", object_type=SnowflakeUndropObjectType.SCHEMA ) sql, _ = expr.to_sql() diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/expression/test_kind_lineage.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/expression/test_kind_lineage.py new file mode 100644 index 0000000..aa30452 --- /dev/null +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/expression/test_kind_lineage.py @@ -0,0 +1,205 @@ +# tests/rhosocial/activerecord_snowflake_test/feature/backend/expression/test_kind_lineage.py +"""The kind audit, as an assertion rather than a paragraph. + +``expression-kinds.md`` decides a backend node's relation to core by one +question: *does core already ship this kind?* Two rules follow, and both are +mechanical enough to pin here: + +* **Core's kind may be re-rendered, never re-classed.** A backend may override + ``format_``; it may not put a second class behind the same kind, because + an artifact names a kind and nothing else, so two classes under one kind leave + the reader no way to choose (rules §7, §10). +* **An override must be reachable.** A mixin placed after the class it + overrides never wins (§11.1), and a ``format_method`` naming a method the + dialect does not have is a formatter that can never run. + +The measured inventory this file pins, read off the running code rather than +maintained by hand: + +========================= ===== +core kinds 357 +snowflake override 0 +snowflake custom 33 +========================= ===== + +The 33 are the nodes that declare a ``format_method``; the rest of +``impl.snowflake.expression`` renders through its own ``to_sql()`` and so has no +kind to register yet. That split is recorded here rather than changed: it is +the same shape MariaDB's audit recorded for its SHOW family, and converting +those nodes is a rename of their dialect entries, not an audit finding. + +Generated with the core script on a temporary copy whose ``BACKENDS`` names this +package and ``SnowflakeDialect`` -- registering a backend in +``scripts/expression_lineage.py`` is a change to the core repository, so it is +reported rather than committed here. +""" + +import importlib +import inspect +import pkgutil + +import pytest + +from rhosocial.activerecord.backend.dialect.base import SQLDialectBase +from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.impl.snowflake.dialect import SnowflakeDialect + +CORE_EXPR_PKG = "rhosocial.activerecord.backend.expression" +SNOWFLAKE_EXPR_PKG = "rhosocial.activerecord.backend.impl.snowflake.expression" + + +def _classes(package_path): + """Every concrete expression class a package defines, by object identity.""" + pkg = importlib.import_module(package_path) + found = {} + for _, modname, _ in pkgutil.walk_packages( + pkg.__path__, pkg.__name__ + ".", onerror=lambda x: None + ): + try: + module = importlib.import_module(modname) + except Exception: # noqa: BLE001 -- an unimportable module defines nothing + continue + for name in dir(module): + obj = getattr(module, name, None) + if ( + isinstance(obj, type) + and obj.__module__ == modname + and issubclass(obj, BaseExpression) + and obj is not BaseExpression + and not inspect.isabstract(obj) + ): + found.setdefault(obj, f"{modname}.{name}") + return found + + +def _declares_format_method(cls): + """Whether *cls* itself (not a base) declares the formatter name.""" + return any("format_method" in vars(klass) for klass in cls.__mro__[:-1]) + + +def _format_method_name(cls): + """The string ``format_method`` answers, or ``None`` when it answers none. + + ``format_method`` is a ``property``, so reading it off the *class* hands + back the descriptor. ``__new__`` without ``__init__`` is enough: every + implementation returns a literal or reads ``self.name``, which is a class + attribute on the type nodes. + """ + try: + return cls.format_method.fget(cls.__new__(cls)) + except NotImplementedError: + return None + + +@pytest.fixture(scope="module") +def dialect(): + return SnowflakeDialect() + + +@pytest.fixture(scope="module") +def core_kinds(): + """kind -> class name for everything core ships, minus the one value node.""" + kinds = {} + for cls, fqn in _classes(CORE_EXPR_PKG).items(): + if issubclass(cls, Literal): + continue # one class carries every value kind + kinds[cls.kind()] = fqn + return kinds + + +@pytest.fixture(scope="module") +def backend_classes(): + return _classes(SNOWFLAKE_EXPR_PKG) + + +def test_core_ships_the_357_kinds_the_audit_counted(core_kinds): + """The denominator, so a core change that moves it is visible here. + + Not an equality on 357 -- core keeps growing, and a new node must not fail + this file. The floor says the walk still reaches core's package; the audit's + number is the one measured on this branch. + """ + assert len(core_kinds) >= 350, ( + f"only {len(core_kinds)} core kinds collected; the package walk may have " + f"stopped early" + ) + + +def test_no_backend_class_replaces_a_core_kind(backend_classes, core_kinds): + """Core's kind may be re-rendered, never re-classed (rules §7, §10). + + The audit's headline number: **zero** overrides. That is the only shape + that keeps an artifact readable -- a kind resolves to one class, always. + A class that genuinely specialises a core node inherits it and carries its + own kind (``SnowflakeCreateMaterializedViewStatement`` over + ``CreateMaterializedViewStatement``), which is what makes this zero rather + than a rule the backend happens to dodge. + """ + replaced = { + cls.kind(): (fqn, core_kinds[cls.kind()]) + for cls, fqn in backend_classes.items() + if cls.kind() in core_kinds + } + assert not replaced, ( + "these classes claim a kind core already ships, which would leave one " + f"kind resolving to two classes: {replaced}" + ) + + +def test_every_declared_formatter_exists_on_the_dialect(dialect, backend_classes): + """A ``format_method`` naming a method the dialect lacks can never run. + + ``to_sql()`` reports such a gap as ``UnsupportedFeatureError`` at call time; + this asks the same question once, over the whole package, so a rename that + orphans a formatter fails here instead of in whichever test happened to + render that node. + """ + missing = [] + for cls, fqn in backend_classes.items(): + method = _format_method_name(cls) + if method is None: + continue + if not isinstance(method, str) or not method: + missing.append((fqn, method)) + elif not callable(getattr(dialect, method, None)): + missing.append((fqn, method)) + assert not missing, ( + "these classes declare a formatting entry the dialect does not have: " + f"{sorted(missing)}" + ) + + +def test_a_backends_kind_carries_the_backend_name_first(backend_classes): + """§3: the backend name goes first -- never last, never in the middle. + + Every custom kind here is spelled ``snowflake_``, and the check is + on the prefix rather than on a list of the 33, so a node added later is + covered without a second edit. + """ + misfiled = sorted( + fqn + for cls, fqn in backend_classes.items() + if _declares_format_method(cls) and not cls.kind().startswith("snowflake_") + ) + assert not misfiled, ( + f"these kinds do not name their backend first: {misfiled}" + ) + + +def test_the_literal_mixin_precedes_the_class_it_overrides(): + """§11.1, for the one mixin this release added. + + ``SnowflakeLiteralValueMixin`` replaces ``SQLDialectBase.format_literal``. + A base list is the MRO, so the overriding mixin must sit ahead of -- a lower + index than -- ``SQLDialectBase``; placed after it, the override is never + asked and every inline literal renders with the base's spellings. + """ + from rhosocial.activerecord.backend.impl.snowflake.mixins.literal import ( + SnowflakeLiteralValueMixin, + ) + + mro = SnowflakeDialect.__mro__ + assert mro.index(SnowflakeLiteralValueMixin) < mro.index(SQLDialectBase), ( + "SnowflakeLiteralValueMixin must precede SQLDialectBase in the base list" + ) diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/expression/test_snowflake_literal_values.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/expression/test_snowflake_literal_values.py new file mode 100644 index 0000000..02d4c62 --- /dev/null +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/expression/test_snowflake_literal_values.py @@ -0,0 +1,180 @@ +# tests/rhosocial/activerecord_snowflake_test/feature/backend/expression/test_snowflake_literal_values.py +"""Snowflake's spellings for the value literals -- construction and rendering +only. + +Core's one literal node owns the binding decision: ``Literal(d, value)`` binds +the host value behind a placeholder, and ``Literal(d, value, inline=True)`` -- +what a DDL syntax position passes, the one that accepts no bind parameters -- +writes the value as SQL text through the dialect's controlled literal +construction. Snowflake's own spellings live in the second tier of that +construction, ``SnowflakeLiteralValueMixin.format_literal``, and everything +else falls through to the base. + +**not server-verified.** There is no Snowflake instance reachable from this +repository; every assertion below is a rendering assertion over grammar +transcribed from the official documentation, never a measurement: + + https://docs.snowflake.com/en/sql-reference/functions/to_binary + https://docs.snowflake.com/en/sql-reference/binary-input-output + https://docs.snowflake.com/en/sql-reference/data-types-uuid + https://docs.snowflake.com/en/sql-reference/data-types-numeric + https://docs.snowflake.com/en/sql-reference/data-types-text + https://docs.snowflake.com/en/sql-reference/data-types-datetime +""" + +import datetime +import decimal +import uuid + +import pytest + +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.impl.snowflake.dialect import SnowflakeDialect + + +@pytest.fixture +def dialect(): + return SnowflakeDialect() + + +class TestBinarySpelling: + """``TO_BINARY('0102', 'HEX')`` -- the encoding named, not assumed.""" + + def test_data_position_binds_the_bytes(self, dialect): + sql, params = Literal(dialect, b"\x01\x02").to_sql() + assert sql == "%s" + assert params == (b"\x01\x02",) + + def test_inline_position_names_hex(self, dialect): + sql, params = Literal(dialect, b"\x01\x02", inline=True).to_sql() + assert sql == "TO_BINARY('0102', 'HEX')" + assert params == () + + def test_the_digits_are_uppercase_like_snowflakes_own_output(self, dialect): + sql, _ = Literal(dialect, b"\xab\xcd\xef", inline=True).to_sql() + assert sql == "TO_BINARY('ABCDEF', 'HEX')" + + def test_an_empty_value_is_the_empty_literal(self, dialect): + sql, _ = Literal(dialect, b"", inline=True).to_sql() + assert sql == "TO_BINARY('', 'HEX')" + + def test_bytearray_is_spelled_from_the_bytes_it_holds(self, dialect): + sql, params = Literal(dialect, bytearray(b"\x01"), inline=True).to_sql() + assert sql == "TO_BINARY('01', 'HEX')" + assert params == () + + +class TestUUIDSpelling: + """The bare quoted canonical text -- the ANSI literal form.""" + + CANONICAL = "c73d9175-0a1d-48c6-8d30-df165461328b" + + def test_data_position_binds_the_uuid(self, dialect): + value = uuid.UUID(self.CANONICAL) + sql, params = Literal(dialect, value).to_sql() + assert sql == "%s" + assert params == (value,) + + def test_inline_position_is_the_quoted_canonical_text(self, dialect): + sql, params = Literal(dialect, uuid.UUID(self.CANONICAL), inline=True).to_sql() + assert sql == f"'{self.CANONICAL}'" + assert params == () + + +class TestIntervalStaysRefused: + """No ``timedelta`` branch in this dialect's ``format_literal``, on purpose. + + The interval *constant* is a date-arithmetic operand that Snowflake + refuses to store in an interval column, and the interval *data type* + takes a string whose shape depends on the column's qualifier, which a + value node does not carry -- so the base's explicit ``TypeError`` is the + answer, and the structural absence is asserted rather than papered over. + """ + + def test_data_position_still_binds(self, dialect): + span = datetime.timedelta(days=2, hours=3) + sql, params = Literal(dialect, span).to_sql() + assert sql == "%s" + assert params == (span,) + + def test_inline_position_keeps_the_base_refusal(self, dialect): + with pytest.raises(TypeError, match="has no inline SQL literal form"): + Literal(dialect, datetime.timedelta(days=1), inline=True).to_sql() + + +class TestTierOneAgreesWithTheBase: + """The audit: nothing overridden for the kinds Snowflake spells like core. + + Each assertion is the base's spelling reached through this dialect's + ``format_literal``, pinned here so a base change that breaks Snowflake's + grammar is caught by this file rather than in the matrix, where the row + would only say "renders". + """ + + def test_null_binds_then_renders_as_the_keyword(self, dialect): + assert Literal(dialect, None).to_sql() == ("%s", (None,)) + assert Literal(dialect, None, inline=True).to_sql() == ("NULL", ()) + + def test_booleans_render_as_keywords(self, dialect): + assert Literal(dialect, True, inline=True).to_sql() == ("TRUE", ()) + assert Literal(dialect, False, inline=True).to_sql() == ("FALSE", ()) + + def test_integers_render_as_numeric_text(self, dialect): + assert Literal(dialect, 18, inline=True).to_sql() == ("18", ()) + assert Literal(dialect, -7, inline=True).to_sql() == ("-7", ()) + + def test_floats_render_as_their_repr(self, dialect): + assert Literal(dialect, 0.5, inline=True).to_sql() == ("0.5", ()) + assert Literal(dialect, 15e-03, inline=True).to_sql() == ("0.015", ()) + + def test_decimals_render_exact(self, dialect): + value = decimal.Decimal("12.34") + assert Literal(dialect, value, inline=True).to_sql() == ("12.34", ()) + + def test_strings_double_the_quotes(self, dialect): + sql, _ = Literal(dialect, "today's", inline=True).to_sql() + assert sql == "'today''s'" + + def test_dates_render_as_quoted_iso(self, dialect): + sql, _ = Literal(dialect, datetime.date(2026, 10, 17), inline=True).to_sql() + assert sql == "'2026-10-17'" + + def test_times_render_as_quoted_iso(self, dialect): + sql, _ = Literal(dialect, datetime.time(10, 3, 56), inline=True).to_sql() + assert sql == "'10:03:56'" + + def test_datetimes_render_as_quoted_iso_with_a_space_separator(self, dialect): + sql, _ = Literal( + dialect, datetime.datetime(2024, 8, 15, 10, 59, 43), inline=True + ).to_sql() + assert sql == "'2024-08-15 10:59:43'" + + +class TestNonFiniteFloatsNeverReachSqlText: + """The base's refusal is Snowflake's answer: they are quoted specials. + + Snowflake spells the FLOAT special values ``'NaN'`` / ``'inf'`` -- quoted + text, not a numeric literal the base could emit. Core decides the value's + kind before rendering (``nan_value`` / ``infinity_value``), so a + ``float`` carrying one of these is refused rather than spelled. + """ + + @pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")]) + def test_inline_position_is_refused(self, dialect, value): + with pytest.raises(ValueError, match="cannot be inlined into SQL"): + Literal(dialect, value, inline=True).to_sql() + + +class TestMROOrder: + """The composed order is what resolves the tier-2 spellings.""" + + def test_the_local_mixin_wins_over_the_base(self, dialect): + assert dialect.format_literal.__func__.__qualname__.startswith( + "SnowflakeLiteralValueMixin" + ) + + def test_inline_literals_route_through_the_local_mixin(self, dialect): + # format_literal_expression hands inline=True literals to + # format_literal, so the MRO test and the rendering test agree. + sql, _ = Literal(dialect, b"\x01", inline=True).to_sql() + assert sql == "TO_BINARY('01', 'HEX')" diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/expression/test_snowflake_nextval.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/expression/test_snowflake_nextval.py new file mode 100644 index 0000000..a0e7079 --- /dev/null +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/expression/test_snowflake_nextval.py @@ -0,0 +1,63 @@ +# tests/rhosocial/activerecord_snowflake_test/feature/backend/expression/test_snowflake_nextval.py +"""``.NEXTVAL`` -- the node, and the statement that reads it. + +Snowflake reads a sequence's next value as a pseudo-column on the sequence +(`seq.nextval`), which is neither the SQL-standard ``NEXT VALUE FOR seq`` nor +PostgreSQL's ``nextval('seq')`` function form. The node exists so that the +value can be named and read without any layer spelling SQL by hand. + +**not server-verified** -- there is no Snowflake instance reachable from this +repository; the spelling below is transcribed from the official page and +checked as a rendering assertion, never a measurement. + +https://docs.snowflake.com/en/sql-reference/functions/nextval +""" + +import pytest + +from rhosocial.activerecord.backend.expression.statements.dql import QueryStatement +from rhosocial.activerecord.backend.impl.snowflake.dialect import SnowflakeDialect +from rhosocial.activerecord.backend.impl.snowflake.expression.sequence import ( + SnowflakeNextvalExpression, +) + + +@pytest.fixture +def dialect(): + return SnowflakeDialect() + + +class TestNextvalRendering: + def test_the_pseudo_column_is_named(self, dialect): + sql, params = SnowflakeNextvalExpression(dialect, "order_id_seq").to_sql() + assert sql == '"order_id_seq".NEXTVAL' + assert params == () + + def test_the_alias_rides_along(self, dialect): + sql, params = SnowflakeNextvalExpression( + dialect, "order_id_seq", alias="next_id" + ).to_sql() + assert sql == '"order_id_seq".NEXTVAL AS "next_id"' + assert params == () + + def test_the_name_is_quoted_once_by_the_dialect(self, dialect): + # A name that needs quoting is the dialect's business, not the node's. + sql, _ = SnowflakeNextvalExpression(dialect, "My Seq").to_sql() + assert sql == '"My Seq".NEXTVAL' + + def test_an_empty_name_is_refused_at_construction(self, dialect): + with pytest.raises(ValueError, match="non-empty sequence name"): + SnowflakeNextvalExpression(dialect, "") + + +class TestNextvalInAQuery: + def test_it_is_a_projection_and_renders_through_its_query(self, dialect): + query = QueryStatement( + dialect, + select=[ + SnowflakeNextvalExpression(dialect, "order_id_seq", alias="next_id") + ], + ) + sql, params = query.to_sql() + assert sql == 'SELECT "order_id_seq".NEXTVAL AS "next_id"' + assert params == () diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/expression/test_snowflake_wildcards.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/expression/test_snowflake_wildcards.py new file mode 100644 index 0000000..b4e9a70 --- /dev/null +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/expression/test_snowflake_wildcards.py @@ -0,0 +1,265 @@ +# tests/rhosocial/activerecord_snowflake_test/feature/backend/expression/test_snowflake_wildcards.py +"""Snowflake's two wildcard spellings: the filter and the qualified row count. + +Core splits the asterisk by grammar position (spec 4.10). Snowflake then adds a +filter to both positions and a qualifier to the row-count one, transcribed from + + https://docs.snowflake.com/en/sql-reference/functions/count + https://docs.snowflake.com/en/sql-reference/sql/select + +**not server-verified.** There is no Snowflake instance reachable from this +repository; every assertion below is a rendering assertion over the transcribed +grammar, never a measurement. The reference pages are quoted in the node's and +the mixin's docstrings; the ``SELECT`` page's own worked examples are the fixtures +where a spelling could be got wrong (``* EXCLUDE department_id``, +``* EXCLUDE (department_id, employee_id)``, ``* ILIKE '%id%'``, +``COUNT(n.*)``). +""" + +import pytest + +from rhosocial.activerecord.backend.expression.core import ( + AllColumnsWildcard, + RowCountWildcard, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef +from rhosocial.activerecord.backend.impl.snowflake.dialect import SnowflakeDialect +from rhosocial.activerecord.backend.impl.snowflake.expression import ( + SnowflakeAllColumnsWildcard, + SnowflakeRowCountWildcard, +) +from rhosocial.activerecord.backend.impl.snowflake.mixins.wildcard import ( + SnowflakeWildcardMixin, +) + + +@pytest.fixture +def dialect(): + return SnowflakeDialect(version=(8, 0, 0)) + + +def _relation(dialect, name="employee_table", alias=None, schema_name=None): + table = Table(dialect, name, schema_name=schema_name) + return NamedRelationRef(dialect, table, alias=alias) + + +class TestCoreFormsAreUnchanged: + """The override exists for the filter, not for the bare asterisk.""" + + def test_bare_all_columns(self, dialect): + assert AllColumnsWildcard(dialect).to_sql() == ("*", ()) + + def test_bare_row_count(self, dialect): + assert RowCountWildcard(dialect).to_sql() == ("*", ()) + + def test_core_qualified_all_columns_still_renders(self, dialect): + node = AllColumnsWildcard(dialect, relation=_relation(dialect, alias="e")) + assert node.to_sql() == ('"e".*', ()) + + def test_an_unfiltered_snowflake_node_is_the_core_spelling(self, dialect): + """The derived node with no filter is byte-identical to core's.""" + assert SnowflakeAllColumnsWildcard(dialect).to_sql() == ("*", ()) + assert SnowflakeRowCountWildcard(dialect).to_sql() == ("*", ()) + + +class TestAllColumnsFilters: + """``SELECT * ILIKE ...`` / ``SELECT * EXCLUDE ...`` -- the SELECT grammar.""" + + def test_ilike_pattern(self, dialect): + node = SnowflakeAllColumnsWildcard(dialect, ilike="%id%") + assert node.to_sql() == ("* ILIKE '%id%'", ()) + + def test_ilike_single_character_wildcards_survive(self, dialect): + """``_`` and ``%`` are the pattern's own metacharacters, not quoting.""" + node = SnowflakeAllColumnsWildcard(dialect, ilike="col_%") + assert node.to_sql() == ("* ILIKE 'col_%'", ()) + + def test_exclude_one_column_is_unparenthesised(self, dialect): + """``* EXCLUDE department_id`` -- the page's own single-name spelling.""" + node = SnowflakeAllColumnsWildcard(dialect, exclude="department_id") + assert node.to_sql() == ('* EXCLUDE "department_id"', ()) + + def test_exclude_several_columns_is_parenthesised(self, dialect): + """``* EXCLUDE (department_id, employee_id)`` -- the page's list spelling.""" + node = SnowflakeAllColumnsWildcard( + dialect, exclude=["department_id", "employee_id"] + ) + assert node.to_sql() == ('* EXCLUDE ("department_id", "employee_id")', ()) + + def test_exclude_rides_a_qualified_wildcard(self, dialect): + """``table_a.* EXCLUDE column_in_table_a`` -- the join example.""" + node = SnowflakeAllColumnsWildcard( + dialect, + relation=_relation(dialect, "employee_table", alias="employee_table"), + exclude="department_id", + ) + assert node.to_sql() == ('"employee_table".* EXCLUDE "department_id"', ()) + + def test_qualified_ilike_rides_the_alias(self, dialect): + node = SnowflakeAllColumnsWildcard( + dialect, + relation=_relation(dialect, "employee_table", alias="e"), + ilike="%name%", + ) + assert node.to_sql() == ('"e".* ILIKE \'%name%\'', ()) + + def test_a_pattern_with_a_quote_is_escaped(self, dialect): + node = SnowflakeAllColumnsWildcard(dialect, ilike="a'b%") + sql, _ = node.to_sql() + assert sql == "* ILIKE 'a''b%'" + + +class TestRowCountFiltersAndQualifier: + """``COUNT(.*)`` -- a different number from ``COUNT(*)``.""" + + def test_qualified_row_count(self, dialect): + """The page's own example: ``SELECT COUNT(n.*) FROM basic_example AS n``.""" + node = SnowflakeRowCountWildcard( + dialect, relation=_relation(dialect, "basic_example", alias="n") + ) + assert node.to_sql() == ('"n".*', ()) + + def test_qualified_row_count_uses_the_relation_name_without_an_alias(self, dialect): + node = SnowflakeRowCountWildcard(dialect, relation=_relation(dialect)) + assert node.to_sql() == ('"employee_table".*', ()) + + def test_qualified_row_count_with_a_filter(self, dialect): + node = SnowflakeRowCountWildcard( + dialect, relation=_relation(dialect, alias="e"), exclude="department_id" + ) + assert node.to_sql() == ('"e".* EXCLUDE "department_id"', ()) + + def test_the_two_counts_are_different_nodes(self, dialect): + """Why this is a derived node and not a flag on core's. + + ``COUNT(*)`` counts every record; ``COUNT(t.*)`` counts the records with + no NULL. Two different numbers, so two different nodes -- the same + reasoning PostgreSQL's own row-count wildcard is derived under. + """ + assert not isinstance(SnowflakeRowCountWildcard(dialect), AllColumnsWildcard) + assert RowCountWildcard(dialect).to_sql() == ("*", ()) + assert SnowflakeRowCountWildcard( + dialect, relation=_relation(dialect, alias="n") + ).to_sql() == ('"n".*', ()) + + +class TestFilterCombinationsAreRefused: + """The two keywords cannot be combined, and the node refuses it by name.""" + + @pytest.mark.parametrize("cls", (SnowflakeAllColumnsWildcard, SnowflakeRowCountWildcard)) + def test_ilike_with_exclude_is_refused(self, dialect, cls): + with pytest.raises(ValueError) as exc_info: + cls(dialect, ilike="%id%", exclude="department_id") + assert "can't be combined" in str(exc_info.value) + + @pytest.mark.parametrize("cls", (SnowflakeAllColumnsWildcard, SnowflakeRowCountWildcard)) + def test_non_string_pattern_is_refused(self, dialect, cls): + with pytest.raises(TypeError): + cls(dialect, ilike=42) + + @pytest.mark.parametrize("cls", (SnowflakeAllColumnsWildcard, SnowflakeRowCountWildcard)) + def test_non_string_exclude_member_is_refused(self, dialect, cls): + with pytest.raises(TypeError): + cls(dialect, exclude=["ok", 3]) + + @pytest.mark.parametrize("cls", (SnowflakeAllColumnsWildcard, SnowflakeRowCountWildcard)) + def test_empty_filter_shapes_are_refused(self, dialect, cls): + with pytest.raises(ValueError): + cls(dialect, ilike="") + with pytest.raises(ValueError): + cls(dialect, exclude=[]) + with pytest.raises(TypeError): + cls(dialect, exclude=3) + + +class TestTheMixinDelegatesCoreForms: + """The override is class-tested, never an attribute probe.""" + + def test_the_mixin_declares_both_formatters(self): + assert "format_all_columns_wildcard" in vars(SnowflakeWildcardMixin) + assert "format_row_count_wildcard" in vars(SnowflakeWildcardMixin) + + def test_delegation_does_not_probe_for_a_missing_attribute(self, dialect): + """Core's nodes have no ``ilike`` at all, and are still rendered. + + The rule 9.8 objection to ``getattr(expr, 'ilike', None)`` is that it + would make a core node answer a question it cannot have; the class test + is what keeps the two paths apart, and this asserts the core path works + through the dialect (not just through ``to_sql``). + """ + assert dialect.format_all_columns_wildcard(AllColumnsWildcard(dialect)) == ("*", ()) + assert dialect.format_row_count_wildcard(RowCountWildcard(dialect)) == ("*", ()) + + def test_the_dialect_renders_the_snowflake_nodes(self, dialect): + assert dialect.format_all_columns_wildcard( + SnowflakeAllColumnsWildcard(dialect, ilike="%id%") + ) == ("* ILIKE '%id%'", ()) + assert dialect.format_row_count_wildcard( + SnowflakeRowCountWildcard( + dialect, relation=_relation(dialect, alias="n") + ) + ) == ('"n".*', ()) + + +class TestWildcardInASelectList: + """The nodes render where a query actually puts them.""" + + def test_select_star_ilike(self, dialect): + from rhosocial.activerecord.backend.expression.statements.dql import ( + QueryStatement, + ) + + query = QueryStatement( + dialect, + select=[SnowflakeAllColumnsWildcard(dialect, ilike="%id%")], + from_=_relation(dialect, "employee_table"), + ) + sql, params = query.to_sql() + assert sql == 'SELECT * ILIKE \'%id%\' FROM "employee_table"' + assert params == () + + def test_count_of_the_qualified_row_count(self, dialect): + """The node renders the qualified form when the node is what renders. + + Note the reachability: core's shared ``format_function_call`` special-cases + a ``RowCountWildcard`` argument to the bare asterisk *before* consulting + any formatter, so a ``FunctionCall(dialect, "COUNT", qualified)`` still + renders ``COUNT(*)`` on this core. The qualified spelling is therefore + reachable by rendering the node (or by a clause that dispatches to it), + not through the aggregate's factory -- the same gap PostgreSQL's own + row-count wildcard has. Fixing it means changing core's shared formatter, + which is outside this branch; it is pinned below so the day it changes + fails here instead of passing quietly. + """ + node = SnowflakeRowCountWildcard( + dialect, relation=_relation(dialect, "basic_example", alias="n") + ) + assert node.to_sql() == ('"n".*', ()) + assert dialect.format_row_count_wildcard(node) == ('"n".*', ()) + + def test_count_star_is_still_the_bare_asterisk(self, dialect): + from rhosocial.activerecord.backend.expression.core import FunctionCall + + sql, params = FunctionCall( + dialect, "COUNT", RowCountWildcard(dialect) + ).to_sql() + assert sql == "COUNT(*)" + assert params == () + + def test_the_aggregate_factory_masks_the_qualified_form(self, dialect): + """Pins core's special case, so the gap above is visible rather than lost. + + ``format_function_call`` answers ``COUNT()`` with the + bare asterisk, and the Snowflake node is a subclass. That is core's + behaviour on every backend with a derived row-count node; this asserts it + rather than pretending the preferred spelling is reached, so a change in + core moves this test rather than silently changing what Snowflake emits. + """ + from rhosocial.activerecord.backend.expression.core import FunctionCall + + node = SnowflakeRowCountWildcard( + dialect, relation=_relation(dialect, "basic_example", alias="n") + ) + assert FunctionCall(dialect, "COUNT", node).to_sql() == ("COUNT(*)", ()) + assert node.to_sql() == ('"n".*', ()) diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/expression_roundtrip_harness.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/expression_roundtrip_harness.py new file mode 100644 index 0000000..b0d9c8c --- /dev/null +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/expression_roundtrip_harness.py @@ -0,0 +1,1238 @@ +# tests/rhosocial/activerecord_snowflake_test/feature/backend/expression_roundtrip_harness.py +""" +Expression serialization round-trip / contract test helpers (Snowflake-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 typing import Any, Callable, Dict, Optional, Type + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +# Annotation fragments that mean "this parameter names a relation object". +# Word boundaries matter: ``TableConstraint`` and ``AddTableConstraintAction`` +# 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" + r"|EdgeTableDefinition)\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): + """An inline literal, for a parameter that wants a value expression. + + Inline rather than bound, for the same reason :func:`_comparison` builds + an inline comparison: a value the matrix constructs has to render in + both positions it can occupy -- a DQL/DML slot, where a bound value is + fine, and a DDL condition (``CHECK`` / generated columns / partial + indexes), which accepts no bind parameters at all since core stopped + lifting binding literals. The inline spelling is valid SQL in both. + """ + from rhosocial.activerecord.backend.expression.core import Literal + + return Literal(dialect, 1, inline=True) + + +def _function_call(dialect): + """A niladic function call, for a parameter that wraps one. + + The typed value expressions (``BooleanValue`` and its siblings) + hold a ``call`` and render it through ``to_sql()``. Handing them 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): + """A predicate comparing a column with an inline literal. + + The literal is written inline rather than bound because a predicate the + matrix builds has to render in **both** positions a predicate can occupy: + a DQL condition, where a bound value is fine, and a DDL condition + (``CHECK`` / partial-index ``WHERE`` / generated columns), which accepts + no bind parameters at all and -- since core stopped lifting binding + literals to the inline form -- refuses one. The inline spelling is valid + in both, so one helper serves every consumer. + """ + from rhosocial.activerecord.backend.expression.core import Column, Literal + from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate + + return ComparisonPredicate( + dialect, "=", Column(dialect, "a"), Literal(dialect, 1, inline=True) + ) + + +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.""" + 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") -> None: + """Deep-compare two get_params() outputs, treating nested BaseExpression + instances as structurally equal and nested dataclasses as recursively compared. + """ + if a is b: + return + if isinstance(a, BaseExpression) and isinstance(b, BaseExpression): + assert_params_equal(a.get_params(), b.get_params(), path + ".") + 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): + assert_params_equal(getattr(a, f.name), getattr(b, f.name), f"{path}.{f.name}") + 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: + assert_params_equal(a[k], b[k], f"{path}.{k}") + return + if isinstance(a, (list, tuple)): + assert len(a) == len(b), f"{path}: length differ" + for i, (x, y) in enumerate(zip(a, b)): + assert_params_equal(x, y, f"{path}[{i}]") + 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 + + + +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. + + Every dialect's ``parse_type`` is the introspection inverse of its + ``format_data_type_`` renderings, and two invariants make a + dialect coherent: + + **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, recorded in the + owning backend's test file with its reason. + + This is the vendored equivalent of the testsuite's shared sweep + (``testsuite/utils/parse_contract.py``), which the testsuite deleted + together with its round-trip harness when round-trip coverage moved + to the owning repository. The two decisions in it that outlived that + move are unchanged: a class the registry cannot construct or cannot + render belongs to the round-trip matrix's own lists, and a + ``CustomType`` answer is never blessed for a rendered word. + + 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*. + skip: class names to leave out of the sweep entirely, in addition + to the built-in ``CustomType`` skip. + + Returns: + A list of human-readable failures; empty is the contract met. + """ + from rhosocial.activerecord.backend.expression.types import CustomType, DataType + + 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 unbuildable 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__ = [ + "assert_params_equal", + "collect_expression_classes", + "make_instance", + "register_all", + "register_special_constructor", + "roundtrip_expression", + "sql_consistent", + "parse_roundtrip_failures", +] \ No newline at end of file diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/extensions/test_file_format.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/extensions/test_file_format.py index cf22870..ed9be3f 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/extensions/test_file_format.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/extensions/test_file_format.py @@ -11,9 +11,9 @@ SnowflakeFileFormatSupport, ) from rhosocial.activerecord.backend.impl.snowflake.expression import ( - SnowflakeAlterFileFormatExpression, - SnowflakeCreateFileFormatExpression, - SnowflakeDropFileFormatExpression, + SnowflakeAlterFileFormatStatement, + SnowflakeCreateFileFormatStatement, + SnowflakeDropFileFormatStatement, SnowflakeFileFormatType, ) @@ -37,7 +37,7 @@ class TestSnowflakeCreateFileFormat: """CREATE [OR REPLACE] FILE FORMAT statement generation.""" def test_create_file_format_basic(self, dialect): - expr = SnowflakeCreateFileFormatExpression( + expr = SnowflakeCreateFileFormatStatement( dialect, "my_csv", type_=SnowflakeFileFormatType.CSV ) sql, params = expr.to_sql() @@ -45,7 +45,7 @@ def test_create_file_format_basic(self, dialect): assert params == () def test_create_file_format_full_options(self, dialect): - expr = SnowflakeCreateFileFormatExpression( + expr = SnowflakeCreateFileFormatStatement( dialect, "my_csv", type_=SnowflakeFileFormatType.CSV, @@ -65,7 +65,7 @@ def test_create_file_format_full_options(self, dialect): ) def test_create_file_format_or_replace(self, dialect): - expr = SnowflakeCreateFileFormatExpression( + expr = SnowflakeCreateFileFormatStatement( dialect, "my_json", or_replace=True, @@ -79,7 +79,7 @@ def test_create_file_format_or_replace(self, dialect): ) def test_create_file_format_parquet(self, dialect): - expr = SnowflakeCreateFileFormatExpression( + expr = SnowflakeCreateFileFormatStatement( dialect, "my_pq", type_="PARQUET" ) sql, _ = expr.to_sql() @@ -87,14 +87,14 @@ def test_create_file_format_parquet(self, dialect): def test_create_file_format_all_types_accepted(self, dialect): for t in SnowflakeFileFormatType: - expr = SnowflakeCreateFileFormatExpression( + expr = SnowflakeCreateFileFormatStatement( dialect, "fmt", type_=t ) sql, _ = expr.to_sql() assert f"TYPE = {t.value}" in sql def test_create_file_format_string_escaped(self, dialect): - expr = SnowflakeCreateFileFormatExpression( + expr = SnowflakeCreateFileFormatStatement( dialect, "my_csv", type_=SnowflakeFileFormatType.CSV, @@ -108,7 +108,7 @@ class TestSnowflakeAlterFileFormat: """ALTER FILE FORMAT ... SET statement generation.""" def test_alter_file_format_set(self, dialect): - expr = SnowflakeAlterFileFormatExpression( + expr = SnowflakeAlterFileFormatStatement( dialect, "my_csv", options={"SKIP_HEADER": 2} ) sql, params = expr.to_sql() @@ -116,7 +116,7 @@ def test_alter_file_format_set(self, dialect): assert params == () def test_alter_file_format_set_comment(self, dialect): - expr = SnowflakeAlterFileFormatExpression( + expr = SnowflakeAlterFileFormatStatement( dialect, "my_csv", comment="updated" ) sql, _ = expr.to_sql() @@ -125,7 +125,7 @@ def test_alter_file_format_set_comment(self, dialect): ) def test_alter_file_format_without_property_raises(self, dialect): - expr = SnowflakeAlterFileFormatExpression(dialect, "my_csv") + expr = SnowflakeAlterFileFormatStatement(dialect, "my_csv") with pytest.raises(ValueError): expr.to_sql() @@ -134,13 +134,13 @@ class TestSnowflakeDropFileFormat: """DROP FILE FORMAT statement generation.""" def test_drop_file_format(self, dialect): - expr = SnowflakeDropFileFormatExpression(dialect, "my_csv") + expr = SnowflakeDropFileFormatStatement(dialect, "my_csv") sql, params = expr.to_sql() assert sql == 'DROP FILE FORMAT "my_csv"' assert params == () def test_drop_file_format_if_exists(self, dialect): - expr = SnowflakeDropFileFormatExpression( + expr = SnowflakeDropFileFormatStatement( dialect, "my_csv", if_exists=True ) sql, _ = expr.to_sql() diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/extensions/test_routine.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/extensions/test_routine.py index 8160039..8e4a6c4 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/extensions/test_routine.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/extensions/test_routine.py @@ -11,9 +11,9 @@ SnowflakeRoutineSupport, ) from rhosocial.activerecord.backend.impl.snowflake.expression import ( - SnowflakeCreateFunctionExpression, - SnowflakeCreateProcedureExpression, - SnowflakeDropRoutineExpression, + SnowflakeCreateFunctionStatement, + SnowflakeCreateProcedureStatement, + SnowflakeDropRoutineStatement, SnowflakeRoutineExecuteAs, SnowflakeRoutineLanguage, SnowflakeRoutineType, @@ -39,7 +39,7 @@ class TestSnowflakeCreateProcedure: """CREATE [OR REPLACE] PROCEDURE statement generation.""" def test_create_procedure_basic(self, dialect): - expr = SnowflakeCreateProcedureExpression( + expr = SnowflakeCreateProcedureStatement( dialect, "p", args=[("x", "NUMBER")], @@ -55,7 +55,7 @@ def test_create_procedure_basic(self, dialect): assert params == () def test_create_procedure_or_replace(self, dialect): - expr = SnowflakeCreateProcedureExpression( + expr = SnowflakeCreateProcedureStatement( dialect, "p", or_replace=True, @@ -70,7 +70,7 @@ def test_create_procedure_or_replace(self, dialect): ) def test_create_procedure_javascript_language(self, dialect): - expr = SnowflakeCreateProcedureExpression( + expr = SnowflakeCreateProcedureStatement( dialect, "p", args=[("x", "NUMBER")], @@ -85,7 +85,7 @@ def test_create_procedure_javascript_language(self, dialect): ) def test_create_procedure_execute_as_caller(self, dialect): - expr = SnowflakeCreateProcedureExpression( + expr = SnowflakeCreateProcedureStatement( dialect, "p", returns="VARCHAR", @@ -99,7 +99,7 @@ def test_create_procedure_execute_as_caller(self, dialect): ) def test_create_procedure_multiple_args(self, dialect): - expr = SnowflakeCreateProcedureExpression( + expr = SnowflakeCreateProcedureStatement( dialect, "p", args=[("a", "NUMBER"), (None, "VARCHAR")], @@ -117,7 +117,7 @@ class TestSnowflakeCreateFunction: """CREATE [OR REPLACE] FUNCTION statement generation.""" def test_create_function_basic(self, dialect): - expr = SnowflakeCreateFunctionExpression( + expr = SnowflakeCreateFunctionStatement( dialect, "f", args=[("x", "NUMBER")], @@ -133,7 +133,7 @@ def test_create_function_basic(self, dialect): assert params == () def test_create_function_volatile(self, dialect): - expr = SnowflakeCreateFunctionExpression( + expr = SnowflakeCreateFunctionStatement( dialect, "f", returns="NUMBER", @@ -148,7 +148,7 @@ def test_create_function_volatile(self, dialect): def test_create_function_unspecified_volatility_omits_the_clause(self, dialect): """Neither parameter set: the optional clause is absent (server default).""" - expr = SnowflakeCreateFunctionExpression( + expr = SnowflakeCreateFunctionStatement( dialect, "f", returns="NUMBER", @@ -163,7 +163,7 @@ def test_create_function_volatility_pair_is_mutually_exclusive(self, dialect): with pytest.raises( ValueError, match="immutable and volatile are mutually exclusive" ): - SnowflakeCreateFunctionExpression( + SnowflakeCreateFunctionStatement( dialect, "f", returns="NUMBER", @@ -173,7 +173,7 @@ def test_create_function_volatility_pair_is_mutually_exclusive(self, dialect): ) def test_create_function_execute_as_owner(self, dialect): - expr = SnowflakeCreateFunctionExpression( + expr = SnowflakeCreateFunctionStatement( dialect, "f", returns="NUMBER", @@ -187,7 +187,7 @@ def test_create_function_execute_as_owner(self, dialect): ) def test_create_function_or_replace(self, dialect): - expr = SnowflakeCreateFunctionExpression( + expr = SnowflakeCreateFunctionStatement( dialect, "f", or_replace=True, @@ -201,7 +201,7 @@ def test_create_function_or_replace(self, dialect): ) def test_create_function_python_language(self, dialect): - expr = SnowflakeCreateFunctionExpression( + expr = SnowflakeCreateFunctionStatement( dialect, "f", returns="NUMBER", @@ -219,7 +219,7 @@ class TestSnowflakeDropRoutine: """DROP PROCEDURE / FUNCTION statement generation.""" def test_drop_procedure(self, dialect): - expr = SnowflakeDropRoutineExpression( + expr = SnowflakeDropRoutineStatement( dialect, "p", routine_type=SnowflakeRoutineType.PROCEDURE ) sql, params = expr.to_sql() @@ -227,7 +227,7 @@ def test_drop_procedure(self, dialect): assert params == () def test_drop_function_if_exists(self, dialect): - expr = SnowflakeDropRoutineExpression( + expr = SnowflakeDropRoutineStatement( dialect, "f", routine_type=SnowflakeRoutineType.FUNCTION, @@ -237,6 +237,6 @@ def test_drop_function_if_exists(self, dialect): assert sql == 'DROP FUNCTION IF EXISTS "f"' def test_drop_routine_defaults_to_procedure(self, dialect): - expr = SnowflakeDropRoutineExpression(dialect, "p") + expr = SnowflakeDropRoutineStatement(dialect, "p") sql, _ = expr.to_sql() assert sql == 'DROP PROCEDURE "p"' diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/extensions/test_stream_task_pipe.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/extensions/test_stream_task_pipe.py index 7c7e20c..1f3acd4 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/extensions/test_stream_task_pipe.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/extensions/test_stream_task_pipe.py @@ -13,17 +13,17 @@ SnowflakeTaskSupport, ) from rhosocial.activerecord.backend.impl.snowflake.expression import ( - SnowflakeAlterPipeExpression, + SnowflakeAlterPipeStatement, SnowflakeAlterPipeMode, - SnowflakeAlterTaskExpression, + SnowflakeAlterTaskStatement, SnowflakeAlterTaskMode, - SnowflakeCreatePipeExpression, - SnowflakeCreateStreamExpression, - SnowflakeCreateTaskExpression, - SnowflakeDropPipeExpression, - SnowflakeDropStreamExpression, - SnowflakeDropTaskExpression, - SnowflakeExecuteTaskExpression, + SnowflakeCreatePipeStatement, + SnowflakeCreateStreamStatement, + SnowflakeCreateTaskStatement, + SnowflakeDropPipeStatement, + SnowflakeDropStreamStatement, + SnowflakeDropTaskStatement, + SnowflakeExecuteTaskStatement, SnowflakeStreamObjectType, ) @@ -47,7 +47,7 @@ class TestSnowflakeCreateStream: """CREATE [OR REPLACE] STREAM statement generation.""" def test_create_stream_basic(self, dialect): - expr = SnowflakeCreateStreamExpression( + expr = SnowflakeCreateStreamStatement( dialect, "my_stream", object_name="t" ) sql, params = expr.to_sql() @@ -55,7 +55,7 @@ def test_create_stream_basic(self, dialect): assert params == () def test_create_stream_or_replace_append_only(self, dialect): - expr = SnowflakeCreateStreamExpression( + expr = SnowflakeCreateStreamStatement( dialect, "my_stream", or_replace=True, @@ -70,7 +70,7 @@ def test_create_stream_or_replace_append_only(self, dialect): ) def test_create_stream_insert_only_on_view(self, dialect): - expr = SnowflakeCreateStreamExpression( + expr = SnowflakeCreateStreamStatement( dialect, "my_stream", object_type=SnowflakeStreamObjectType.VIEW, @@ -83,7 +83,7 @@ def test_create_stream_insert_only_on_view(self, dialect): ) def test_create_stream_on_external_table(self, dialect): - expr = SnowflakeCreateStreamExpression( + expr = SnowflakeCreateStreamStatement( dialect, "my_stream", object_type=SnowflakeStreamObjectType.EXTERNAL_TABLE, @@ -93,7 +93,7 @@ def test_create_stream_on_external_table(self, dialect): assert sql == 'CREATE STREAM "my_stream" ON EXTERNAL TABLE "et"' def test_create_stream_time_travel_at(self, dialect): - expr = SnowflakeCreateStreamExpression( + expr = SnowflakeCreateStreamStatement( dialect, "my_stream", object_name="t", @@ -106,7 +106,7 @@ def test_create_stream_time_travel_at(self, dialect): ) def test_create_stream_time_travel_before_offset(self, dialect): - expr = SnowflakeCreateStreamExpression( + expr = SnowflakeCreateStreamStatement( dialect, "my_stream", object_name="t", @@ -118,18 +118,18 @@ def test_create_stream_time_travel_before_offset(self, dialect): ) def test_create_stream_requires_object_name(self, dialect): - expr = SnowflakeCreateStreamExpression(dialect, "my_stream") + expr = SnowflakeCreateStreamStatement(dialect, "my_stream") with pytest.raises(ValueError): expr.to_sql() def test_drop_stream(self, dialect): - expr = SnowflakeDropStreamExpression(dialect, "my_stream") + expr = SnowflakeDropStreamStatement(dialect, "my_stream") sql, params = expr.to_sql() assert sql == 'DROP STREAM "my_stream"' assert params == () def test_drop_stream_if_exists(self, dialect): - expr = SnowflakeDropStreamExpression(dialect, "my_stream", if_exists=True) + expr = SnowflakeDropStreamStatement(dialect, "my_stream", if_exists=True) sql, _ = expr.to_sql() assert sql == 'DROP STREAM IF EXISTS "my_stream"' @@ -148,7 +148,7 @@ class TestSnowflakeCreateTask: """CREATE [OR REPLACE] TASK statement generation.""" def test_create_task_basic(self, dialect): - expr = SnowflakeCreateTaskExpression( + expr = SnowflakeCreateTaskStatement( dialect, "my_task", sql="INSERT INTO archive SELECT * FROM my_stream" ) sql, params = expr.to_sql() @@ -159,7 +159,7 @@ def test_create_task_basic(self, dialect): assert params == () def test_create_task_schedule_interval(self, dialect): - expr = SnowflakeCreateTaskExpression( + expr = SnowflakeCreateTaskStatement( dialect, "my_task", warehouse="my_wh", @@ -173,7 +173,7 @@ def test_create_task_schedule_interval(self, dialect): ) def test_create_task_using_cron_with_timezone(self, dialect): - expr = SnowflakeCreateTaskExpression( + expr = SnowflakeCreateTaskStatement( dialect, "my_task", using_cron="0 0 * * *", @@ -187,7 +187,7 @@ def test_create_task_using_cron_with_timezone(self, dialect): ) def test_create_task_when_condition(self, dialect): - expr = SnowflakeCreateTaskExpression( + expr = SnowflakeCreateTaskStatement( dialect, "my_task", when="SYSTEM$STREAM_HAS_DATA('my_stream')", @@ -200,7 +200,7 @@ def test_create_task_when_condition(self, dialect): ) def test_create_task_after_dependencies(self, dialect): - expr = SnowflakeCreateTaskExpression( + expr = SnowflakeCreateTaskStatement( dialect, "my_task", after=["another_task", "third_task"], @@ -213,7 +213,7 @@ def test_create_task_after_dependencies(self, dialect): ) def test_create_task_or_replace(self, dialect): - expr = SnowflakeCreateTaskExpression( + expr = SnowflakeCreateTaskStatement( dialect, "my_task", or_replace=True, sql="SELECT 1" ) sql, _ = expr.to_sql() @@ -224,7 +224,7 @@ class TestSnowflakeAlterTask: """ALTER TASK RESUME / SUSPEND / ADD AFTER / REMOVE AFTER generation.""" def test_alter_task_resume(self, dialect): - expr = SnowflakeAlterTaskExpression( + expr = SnowflakeAlterTaskStatement( dialect, "my_task", mode=SnowflakeAlterTaskMode.RESUME ) sql, params = expr.to_sql() @@ -232,14 +232,14 @@ def test_alter_task_resume(self, dialect): assert params == () def test_alter_task_suspend(self, dialect): - expr = SnowflakeAlterTaskExpression( + expr = SnowflakeAlterTaskStatement( dialect, "my_task", mode=SnowflakeAlterTaskMode.SUSPEND ) sql, _ = expr.to_sql() assert sql == 'ALTER TASK "my_task" SUSPEND' def test_alter_task_add_after(self, dialect): - expr = SnowflakeAlterTaskExpression( + expr = SnowflakeAlterTaskStatement( dialect, "my_task", mode=SnowflakeAlterTaskMode.ADD_AFTER, @@ -249,7 +249,7 @@ def test_alter_task_add_after(self, dialect): assert sql == 'ALTER TASK "my_task" ADD AFTER "another_task"' def test_alter_task_remove_after(self, dialect): - expr = SnowflakeAlterTaskExpression( + expr = SnowflakeAlterTaskStatement( dialect, "my_task", mode=SnowflakeAlterTaskMode.REMOVE_AFTER, @@ -259,7 +259,7 @@ def test_alter_task_remove_after(self, dialect): assert sql == 'ALTER TASK "my_task" REMOVE AFTER "another_task"' def test_alter_task_set_warehouse(self, dialect): - expr = SnowflakeAlterTaskExpression( + expr = SnowflakeAlterTaskStatement( dialect, "my_task", mode=SnowflakeAlterTaskMode.SET, @@ -269,14 +269,14 @@ def test_alter_task_set_warehouse(self, dialect): assert sql == 'ALTER TASK "my_task" SET WAREHOUSE = "other_wh"' def test_alter_task_add_after_without_tasks_raises(self, dialect): - expr = SnowflakeAlterTaskExpression( + expr = SnowflakeAlterTaskStatement( dialect, "my_task", mode=SnowflakeAlterTaskMode.ADD_AFTER ) with pytest.raises(ValueError): expr.to_sql() def test_alter_task_set_without_property_raises(self, dialect): - expr = SnowflakeAlterTaskExpression( + expr = SnowflakeAlterTaskStatement( dialect, "my_task", mode=SnowflakeAlterTaskMode.SET ) with pytest.raises(ValueError): @@ -287,23 +287,23 @@ class TestSnowflakeExecuteTask: """EXECUTE TASK statement generation.""" def test_execute_task(self, dialect): - expr = SnowflakeExecuteTaskExpression(dialect, "my_task") + expr = SnowflakeExecuteTaskStatement(dialect, "my_task") sql, params = expr.to_sql() assert sql == 'EXECUTE TASK "my_task"' assert params == () def test_execute_task_retry_last(self, dialect): - expr = SnowflakeExecuteTaskExpression(dialect, "my_task", retry_last=True) + expr = SnowflakeExecuteTaskStatement(dialect, "my_task", retry_last=True) sql, _ = expr.to_sql() assert sql == 'EXECUTE TASK "my_task" RETRY LAST' def test_drop_task(self, dialect): - expr = SnowflakeDropTaskExpression(dialect, "my_task") + expr = SnowflakeDropTaskStatement(dialect, "my_task") sql, _ = expr.to_sql() assert sql == 'DROP TASK "my_task"' def test_drop_task_if_exists(self, dialect): - expr = SnowflakeDropTaskExpression(dialect, "my_task", if_exists=True) + expr = SnowflakeDropTaskStatement(dialect, "my_task", if_exists=True) sql, _ = expr.to_sql() assert sql == 'DROP TASK IF EXISTS "my_task"' @@ -322,7 +322,7 @@ class TestSnowflakeCreatePipe: """CREATE [OR REPLACE] PIPE statement generation.""" def test_create_pipe_basic(self, dialect): - expr = SnowflakeCreatePipeExpression( + expr = SnowflakeCreatePipeStatement( dialect, "my_pipe", copy_sql="COPY INTO t FROM @stage" ) sql, params = expr.to_sql() @@ -330,7 +330,7 @@ def test_create_pipe_basic(self, dialect): assert params == () def test_create_pipe_auto_ingest(self, dialect): - expr = SnowflakeCreatePipeExpression( + expr = SnowflakeCreatePipeStatement( dialect, "my_pipe", or_replace=True, @@ -344,7 +344,7 @@ def test_create_pipe_auto_ingest(self, dialect): ) def test_create_pipe_error_integration(self, dialect): - expr = SnowflakeCreatePipeExpression( + expr = SnowflakeCreatePipeStatement( dialect, "my_pipe", error_integration="my_err_int", @@ -357,7 +357,7 @@ def test_create_pipe_error_integration(self, dialect): ) def test_create_pipe_requires_copy_sql(self, dialect): - expr = SnowflakeCreatePipeExpression(dialect, "my_pipe") + expr = SnowflakeCreatePipeStatement(dialect, "my_pipe") with pytest.raises(ValueError): expr.to_sql() @@ -366,7 +366,7 @@ class TestSnowflakeAlterPipe: """ALTER PIPE REFRESH / SET / PAUSE / RESUME generation.""" def test_alter_pipe_refresh(self, dialect): - expr = SnowflakeAlterPipeExpression( + expr = SnowflakeAlterPipeStatement( dialect, "my_pipe", mode=SnowflakeAlterPipeMode.REFRESH ) sql, params = expr.to_sql() @@ -374,7 +374,7 @@ def test_alter_pipe_refresh(self, dialect): assert params == () def test_alter_pipe_set_paused(self, dialect): - expr = SnowflakeAlterPipeExpression( + expr = SnowflakeAlterPipeStatement( dialect, "my_pipe", mode=SnowflakeAlterPipeMode.SET, @@ -386,32 +386,32 @@ def test_alter_pipe_set_paused(self, dialect): ) def test_alter_pipe_pause(self, dialect): - expr = SnowflakeAlterPipeExpression( + expr = SnowflakeAlterPipeStatement( dialect, "my_pipe", mode=SnowflakeAlterPipeMode.PAUSE ) sql, _ = expr.to_sql() assert sql == 'ALTER PIPE "my_pipe" SET PIPE_EXECUTION_PAUSED = TRUE' def test_alter_pipe_resume(self, dialect): - expr = SnowflakeAlterPipeExpression( + expr = SnowflakeAlterPipeStatement( dialect, "my_pipe", mode=SnowflakeAlterPipeMode.RESUME ) sql, _ = expr.to_sql() assert sql == 'ALTER PIPE "my_pipe" SET PIPE_EXECUTION_PAUSED = FALSE' def test_alter_pipe_set_without_property_raises(self, dialect): - expr = SnowflakeAlterPipeExpression( + expr = SnowflakeAlterPipeStatement( dialect, "my_pipe", mode=SnowflakeAlterPipeMode.SET ) with pytest.raises(ValueError): expr.to_sql() def test_drop_pipe(self, dialect): - expr = SnowflakeDropPipeExpression(dialect, "my_pipe") + expr = SnowflakeDropPipeStatement(dialect, "my_pipe") sql, _ = expr.to_sql() assert sql == 'DROP PIPE "my_pipe"' def test_drop_pipe_if_exists(self, dialect): - expr = SnowflakeDropPipeExpression(dialect, "my_pipe", if_exists=True) + expr = SnowflakeDropPipeStatement(dialect, "my_pipe", if_exists=True) sql, _ = expr.to_sql() assert sql == 'DROP PIPE IF EXISTS "my_pipe"' diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/extensions/test_undrop_clone_materialized_view.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/extensions/test_undrop_clone_materialized_view.py index e45adac..be352e5 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/extensions/test_undrop_clone_materialized_view.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/extensions/test_undrop_clone_materialized_view.py @@ -7,10 +7,10 @@ import pytest from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError -from rhosocial.activerecord.backend.expression import Column, QueryExpression +from rhosocial.activerecord.backend.expression import Column, QueryStatement from rhosocial.activerecord.backend.expression.objects import MaterializedView, Table from rhosocial.activerecord.backend.expression.statements.ddl_view import ( - CreateMaterializedViewExpression, + CreateMaterializedViewStatement, ) from rhosocial.activerecord.backend.impl.snowflake.dialect import SnowflakeDialect from rhosocial.activerecord.backend.impl.snowflake.protocols import ( @@ -19,8 +19,8 @@ SnowflakeUndropSupport, ) from rhosocial.activerecord.backend.impl.snowflake.expression import ( - SnowflakeCreateMaterializedViewExpression, - SnowflakeUndropExpression, + SnowflakeCreateMaterializedViewStatement, + SnowflakeUndropStatement, SnowflakeUndropObjectType, ) @@ -44,7 +44,7 @@ class TestSnowflakeUndrop: """UNDROP statement generation.""" def test_undrop_table(self, dialect): - expr = SnowflakeUndropExpression( + expr = SnowflakeUndropStatement( dialect, "t", object_type=SnowflakeUndropObjectType.TABLE ) sql, params = expr.to_sql() @@ -52,21 +52,21 @@ def test_undrop_table(self, dialect): assert params == () def test_undrop_schema(self, dialect): - expr = SnowflakeUndropExpression( + expr = SnowflakeUndropStatement( dialect, "s", object_type=SnowflakeUndropObjectType.SCHEMA ) sql, _ = expr.to_sql() assert sql == 'UNDROP SCHEMA "s"' def test_undrop_database(self, dialect): - expr = SnowflakeUndropExpression( + expr = SnowflakeUndropStatement( dialect, "d", object_type=SnowflakeUndropObjectType.DATABASE ) sql, _ = expr.to_sql() assert sql == 'UNDROP DATABASE "d"' def test_undrop_defaults_to_table(self, dialect): - expr = SnowflakeUndropExpression(dialect, "t") + expr = SnowflakeUndropStatement(dialect, "t") sql, _ = expr.to_sql() assert sql == 'UNDROP TABLE "t"' @@ -95,7 +95,7 @@ class TestSnowflakeCreateMaterializedView: """CREATE [OR REPLACE] MATERIALIZED VIEW statement generation.""" def test_create_materialized_view_basic(self, dialect): - expr = SnowflakeCreateMaterializedViewExpression( + expr = SnowflakeCreateMaterializedViewStatement( dialect, "mv", as_query="SELECT c1, SUM(c2) FROM t GROUP BY c1" ) sql, params = expr.to_sql() @@ -106,14 +106,14 @@ def test_create_materialized_view_basic(self, dialect): assert params == () def test_create_materialized_view_or_replace(self, dialect): - expr = SnowflakeCreateMaterializedViewExpression( + expr = SnowflakeCreateMaterializedViewStatement( dialect, "mv", or_replace=True, as_query="SELECT 1" ) sql, _ = expr.to_sql() assert sql == 'CREATE OR REPLACE MATERIALIZED VIEW "mv" AS SELECT 1' def test_create_materialized_view_cluster_by(self, dialect): - expr = SnowflakeCreateMaterializedViewExpression( + expr = SnowflakeCreateMaterializedViewStatement( dialect, "mv", cluster_by=["c1"], @@ -126,7 +126,7 @@ def test_create_materialized_view_cluster_by(self, dialect): ) def test_create_materialized_view_column_list(self, dialect): - expr = SnowflakeCreateMaterializedViewExpression( + expr = SnowflakeCreateMaterializedViewStatement( dialect, "mv", column_list=["a", "b"], @@ -139,7 +139,7 @@ def test_create_materialized_view_column_list(self, dialect): ) def test_create_materialized_view_requires_as_query(self, dialect): - expr = SnowflakeCreateMaterializedViewExpression(dialect, "mv") + expr = SnowflakeCreateMaterializedViewStatement(dialect, "mv") with pytest.raises(ValueError): expr.to_sql() @@ -147,21 +147,21 @@ def test_create_materialized_view_requires_as_query(self, dialect): class TestSnowflakeGenericMaterializedViewInterop: """The core MV expression must render on Snowflake. - ``SnowflakeCreateMaterializedViewExpression`` extends the core expression, so + ``SnowflakeCreateMaterializedViewStatement`` extends the core expression, so a backend advertising ``supports_materialized_view()`` has to work with the generic API too. Previously the formatter read ``expr.as_query`` only, and the generic expression raised ``AttributeError``. """ def _query(self, dialect): - return QueryExpression( + return QueryStatement( dialect=dialect, select=[Column(dialect, "c1")], from_=Table(dialect, "t"), ) def test_generic_expression_renders(self, dialect): - expr = CreateMaterializedViewExpression( + expr = CreateMaterializedViewStatement( dialect=dialect, view=MaterializedView(dialect, "mv"), query=self._query(dialect), @@ -171,7 +171,7 @@ def test_generic_expression_renders(self, dialect): assert params == () def test_generic_expression_with_column_aliases(self, dialect): - expr = CreateMaterializedViewExpression( + expr = CreateMaterializedViewStatement( dialect=dialect, view=MaterializedView(dialect, "mv"), query=self._query(dialect), @@ -181,7 +181,7 @@ def test_generic_expression_with_column_aliases(self, dialect): assert '("alias_c1")' in sql def test_generic_expression_requires_query(self, dialect): - expr = CreateMaterializedViewExpression( + expr = CreateMaterializedViewStatement( dialect=dialect, view=MaterializedView(dialect, "mv"), query=None ) with pytest.raises(ValueError): @@ -189,21 +189,21 @@ def test_generic_expression_requires_query(self, dialect): def test_snowflake_expression_accepts_core_field_names(self, dialect): """The core vocabulary (view/query) works on the Snowflake expression.""" - expr = SnowflakeCreateMaterializedViewExpression( + expr = SnowflakeCreateMaterializedViewStatement( dialect, view=MaterializedView(dialect, "mv"), query=self._query(dialect) ) sql, _ = expr.to_sql() assert sql.startswith('CREATE MATERIALIZED VIEW "mv" AS SELECT') def test_snowflake_expression_accepts_raw_query_string(self, dialect): - expr = SnowflakeCreateMaterializedViewExpression( + expr = SnowflakeCreateMaterializedViewStatement( dialect, "mv", as_query="SELECT 1" ) sql, _ = expr.to_sql() assert sql == 'CREATE MATERIALIZED VIEW "mv" AS SELECT 1' def test_field_name_aliases(self, dialect): - expr = SnowflakeCreateMaterializedViewExpression( + expr = SnowflakeCreateMaterializedViewStatement( dialect, "mv", as_query="SELECT 1", column_list=["a"] ) assert expr.name == expr.view.name == "mv" @@ -212,24 +212,24 @@ def test_field_name_aliases(self, dialect): def test_or_replace_and_if_not_exists_are_exclusive(self, dialect): with pytest.raises(ValueError): - SnowflakeCreateMaterializedViewExpression( + SnowflakeCreateMaterializedViewStatement( dialect, "mv", as_query="SELECT 1", or_replace=True, if_not_exists=True ) def test_view_name_is_required(self, dialect): with pytest.raises(ValueError): - SnowflakeCreateMaterializedViewExpression(dialect, as_query="SELECT 1") + SnowflakeCreateMaterializedViewStatement(dialect, as_query="SELECT 1") def test_tablespace_is_rejected(self, dialect): """Snowflake has no TABLESPACE — refuse instead of dropping the clause.""" - expr = SnowflakeCreateMaterializedViewExpression( + expr = SnowflakeCreateMaterializedViewStatement( dialect, "mv", as_query="SELECT 1", tablespace="fast_ssd" ) with pytest.raises(UnsupportedFeatureError): expr.to_sql() def test_storage_options_are_rejected(self, dialect): - expr = SnowflakeCreateMaterializedViewExpression( + expr = SnowflakeCreateMaterializedViewStatement( dialect, "mv", as_query="SELECT 1", storage_options={"fillfactor": 70} ) with pytest.raises(UnsupportedFeatureError): @@ -237,7 +237,7 @@ def test_storage_options_are_rejected(self, dialect): def test_with_data_is_refused_by_name(self, dialect): """Snowflake has no WITH [NO] DATA; the inherited pair must not leak out.""" - expr = CreateMaterializedViewExpression( + expr = CreateMaterializedViewStatement( dialect=dialect, view=MaterializedView(dialect, "mv"), query=self._query(dialect), @@ -248,7 +248,7 @@ def test_with_data_is_refused_by_name(self, dialect): assert exc_info.value.feature_name == "MATERIALIZED VIEW WITH [NO] DATA" def test_no_data_is_refused_by_name(self, dialect): - expr = CreateMaterializedViewExpression( + expr = CreateMaterializedViewStatement( dialect=dialect, view=MaterializedView(dialect, "mv"), query=self._query(dialect), @@ -260,7 +260,7 @@ def test_no_data_is_refused_by_name(self, dialect): def test_unspecified_renders_without_the_clause(self, dialect): """Neither parameter set: Snowflake creates the view without either word.""" - expr = CreateMaterializedViewExpression( + expr = CreateMaterializedViewStatement( dialect=dialect, view=MaterializedView(dialect, "mv"), query=self._query(dialect), @@ -273,7 +273,7 @@ def test_with_data_pair_is_mutually_exclusive(self, dialect): with pytest.raises( ValueError, match="with_data and no_data are mutually exclusive" ): - CreateMaterializedViewExpression( + CreateMaterializedViewStatement( dialect=dialect, view=MaterializedView(dialect, "mv"), query=self._query(dialect), diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/extensions/test_warehouse_stage_copy.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/extensions/test_warehouse_stage_copy.py index 6c48bb3..a0481c6 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/extensions/test_warehouse_stage_copy.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/extensions/test_warehouse_stage_copy.py @@ -12,15 +12,15 @@ SnowflakeWarehouseSupport, ) from rhosocial.activerecord.backend.impl.snowflake.expression import ( - SnowflakeAlterWarehouseExpression, + SnowflakeAlterWarehouseStatement, SnowflakeAlterWarehouseMode, - SnowflakeAlterStageExpression, - SnowflakeCopyIntoExpression, + SnowflakeAlterStageStatement, + SnowflakeCopyIntoStatement, SnowflakeCopyIntoMode, - SnowflakeCreateStageExpression, - SnowflakeCreateWarehouseExpression, - SnowflakeDropStageExpression, - SnowflakeDropWarehouseExpression, + SnowflakeCreateStageStatement, + SnowflakeCreateWarehouseStatement, + SnowflakeDropStageStatement, + SnowflakeDropWarehouseStatement, ) @@ -49,13 +49,13 @@ class TestSnowflakeCreateWarehouse: """CREATE WAREHOUSE statement generation.""" def test_create_warehouse_basic(self, dialect): - expr = SnowflakeCreateWarehouseExpression(dialect, "my_wh") + expr = SnowflakeCreateWarehouseStatement(dialect, "my_wh") sql, params = expr.to_sql() assert sql == 'CREATE WAREHOUSE "my_wh"' assert params == () def test_create_warehouse_full_options(self, dialect): - expr = SnowflakeCreateWarehouseExpression( + expr = SnowflakeCreateWarehouseStatement( dialect, "my_wh", or_replace=True, @@ -83,7 +83,7 @@ def test_create_warehouse_full_options(self, dialect): assert params == () def test_create_warehouse_string_escaped(self, dialect): - expr = SnowflakeCreateWarehouseExpression( + expr = SnowflakeCreateWarehouseStatement( dialect, "my_wh", comment="it's quoted" ) sql, _ = expr.to_sql() @@ -94,7 +94,7 @@ class TestSnowflakeAlterWarehouse: """ALTER WAREHOUSE SUSPEND / RESUME / SET / RENAME generation.""" def test_alter_warehouse_suspend(self, dialect): - expr = SnowflakeAlterWarehouseExpression( + expr = SnowflakeAlterWarehouseStatement( dialect, "my_wh", mode=SnowflakeAlterWarehouseMode.SUSPEND ) sql, params = expr.to_sql() @@ -102,14 +102,14 @@ def test_alter_warehouse_suspend(self, dialect): assert params == () def test_alter_warehouse_resume(self, dialect): - expr = SnowflakeAlterWarehouseExpression( + expr = SnowflakeAlterWarehouseStatement( dialect, "my_wh", mode=SnowflakeAlterWarehouseMode.RESUME ) sql, _ = expr.to_sql() assert sql == 'ALTER WAREHOUSE "my_wh" RESUME' def test_alter_warehouse_set(self, dialect): - expr = SnowflakeAlterWarehouseExpression( + expr = SnowflakeAlterWarehouseStatement( dialect, "my_wh", mode=SnowflakeAlterWarehouseMode.SET, @@ -119,7 +119,7 @@ def test_alter_warehouse_set(self, dialect): assert sql == 'ALTER WAREHOUSE "my_wh" SET WAREHOUSE_SIZE = \'MEDIUM\'' def test_alter_warehouse_set_multiple(self, dialect): - expr = SnowflakeAlterWarehouseExpression( + expr = SnowflakeAlterWarehouseStatement( dialect, "my_wh", mode=SnowflakeAlterWarehouseMode.SET, @@ -130,7 +130,7 @@ def test_alter_warehouse_set_multiple(self, dialect): assert sql == 'ALTER WAREHOUSE "my_wh" SET MAX_CLUSTER_COUNT = 3 AUTO_SUSPEND = FALSE' def test_alter_warehouse_rename(self, dialect): - expr = SnowflakeAlterWarehouseExpression( + expr = SnowflakeAlterWarehouseStatement( dialect, "my_wh", mode=SnowflakeAlterWarehouseMode.RENAME, @@ -140,7 +140,7 @@ def test_alter_warehouse_rename(self, dialect): assert sql == 'ALTER WAREHOUSE "my_wh" RENAME TO "new_wh"' def test_alter_warehouse_set_without_property_raises(self, dialect): - expr = SnowflakeAlterWarehouseExpression( + expr = SnowflakeAlterWarehouseStatement( dialect, "my_wh", mode=SnowflakeAlterWarehouseMode.SET ) with pytest.raises(ValueError): @@ -151,13 +151,13 @@ class TestSnowflakeDropWarehouse: """DROP WAREHOUSE statement generation.""" def test_drop_warehouse(self, dialect): - expr = SnowflakeDropWarehouseExpression(dialect, "my_wh") + expr = SnowflakeDropWarehouseStatement(dialect, "my_wh") sql, params = expr.to_sql() assert sql == 'DROP WAREHOUSE "my_wh"' assert params == () def test_drop_warehouse_if_exists(self, dialect): - expr = SnowflakeDropWarehouseExpression(dialect, "my_wh", if_exists=True) + expr = SnowflakeDropWarehouseStatement(dialect, "my_wh", if_exists=True) sql, _ = expr.to_sql() assert sql == 'DROP WAREHOUSE IF EXISTS "my_wh"' @@ -166,20 +166,20 @@ class TestSnowflakeCreateStage: """CREATE STAGE statement generation.""" def test_create_stage_basic(self, dialect): - expr = SnowflakeCreateStageExpression(dialect, "my_stage") + expr = SnowflakeCreateStageStatement(dialect, "my_stage") sql, params = expr.to_sql() assert sql == 'CREATE STAGE "my_stage"' assert params == () def test_create_stage_or_replace_temporary(self, dialect): - expr = SnowflakeCreateStageExpression( + expr = SnowflakeCreateStageStatement( dialect, "my_stage", or_replace=True, temporary=True ) sql, _ = expr.to_sql() assert sql == 'CREATE OR REPLACE TEMPORARY STAGE "my_stage"' def test_create_stage_full_options(self, dialect): - expr = SnowflakeCreateStageExpression( + expr = SnowflakeCreateStageStatement( dialect, "my_stage", url="s3://bucket/path", @@ -199,7 +199,7 @@ def test_create_stage_full_options(self, dialect): ) def test_create_stage_encryption_string_fragment(self, dialect): - expr = SnowflakeCreateStageExpression( + expr = SnowflakeCreateStageStatement( dialect, "my_stage", encryption="TYPE = 'SSE_S3'" ) sql, _ = expr.to_sql() @@ -210,13 +210,13 @@ class TestSnowflakeAlterDropStage: """ALTER / DROP STAGE statement generation.""" def test_alter_stage_set_file_format(self, dialect): - expr = SnowflakeAlterStageExpression(dialect, "my_stage", file_format="csv") + expr = SnowflakeAlterStageStatement(dialect, "my_stage", file_format="csv") sql, params = expr.to_sql() assert sql == 'ALTER STAGE "my_stage" SET FILE_FORMAT = "csv"' assert params == () def test_alter_stage_set_url(self, dialect): - expr = SnowflakeAlterStageExpression( + expr = SnowflakeAlterStageStatement( dialect, "my_stage", url="azure://container/path" ) sql, _ = expr.to_sql() @@ -225,18 +225,18 @@ def test_alter_stage_set_url(self, dialect): ) def test_alter_stage_without_property_raises(self, dialect): - expr = SnowflakeAlterStageExpression(dialect, "my_stage") + expr = SnowflakeAlterStageStatement(dialect, "my_stage") with pytest.raises(ValueError): expr.to_sql() def test_drop_stage(self, dialect): - expr = SnowflakeDropStageExpression(dialect, "my_stage") + expr = SnowflakeDropStageStatement(dialect, "my_stage") sql, params = expr.to_sql() assert sql == 'DROP STAGE "my_stage"' assert params == () def test_drop_stage_if_exists(self, dialect): - expr = SnowflakeDropStageExpression(dialect, "my_stage", if_exists=True) + expr = SnowflakeDropStageStatement(dialect, "my_stage", if_exists=True) sql, _ = expr.to_sql() assert sql == 'DROP STAGE IF EXISTS "my_stage"' @@ -245,7 +245,7 @@ class TestSnowflakeCopyIntoLoad: """COPY INTO
FROM @ (load direction).""" def test_copy_into_load_basic(self, dialect): - expr = SnowflakeCopyIntoExpression( + expr = SnowflakeCopyIntoStatement( dialect, mode=SnowflakeCopyIntoMode.LOAD, table="t", @@ -256,7 +256,7 @@ def test_copy_into_load_basic(self, dialect): assert params == () def test_copy_into_load_full_options(self, dialect): - expr = SnowflakeCopyIntoExpression( + expr = SnowflakeCopyIntoStatement( dialect, mode=SnowflakeCopyIntoMode.LOAD, table="t", @@ -282,7 +282,7 @@ def test_copy_into_load_full_options(self, dialect): ) def test_copy_into_load_file_format_string_fragment(self, dialect): - expr = SnowflakeCopyIntoExpression( + expr = SnowflakeCopyIntoStatement( dialect, mode=SnowflakeCopyIntoMode.LOAD, table="t", @@ -297,7 +297,7 @@ class TestSnowflakeCopyIntoUnload: """COPY INTO @ FROM
(unload direction).""" def test_copy_into_unload_basic(self, dialect): - expr = SnowflakeCopyIntoExpression( + expr = SnowflakeCopyIntoStatement( dialect, mode=SnowflakeCopyIntoMode.UNLOAD, table="t", @@ -308,7 +308,7 @@ def test_copy_into_unload_basic(self, dialect): assert params == () def test_copy_into_unload_full_options(self, dialect): - expr = SnowflakeCopyIntoExpression( + expr = SnowflakeCopyIntoStatement( dialect, mode=SnowflakeCopyIntoMode.UNLOAD, table="t", diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/functions/test_semi_structured_functions.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/functions/test_semi_structured_functions.py new file mode 100644 index 0000000..b726a5f --- /dev/null +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/functions/test_semi_structured_functions.py @@ -0,0 +1,95 @@ +# tests/rhosocial/activerecord_snowflake_test/feature/backend/functions/test_semi_structured_functions.py +"""Rendering assertions for the semi-structured function factories. + +**not server-verified** -- this repository reaches no Snowflake instance, so +every assertion below is a rendering assertion over the documented spellings, +never a measurement. When a live server contradicts a spelling here, change +the spelling, not the test. + +The boundary this file pins is the one the function wave draws: a factory +whose function core has classed migrates to that class (``ARRAY_AGG`` is +``ArrayAggregateFunction``, with DISTINCT and the window clauses an aggregate +takes, and an artifact naming ``array_aggregate_function``); a function core +has not classed stays a bare ``FunctionCall`` until core classes it -- +``ARRAY_CONSTRUCT`` and ``PARSE_JSON`` are pinned here as the stay-side +witnesses, so a future classing is a visible change to this file rather than +a silent drift. +""" + +import pytest + +from rhosocial.activerecord.backend.expression.core import Column, FunctionCall +from rhosocial.activerecord.backend.expression.functions.array import ( + ArrayAggregateFunction, +) +from rhosocial.activerecord.backend.impl.snowflake.dialect import SnowflakeDialect +from rhosocial.activerecord.backend.impl.snowflake.functions import ( + array_agg, + array_construct, + parse_json, +) + + +@pytest.fixture +def dialect(): + return SnowflakeDialect(version=(8, 0, 0)) + + +class TestArrayAggIsTheClassedAggregate: + """``ARRAY_AGG`` renders through the node core classes for it.""" + + def test_the_factory_returns_the_classed_node(self, dialect): + node = array_agg(dialect, "tags") + assert isinstance(node, ArrayAggregateFunction) + assert not isinstance(node, FunctionCall) + + def test_a_bare_string_becomes_a_column_as_before(self, dialect): + node = array_agg(dialect, "tags") + assert isinstance(node.operand, Column) + assert node.operand.name == "tags" + + def test_the_spelling_is_what_the_call_used_to_render(self, dialect): + sql, params = array_agg(dialect, "tags").to_sql() + assert sql == 'ARRAY_AGG("tags")' + assert params == () + + def test_an_expression_operand_passes_through(self, dialect): + operand = Column(dialect, "tag", table="rows") + sql, params = array_agg(dialect, operand).to_sql() + assert sql == 'ARRAY_AGG("rows"."tag")' + assert params == () + + def test_the_aggregate_clauses_render_through_the_node(self, dialect): + """DISTINCT and the alias are the aggregate capabilities the call lacked.""" + node = array_agg(dialect, "tags").as_("collected") + sql, _ = node.to_sql() + assert sql == 'ARRAY_AGG("tags") AS "collected"' + + distinct = ArrayAggregateFunction( + dialect, array_agg(dialect, "tags").operand, distinct=True + ) + sql, _ = distinct.to_sql() + assert sql == 'ARRAY_AGG(DISTINCT "tags")' + + +class TestTheUnclassedOnesStayCalls: + """Functions core has not classed keep their bare ``FunctionCall`` shape. + + Each assertion names the function it watches, so a future classing in core + fails this file by changing a type -- the migration then becomes a + deliberate edit here rather than a silent drift. + """ + + def test_array_construct_is_still_a_call(self, dialect): + node = array_construct(dialect, "a", "b") + assert type(node) is FunctionCall + sql, params = node.to_sql() + assert sql == 'ARRAY_CONSTRUCT("a", "b")' + assert params == () + + def test_parse_json_is_still_a_call(self, dialect): + node = parse_json(dialect, "payload") + assert type(node) is FunctionCall + sql, params = node.to_sql() + assert sql == 'PARSE_JSON("payload")' + assert params == () diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/introspection/test_account_introspection.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/introspection/test_account_introspection.py new file mode 100644 index 0000000..5ac043e --- /dev/null +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/introspection/test_account_introspection.py @@ -0,0 +1,660 @@ +# tests/rhosocial/activerecord_snowflake_test/feature/backend/introspection/test_account_introspection.py +"""Account-object introspection: SQL text, row parsing, caching, symmetry. + +Every test here is offline. Rows are fabricated to match the documented +column names and value domains; no Snowflake account is contacted, and a +passing test pins the statement and the parser, never the account's answer. + +The statements are built by the expression classes in +``rhosocial.activerecord.backend.impl.snowflake.expression.introspection`` and +rendered by the dialect mixin, so each SQL expectation below is the output of a +tree of ``Column`` / ``ComparisonPredicate`` / ``Literal`` / ``QueryStatement`` +nodes rather than of a text builder. Any statement that stops being built that +way fails here as a text mismatch. + +Documentation the expectations were written from: + +* https://docs.snowflake.com/en/sql-reference/info-schema/databases +* https://docs.snowflake.com/en/sql-reference/info-schema/schemata +* https://docs.snowflake.com/en/sql-reference/sql/show-warehouses +* https://docs.snowflake.com/en/sql-reference/info-schema/table_storage_metrics +* https://docs.snowflake.com/en/sql-reference/info-schema/tables +""" +import inspect +from dataclasses import FrozenInstanceError +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from rhosocial.activerecord.backend.impl.snowflake.dialect import SnowflakeDialect +from rhosocial.activerecord.backend.impl.snowflake.introspection import ( + AsyncSnowflakeIntrospector, + SnowflakeDatabaseInfo, + SnowflakeIntrospectorMixin, + SnowflakeSchemaInfo, + SnowflakeTableStorageMetrics, + SnowflakeWarehouseInfo, + SyncSnowflakeIntrospector, +) +from rhosocial.activerecord.backend.impl.snowflake.expression.introspection import ( + SnowflakeDatabaseListQueryStatement, + SnowflakeSchemaListQueryStatement, + SnowflakeTableStorageMetricsQueryStatement, + SnowflakeWarehouseListQueryStatement, +) +from rhosocial.activerecord.backend.introspection.executor import ( + SyncIntrospectorExecutor, +) + +DATABASES_SQL = ( + "SELECT DATABASE_NAME, DATABASE_OWNER, IS_TRANSIENT, COMMENT, " + "CREATED, LAST_ALTERED, RETENTION_TIME, TYPE, OWNER_ROLE_TYPE " + "FROM SNOWFLAKE.INFORMATION_SCHEMA.DATABASES " + "ORDER BY DATABASE_NAME" +) + +SCHEMAS_SQL = ( + "SELECT CATALOG_NAME, SCHEMA_NAME, SCHEMA_OWNER, IS_TRANSIENT, " + "IS_MANAGED_ACCESS, RETENTION_TIME, CREATED, LAST_ALTERED, COMMENT " + "FROM INFORMATION_SCHEMA.SCHEMATA ORDER BY SCHEMA_NAME" +) + +SCHEMAS_IN_DATABASE_SQL = ( + "SELECT CATALOG_NAME, SCHEMA_NAME, SCHEMA_OWNER, IS_TRANSIENT, " + "IS_MANAGED_ACCESS, RETENTION_TIME, CREATED, LAST_ALTERED, COMMENT " + 'FROM "ANALYTICS".INFORMATION_SCHEMA.SCHEMATA ORDER BY SCHEMA_NAME' +) + +STORAGE_SQL = ( + "SELECT m.TABLE_CATALOG, m.TABLE_SCHEMA, m.TABLE_NAME, m.ID, " + "m.CLONE_GROUP_ID, m.IS_TRANSIENT, m.ACTIVE_BYTES, " + "m.TIME_TRAVEL_BYTES, m.FAILSAFE_BYTES, m.RETAINED_FOR_CLONE_BYTES, " + "m.TABLE_CREATED, m.TABLE_DROPPED, m.TABLE_ENTERED_FAILSAFE, " + "m.COMMENT, t.TABLE_TYPE, t.ROW_COUNT, t.CLUSTERING_KEY, " + "t.AUTO_CLUSTERING_ON " + "FROM INFORMATION_SCHEMA.TABLE_STORAGE_METRICS AS m " + "LEFT JOIN INFORMATION_SCHEMA.TABLES AS t " + "ON t.TABLE_CATALOG = m.TABLE_CATALOG " + "AND t.TABLE_SCHEMA = m.TABLE_SCHEMA " + "AND t.TABLE_NAME = m.TABLE_NAME " + "WHERE m.TABLE_NAME = %s AND m.TABLE_DROPPED IS NULL " + "AND m.TABLE_SCHEMA = %s ORDER BY m.TABLE_CREATED DESC" +) + + +@pytest.fixture +def dialect(): + return SnowflakeDialect(version=(8, 32, 0)) + + +@pytest.fixture +def mock_backend(dialect): + backend = MagicMock() + backend._version = (8, 32, 0) + backend.config = MagicMock() + backend.config.schema = "PUBLIC" + backend.config.database = "MY_DB" + backend.dialect = dialect + return backend + + +@pytest.fixture +def sync_executor(): + return MagicMock(spec=SyncIntrospectorExecutor) + + +@pytest.fixture +def sync_introspector(mock_backend, sync_executor): + return SyncSnowflakeIntrospector(mock_backend, sync_executor) + + +@pytest.fixture +def async_executor(): + return AsyncMock() + + +@pytest.fixture +def async_introspector(mock_backend, async_executor): + return AsyncSnowflakeIntrospector(mock_backend, async_executor) + + +def database_row(**overrides): + row = { + "DATABASE_NAME": "ANALYTICS", + "DATABASE_OWNER": "SYSADMIN", + "IS_TRANSIENT": "NO", + "COMMENT": "reporting", + "CREATED": datetime(2026, 1, 2, 3, 4, 5), + "LAST_ALTERED": datetime(2026, 2, 3, 4, 5, 6), + "RETENTION_TIME": 1, + "TYPE": "STANDARD", + "OWNER_ROLE_TYPE": "ROLE", + } + row.update(overrides) + return row + + +def schema_row(**overrides): + row = { + "CATALOG_NAME": "ANALYTICS", + "SCHEMA_NAME": "PUBLIC", + "SCHEMA_OWNER": "SYSADMIN", + "IS_TRANSIENT": "YES", + "IS_MANAGED_ACCESS": "NO", + "RETENTION_TIME": 0, + "CREATED": datetime(2026, 1, 2, 3, 4, 5), + "LAST_ALTERED": datetime(2026, 2, 3, 4, 5, 6), + "COMMENT": None, + } + row.update(overrides) + return row + + +def warehouse_row(**overrides): + """A ``SHOW WAREHOUSES`` row, in the lowercase shape the page shows.""" + row = { + "name": "COMPUTE_WH", + "state": "STARTED", + "type": "STANDARD", + "size": "X-Small", + "min_cluster_count": 1, + "max_cluster_count": 2, + "started_clusters": 1, + "running": 0, + "queued": 0, + "is_default": "Y", + "is_current": "N", + "is_interactive": "N", + "auto_suspend": 60, + "auto_resume": "Y", + "created_on": datetime(2026, 1, 1), + "resumed_on": datetime(2026, 1, 2), + "updated_on": datetime(2026, 1, 3), + "owner": "SYSADMIN", + "comment": "primary", + "scaling_policy": "STANDARD", + "resource_constraint": None, + "generation": 2, + "enable_query_acceleration": "N", + "query_acceleration_max_scale_factor": 8, + "resource_monitor": None, + "owner_role_type": "ROLE", + } + row.update(overrides) + return row + + +def storage_row(**overrides): + row = { + "TABLE_CATALOG": "ANALYTICS", + "TABLE_SCHEMA": "PUBLIC", + "TABLE_NAME": "ORDERS", + "ID": 42, + "CLONE_GROUP_ID": 42, + "IS_TRANSIENT": "NO", + "ACTIVE_BYTES": 1024, + "TIME_TRAVEL_BYTES": 200, + "FAILSAFE_BYTES": 30, + "RETAINED_FOR_CLONE_BYTES": 5, + "TABLE_CREATED": datetime(2026, 1, 2), + "TABLE_DROPPED": None, + "TABLE_ENTERED_FAILSAFE": None, + "COMMENT": "orders", + "TABLE_TYPE": "BASE TABLE", + "ROW_COUNT": 17, + "CLUSTERING_KEY": "LINEAR(ORDER_DATE)", + "AUTO_CLUSTERING_ON": True, + } + row.update(overrides) + return row + + +# ================================================================== # +# SQL text -- the rendered tree, pinned +# ================================================================== # + +class TestAccountObjectSQL: + """Each statement class renders the documented SQL through the dialect. + + The expectations are the output of ``Expression.to_sql()``, so they pin + the whole path: node construction, the dialect formatter, the placeholder + spelling and the alias quoting. A statement that falls back to text + concatenation loses the ``AS m`` / ``AS t`` row-source spellings and fails + here. + """ + + def test_databases_sql_is_exact(self, dialect): + assert SnowflakeDatabaseListQueryStatement(dialect).to_sql() == ( + DATABASES_SQL, + (), + ) + + def test_schemas_sql_without_database_is_exact(self, dialect): + assert SnowflakeSchemaListQueryStatement(dialect).to_sql() == ( + SCHEMAS_SQL, + (), + ) + + def test_schemas_sql_qualifies_the_database(self, dialect): + # A database name is part of the view's name, so it is rendered as a + # quoted identifier rather than bound. + assert SnowflakeSchemaListQueryStatement( + dialect, database="ANALYTICS" + ).to_sql() == (SCHEMAS_IN_DATABASE_SQL, ()) + + def test_warehouses_use_show_not_a_view(self, dialect): + # There is no INFORMATION_SCHEMA.WAREHOUSES; the documented route is + # SHOW WAREHOUSES, rendered by the SHOW statement expression. + assert SnowflakeWarehouseListQueryStatement(dialect).to_sql() == ( + "SHOW WAREHOUSES", + (), + ) + + def test_storage_sql_is_exact(self, dialect): + assert SnowflakeTableStorageMetricsQueryStatement( + dialect, "ORDERS", schema="PUBLIC" + ).to_sql() == (STORAGE_SQL, ("ORDERS", "PUBLIC")) + + def test_storage_sql_omits_schema_filter_when_absent(self, dialect): + sql, params = SnowflakeTableStorageMetricsQueryStatement( + dialect, "ORDERS" + ).to_sql() + assert "AND m.TABLE_SCHEMA = %s" not in sql + assert sql.endswith("ORDER BY m.TABLE_CREATED DESC") + assert params == ("ORDERS",) + + def test_statements_bind_values_and_never_interpolate_them(self, dialect): + """A caller-supplied name is a bind parameter, not SQL text. + + The one exception is the database a caller asks for schemas of, which + is part of the view's name and therefore an identifier. A table name + is a value compared to a column, so it must travel as a parameter. + """ + sql, params = SnowflakeTableStorageMetricsQueryStatement( + dialect, "ORDERS'); DROP TABLE users;--", schema="PUBLIC" + ).to_sql() + assert params == ("ORDERS'); DROP TABLE users;--", "PUBLIC") + assert "DROP TABLE" not in sql + assert sql.count("%s") == 2 + + +# ================================================================== # +# Row parsing +# ================================================================== # + +class TestAccountObjectParsing: + @pytest.fixture + def parser(self): + return SnowflakeIntrospectorMixin.__new__(SnowflakeIntrospectorMixin) + + def test_database_row_maps_to_typed_value(self, parser): + [database] = parser.parse_databases([database_row()]) + assert database == SnowflakeDatabaseInfo( + name="ANALYTICS", + owner="SYSADMIN", + is_transient=False, + comment="reporting", + created=datetime(2026, 1, 2, 3, 4, 5), + last_altered=datetime(2026, 2, 3, 4, 5, 6), + retention_time=1, + database_type="STANDARD", + owner_role_type="ROLE", + ) + + def test_database_transient_flag_reads_yes(self, parser): + [database] = parser.parse_databases( + [database_row(IS_TRANSIENT="YES")] + ) + assert database.is_transient is True + + def test_database_keys_are_case_insensitive(self, parser): + row = {key.lower(): value for key, value in database_row().items()} + [database] = parser.parse_databases([row]) + assert database.name == "ANALYTICS" + + def test_database_unreadable_number_becomes_none(self, parser): + [database] = parser.parse_databases( + [database_row(RETENTION_TIME="not-a-number")] + ) + assert database.retention_time is None + + def test_schema_row_maps_to_typed_value(self, parser): + [schema] = parser.parse_schemas([schema_row()]) + assert schema == SnowflakeSchemaInfo( + name="PUBLIC", + catalog_name="ANALYTICS", + owner="SYSADMIN", + is_transient=True, + is_managed_access=False, + retention_time=0, + created=datetime(2026, 1, 2, 3, 4, 5), + last_altered=datetime(2026, 2, 3, 4, 5, 6), + comment=None, + ) + + def test_schema_managed_access_reads_yes(self, parser): + [schema] = parser.parse_schemas( + [schema_row(IS_MANAGED_ACCESS="YES")] + ) + assert schema.is_managed_access is True + + def test_warehouse_row_maps_lowercase_keys(self, parser): + [warehouse] = parser.parse_warehouses([warehouse_row()]) + assert warehouse == SnowflakeWarehouseInfo( + name="COMPUTE_WH", + state="STARTED", + warehouse_type="STANDARD", + size="X-Small", + min_cluster_count=1, + max_cluster_count=2, + started_clusters=1, + running=0, + queued=0, + is_default=True, + is_current=False, + is_interactive=False, + auto_suspend=60, + auto_resume=True, + created_on=datetime(2026, 1, 1), + resumed_on=datetime(2026, 1, 2), + updated_on=datetime(2026, 1, 3), + owner="SYSADMIN", + comment="primary", + scaling_policy="STANDARD", + resource_constraint=None, + generation=2, + enable_query_acceleration=False, + query_acceleration_max_scale_factor=8, + resource_monitor=None, + owner_role_type="ROLE", + ) + + def test_warehouse_auto_suspend_null_means_never(self, parser): + [warehouse] = parser.parse_warehouses( + [warehouse_row(auto_suspend=None, auto_resume=None)] + ) + assert warehouse.auto_suspend is None + assert warehouse.auto_resume is None + + def test_storage_row_maps_and_sums_bytes(self, parser): + metrics = parser.parse_table_storage_metrics( + [storage_row()], "ORDERS", "PUBLIC" + ) + assert metrics == SnowflakeTableStorageMetrics( + table_name="ORDERS", + table_catalog="ANALYTICS", + table_schema="PUBLIC", + table_id=42, + clone_group_id=42, + is_transient=False, + table_type="BASE TABLE", + active_bytes=1024, + time_travel_bytes=200, + failsafe_bytes=30, + retained_for_clone_bytes=5, + row_count=17, + clustering_key="LINEAR(ORDER_DATE)", + auto_clustering_on=True, + table_created=datetime(2026, 1, 2), + table_dropped=None, + table_entered_failsafe=None, + comment="orders", + ) + assert metrics.total_billed_bytes == 1259 + assert metrics.is_dropped is False + assert metrics.is_clustered is True + + def test_storage_prefers_the_current_version(self, parser): + dropped = storage_row( + TABLE_DROPPED=datetime(2026, 2, 1), + ACTIVE_BYTES=1, + TABLE_NAME="ORDERS", + ) + metrics = parser.parse_table_storage_metrics( + [dropped, storage_row()], "ORDERS", "PUBLIC" + ) + assert metrics.table_dropped is None + assert metrics.active_bytes == 1024 + + def test_storage_without_rows_is_none(self, parser): + assert ( + parser.parse_table_storage_metrics([], "ORDERS", "PUBLIC") + is None + ) + + def test_storage_unclustered_table(self, parser): + metrics = parser.parse_table_storage_metrics( + [ + storage_row( + CLUSTERING_KEY=None, + AUTO_CLUSTERING_ON=False, + ROW_COUNT=0, + ) + ], + "ORDERS", + "PUBLIC", + ) + assert metrics.is_clustered is False + assert metrics.row_count == 0 + assert metrics.auto_clustering_on is False + + +# ================================================================== # +# Sync public API: exact statements and warm cache +# ================================================================== # + +class TestSyncAccountObjectIntrospection: + def test_list_databases_executes_documented_sql( + self, sync_introspector, sync_executor + ): + sync_executor.execute.return_value = [database_row()] + databases = sync_introspector.list_databases() + assert databases == [SnowflakeDatabaseInfo( + name="ANALYTICS", + owner="SYSADMIN", + is_transient=False, + comment="reporting", + created=datetime(2026, 1, 2, 3, 4, 5), + last_altered=datetime(2026, 2, 3, 4, 5, 6), + retention_time=1, + database_type="STANDARD", + owner_role_type="ROLE", + )] + sync_executor.execute.assert_called_once_with(DATABASES_SQL, ()) + + def test_list_databases_second_call_is_a_cache_hit( + self, sync_introspector, sync_executor + ): + sync_executor.execute.return_value = [database_row()] + first = sync_introspector.list_databases() + second = sync_introspector.list_databases() + assert first is second + assert sync_executor.execute.call_count == 1 + + def test_list_schemas_passes_database_as_identifier( + self, sync_introspector, sync_executor + ): + sync_executor.execute.return_value = [schema_row()] + schemas = sync_introspector.list_schemas("ANALYTICS") + assert schemas == [SnowflakeSchemaInfo( + name="PUBLIC", + catalog_name="ANALYTICS", + owner="SYSADMIN", + is_transient=True, + is_managed_access=False, + retention_time=0, + created=datetime(2026, 1, 2, 3, 4, 5), + last_altered=datetime(2026, 2, 3, 4, 5, 6), + comment=None, + )] + sync_executor.execute.assert_called_once_with( + SCHEMAS_IN_DATABASE_SQL, () + ) + + def test_list_schemas_does_not_share_between_databases( + self, sync_introspector, sync_executor + ): + sync_executor.execute.return_value = [schema_row()] + sync_introspector.list_schemas() + sync_introspector.list_schemas("ANALYTICS") + sync_introspector.list_schemas("ANALYTICS") + assert sync_executor.execute.call_count == 2 + + def test_list_warehouses_executes_show( + self, sync_introspector, sync_executor + ): + sync_executor.execute.return_value = [warehouse_row()] + [warehouse] = sync_introspector.list_warehouses() + assert warehouse.name == "COMPUTE_WH" + assert warehouse.is_default is True + sync_executor.execute.assert_called_once_with("SHOW WAREHOUSES", ()) + + def test_list_warehouses_second_call_is_a_cache_hit( + self, sync_introspector, sync_executor + ): + sync_executor.execute.return_value = [warehouse_row()] + sync_introspector.list_warehouses() + sync_introspector.list_warehouses() + assert sync_executor.execute.call_count == 1 + + def test_storage_metrics_uses_configured_schema( + self, sync_introspector, sync_executor + ): + sync_executor.execute.return_value = [storage_row()] + metrics = sync_introspector.get_table_storage_metrics("ORDERS") + assert metrics.table_schema == "PUBLIC" + sync_executor.execute.assert_called_once_with( + STORAGE_SQL, ("ORDERS", "PUBLIC") + ) + + def test_storage_metrics_second_call_is_a_cache_hit( + self, sync_introspector, sync_executor + ): + sync_executor.execute.return_value = [storage_row()] + first = sync_introspector.get_table_storage_metrics("ORDERS") + second = sync_introspector.get_table_storage_metrics("ORDERS") + assert first is second + assert sync_executor.execute.call_count == 1 + + def test_storage_metrics_none_is_not_cached( + self, sync_introspector, sync_executor + ): + sync_executor.execute.side_effect = [[], [storage_row()]] + assert sync_introspector.get_table_storage_metrics("ORDERS") is None + assert sync_introspector.get_table_storage_metrics("ORDERS") is not None + assert sync_executor.execute.call_count == 2 + + +# ================================================================== # +# Async public API mirrors the sync one +# ================================================================== # + +class TestAsyncAccountObjectIntrospection: + async def test_list_databases_awaits_the_same_sql( + self, async_introspector, async_executor + ): + async_executor.execute.return_value = [database_row()] + [database] = await async_introspector.list_databases() + assert database.name == "ANALYTICS" + async_executor.execute.assert_awaited_once_with(DATABASES_SQL, ()) + + async def test_list_databases_second_call_is_a_cache_hit( + self, async_introspector, async_executor + ): + async_executor.execute.return_value = [database_row()] + first = await async_introspector.list_databases() + second = await async_introspector.list_databases() + assert first is second + assert async_executor.execute.await_count == 1 + + async def test_list_schemas_awaits_the_same_sql( + self, async_introspector, async_executor + ): + async_executor.execute.return_value = [schema_row()] + [schema] = await async_introspector.list_schemas("ANALYTICS") + assert schema.name == "PUBLIC" + async_executor.execute.assert_awaited_once_with( + SCHEMAS_IN_DATABASE_SQL, () + ) + + async def test_list_warehouses_awaits_show( + self, async_introspector, async_executor + ): + async_executor.execute.return_value = [warehouse_row()] + [warehouse] = await async_introspector.list_warehouses() + assert warehouse.is_current is False + async_executor.execute.assert_awaited_once_with( + "SHOW WAREHOUSES", () + ) + + async def test_storage_metrics_awaits_the_same_sql( + self, async_introspector, async_executor + ): + async_executor.execute.return_value = [storage_row()] + metrics = await async_introspector.get_table_storage_metrics("ORDERS") + assert metrics.total_billed_bytes == 1259 + async_executor.execute.assert_awaited_once_with( + STORAGE_SQL, ("ORDERS", "PUBLIC") + ) + + async def test_storage_metrics_none_is_not_cached( + self, async_introspector, async_executor + ): + async_executor.execute.side_effect = [[], [storage_row()]] + assert ( + await async_introspector.get_table_storage_metrics("ORDERS") + is None + ) + assert ( + await async_introspector.get_table_storage_metrics("ORDERS") + is not None + ) + assert async_executor.execute.await_count == 2 + + +# ================================================================== # +# Value semantics and sync/async symmetry +# ================================================================== # + +class TestAccountObjectValues: + def test_values_are_frozen(self): + database = SnowflakeDatabaseInfo(name="ANALYTICS") + with pytest.raises(FrozenInstanceError): + database.name = "OTHER" # type: ignore[misc] + + def test_sync_and_async_methods_have_identical_signatures(self): + for name in ( + "list_databases", + "list_schemas", + "list_warehouses", + "get_table_storage_metrics", + ): + sync_signature = inspect.signature( + getattr(SyncSnowflakeIntrospector, name) + ) + async_signature = inspect.signature( + getattr(AsyncSnowflakeIntrospector, name) + ) + assert sync_signature == async_signature, name + + def test_every_statement_names_a_dialect_formatter(self, dialect): + """The tree dispatches through the dialect; no node renders itself. + + Each statement's ``format_method`` must be a ``format_*_query_statement`` + method this backend's dialect mixin actually implements, or ``to_sql()`` + raises at render time rather than producing wrong SQL. + """ + statements = ( + (SnowflakeDatabaseListQueryStatement, {}), + (SnowflakeSchemaListQueryStatement, {}), + (SnowflakeWarehouseListQueryStatement, {}), + (SnowflakeTableStorageMetricsQueryStatement, {"table_name": "T"}), + ) + for statement, kwargs in statements: + method = statement(dialect, **kwargs).format_method + assert method.endswith("_query_statement"), statement.__name__ + assert callable(getattr(dialect, method)), ( + f"{statement.__name__} renders through {method}, " + "which the dialect does not implement" + ) diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/introspection/test_example_account_objects.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/introspection/test_example_account_objects.py new file mode 100644 index 0000000..a7d01b9 --- /dev/null +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/introspection/test_example_account_objects.py @@ -0,0 +1,418 @@ +# tests/rhosocial/activerecord_snowflake_test/feature/backend/introspection/test_example_account_objects.py +"""The account-object example: refuses cleanly, and demonstrates the cache. + +No test here opens a Snowflake connection. Two paths are covered: + +* with no credentials the example prints the variables to set and exits 2, + having built no connection and no statement; +* with a mocked backend it runs end to end, so the cold/warm executor counts + it prints -- and the cache behaviour they claim -- really execute. + +The second path patches ``SnowflakeBackend`` in the example module, so nothing +in the example is skipped by a marker: the whole body of ``main()`` runs. +Copyright-free by design: no row is invented on the refusal path. +""" +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple +from unittest.mock import MagicMock, patch + +import pytest + +EXAMPLE = ( + "rhosocial.activerecord.backend.impl.snowflake.examples." + "introspection_account_objects" +) + + +def database_row(**overrides): + row = { + "DATABASE_NAME": "ANALYTICS", + "DATABASE_OWNER": "SYSADMIN", + "IS_TRANSIENT": "NO", + "COMMENT": "reporting", + "CREATED": datetime(2026, 1, 2, 3, 4, 5), + "LAST_ALTERED": datetime(2026, 2, 3, 4, 5, 6), + "RETENTION_TIME": 1, + "TYPE": "STANDARD", + "OWNER_ROLE_TYPE": "ROLE", + } + row.update(overrides) + return row + + +def warehouse_row(**overrides): + """A ``SHOW WAREHOUSES`` row, in the documented lowercase shape.""" + row = { + "name": "COMPUTE_WH", + "state": "STARTED", + "type": "STANDARD", + "size": "X-Small", + "min_cluster_count": 1, + "max_cluster_count": 1, + "started_clusters": 1, + "running": 0, + "queued": 0, + "is_default": "Y", + "is_current": "Y", + "is_interactive": "N", + "auto_suspend": 60, + "auto_resume": True, + "created_on": datetime(2026, 1, 1), + "resumed_on": None, + "updated_on": datetime(2026, 1, 3), + "owner": "SYSADMIN", + "comment": None, + "scaling_policy": "STANDARD", + "resource_constraint": None, + "generation": 1, + "enable_query_acceleration": False, + "query_acceleration_max_scale_factor": None, + "resource_monitor": None, + "owner_role_type": "ROLE", + } + row.update(overrides) + return row + + +def schema_row(**overrides): + row = { + "CATALOG_NAME": "ANALYTICS", + "SCHEMA_NAME": "PUBLIC", + "SCHEMA_OWNER": "SYSADMIN", + "IS_TRANSIENT": "NO", + "IS_MANAGED_ACCESS": "NO", + "RETENTION_TIME": 1, + "CREATED": datetime(2026, 1, 2, 3, 4, 5), + "LAST_ALTERED": None, + "COMMENT": None, + } + row.update(overrides) + return row + + +def storage_row(**overrides): + row = { + "TABLE_CATALOG": "ANALYTICS", + "TABLE_SCHEMA": "PUBLIC", + "TABLE_NAME": "ORDERS", + "ID": 7, + "CLONE_GROUP_ID": 7, + "IS_TRANSIENT": "NO", + "ACTIVE_BYTES": 2048, + "TIME_TRAVEL_BYTES": 128, + "FAILSAFE_BYTES": 64, + "RETAINED_FOR_CLONE_BYTES": 16, + "TABLE_CREATED": datetime(2026, 1, 2), + "TABLE_DROPPED": None, + "TABLE_ENTERED_FAILSAFE": None, + "COMMENT": None, + "TABLE_TYPE": "BASE TABLE", + "ROW_COUNT": 3, + "CLUSTERING_KEY": None, + "AUTO_CLUSTERING_ON": False, + } + row.update(overrides) + return row + + +def clean_env() -> Dict[str, Optional[str]]: + """Every variable the example reads, set to ``None`` (unset).""" + return { + name: None + for name in ( + "SNOWFLAKE_ACCOUNT", + "SNOWFLAKE_USER", + "SNOWFLAKE_PASSWORD", + "SNOWFLAKE_PRIVATE_KEY_PATH", + "SNOWFLAKE_DATABASE", + "SNOWFLAKE_WAREHOUSE", + "SNOWFLAKE_SCHEMA", + "SNOWFLAKE_ROLE", + ) + } + + +class _RecordingExecutor: + """A stand-in for the driver-backed executor, per the repo's mock pattern. + + It answers the statements the introspector really sends and counts them, + which is exactly what the example's own wrapper observes -- so the printed + cold/warm figures are this object's call counts, not a double mock. + + ``silent`` models the account the role has been granted nothing on: every + question is answered with no rows. The tables listing is answered with one + table unless ``silent`` is set, because the example needs a table name to + measure and a silent account has none. + """ + + def __init__(self, silent: bool = False) -> None: + self.silent = silent + self.statements: List[Tuple[str, tuple]] = [] + + def execute(self, sql: str, params: Tuple = ()) -> List[Dict[str, Any]]: + self.statements.append((sql, params)) + if self.silent: + return [] + if "SHOW WAREHOUSES" in sql: + return [warehouse_row()] + if "INFORMATION_SCHEMA.DATABASES" in sql: + return [database_row()] + if "INFORMATION_SCHEMA.SCHEMATA" in sql: + return [schema_row()] + if "TABLE_STORAGE_METRICS" in sql: + return [storage_row()] + if "INFORMATION_SCHEMA.TABLES" in sql: + return [ + {"TABLE_NAME": "ORDERS", "TABLE_TYPE": "BASE TABLE", "COMMENT": None} + ] + return [] + + @property + def question_count(self) -> int: + return len(self.statements) + + +@pytest.fixture +def example_module(): + """The example, imported only when a test needs it. + + Importing it pulls in the backend, which imports the Snowflake connector + package; that is a real import cost this suite would otherwise pay for + every test that merely mentions the example's name. + """ + import importlib + + return importlib.import_module(EXAMPLE) + + +def make_introspector(executor): + """A real sync introspector wired to *executor*. + + Deliberately the production class: the example's cache claims are about + :class:`SyncSnowflakeIntrospector`, so mocking the introspector instead + would test the mock. + """ + from rhosocial.activerecord.backend.impl.snowflake.dialect import SnowflakeDialect + from rhosocial.activerecord.backend.impl.snowflake.introspection import ( + SyncSnowflakeIntrospector, + ) + + backend = MagicMock() + backend._version = (8, 32, 0) + backend.config = MagicMock() + backend.config.schema = "PUBLIC" + backend.config.database = "ANALYTICS" + backend.dialect = SnowflakeDialect(version=(8, 32, 0)) + return SyncSnowflakeIntrospector(backend, executor) + + +@pytest.fixture +def introspector(): + instrumented = make_introspector(_RecordingExecutor()) + return instrumented, instrumented._executor + + +@pytest.fixture +def credentials(monkeypatch): + """A complete, fake credential environment.""" + unset_all(monkeypatch) + monkeypatch.setenv("SNOWFLAKE_ACCOUNT", "example-account") + monkeypatch.setenv("SNOWFLAKE_USER", "example-user") + monkeypatch.setenv("SNOWFLAKE_PASSWORD", "example-password") + monkeypatch.setenv("SNOWFLAKE_DATABASE", "ANALYTICS") + monkeypatch.setenv("SNOWFLAKE_WAREHOUSE", "COMPUTE_WH") + monkeypatch.setenv("SNOWFLAKE_SCHEMA", "PUBLIC") + + +def unset_all(monkeypatch): + for name in clean_env(): + monkeypatch.delenv(name, raising=False) + + +# ================================================================== # +# Refusing to invent an answer +# ================================================================== # + +class TestExampleRefusesWithoutCredentials: + """With nothing to connect to, it says so and builds nothing.""" + + def test_no_credentials_exits_non_zero_and_names_the_variables( + self, example_module, monkeypatch, capsys + ): + unset_all(monkeypatch) + exit_code = example_module.main([]) + + assert exit_code == 2 + captured = capsys.readouterr() + # The refusal is actionable: every required variable is named, and the + # two alternatives for authentication are both offered. + for name in example_module._REQUIRED_SETTINGS: + assert name in captured.err, name + assert "SNOWFLAKE_PASSWORD" in captured.err + assert "SNOWFLAKE_PRIVATE_KEY_PATH" in captured.err + assert "nothing was queried" in captured.err + # Nothing was printed as a result, and nothing contacted the network. + assert captured.out == "" + + def test_partial_credentials_are_refused_too( + self, example_module, monkeypatch, capsys + ): + """An account and a user without a secret is not a session. + + Building a config from half an environment would fail inside the + driver with a message about the driver; the example refuses before + that, on the variable that is actually missing. + """ + unset_all(monkeypatch) + monkeypatch.setenv("SNOWFLAKE_ACCOUNT", "example-account") + monkeypatch.setenv("SNOWFLAKE_USER", "example-user") + monkeypatch.setenv("SNOWFLAKE_DATABASE", "ANALYTICS") + monkeypatch.setenv("SNOWFLAKE_WAREHOUSE", "COMPUTE_WH") + + exit_code = example_module.main([]) + + assert exit_code == 2 + assert "SNOWFLAKE_PASSWORD (or SNOWFLAKE_PRIVATE_KEY_PATH)" in ( + capsys.readouterr().err + ) + + def test_password_or_key_either_satisfies_the_secret( + self, example_module, monkeypatch, capsys + ): + """A private key stands in for a password, as the config allows.""" + unset_all(monkeypatch) + monkeypatch.setenv("SNOWFLAKE_ACCOUNT", "example-account") + monkeypatch.setenv("SNOWFLAKE_USER", "example-user") + monkeypatch.setenv("SNOWFLAKE_DATABASE", "ANALYTICS") + monkeypatch.setenv("SNOWFLAKE_WAREHOUSE", "COMPUTE_WH") + monkeypatch.setenv("SNOWFLAKE_PRIVATE_KEY_PATH", "C:/keys/example.p8") + + config = example_module._config_from_env() + + assert config is not None + assert config.private_key_path == "C:/keys/example.p8" + assert capsys.readouterr().err == "" + + def test_no_connection_is_attempted_when_refusing( + self, example_module, monkeypatch + ): + """The refusal must happen before the backend object is built. + + Patching the class is the assertion: if the example had constructed a + backend to ask it about the environment, the patched class would have + been called and this test would see it. + """ + unset_all(monkeypatch) + with patch.object(example_module, "SnowflakeBackend") as backend_cls: + exit_code = example_module.main([]) + assert exit_code == 2 + backend_cls.assert_not_called() + + +# ================================================================== # +# The cache demonstration actually demonstrates something +# ================================================================== # + +class TestExampleDemonstratesTheCache: + def test_each_question_is_asked_once_and_cached_once( + self, example_module, introspector, credentials, capsys + ): + instrumented, executor = introspector + backend = MagicMock() + backend._connection = object() # the example disconnects a live one + backend.introspector = instrumented + + with patch.object( + example_module, "SnowflakeBackend", return_value=backend + ): + exit_code = example_module.main([]) + + assert exit_code == 0 + out = capsys.readouterr().out + # Four sections, one cold statement each and no warm ones. + assert out.count("cold=1 warm=0") == 4 + assert "Databases" in out + assert "ANALYTICS" in out + assert "COMPUTE_WH" in out + assert "PUBLIC" in out + # The whole run asked the account five questions: one per section, + # plus the table listing that picks a table to measure. The second + # call of every section was served from the cache, which is the claim. + assert executor.question_count == 5 + # The real executor was handed back, so the example leaves nothing + # standing between the introspector and the driver. + assert instrumented._executor is executor + + def test_a_second_run_reports_the_same_counts( + self, example_module, credentials, capsys + ): + """Repeatability: a fresh introspector and a fresh count. + + The point is that the numbers come from the executor rather than from + a value printed once and copied. + """ + instrumented = make_introspector(_RecordingExecutor()) + executor = instrumented._executor + backend = MagicMock() + backend._connection = None + backend.introspector = instrumented + + with patch.object( + example_module, "SnowflakeBackend", return_value=backend + ): + example_module.main([]) + assert capsys.readouterr().out.count("cold=1 warm=0") == 4 + assert executor.question_count == 5 + + def test_the_requested_table_reports_no_row_when_the_view_is_empty( + self, example_module, credentials, capsys + ): + """``None`` metrics are reported as ambiguous, not as zero bytes. + + The storage view returns no rows for a role other than ACCOUNTADMIN, + which the view's documentation says is indistinguishable from a missing + table. The example must print that ambiguity rather than a number. + """ + silent = _RecordingExecutor(silent=True) + instrumented = make_introspector(silent) + backend = MagicMock() + backend._connection = None + backend.introspector = instrumented + + with patch.object( + example_module, "SnowflakeBackend", return_value=backend + ): + exit_code = example_module.main(["ORDERS"]) + + assert exit_code == 0 + out = capsys.readouterr().out + assert "no metrics row" in out + assert "may not see the metrics" in out + assert "active bytes" not in out + # ``None`` is not cached, so the warm call asked again: an absent + # answer may become present after a grant, and caching it would make + # that invisible. + assert "cold=1 warm=1" in out + + def test_empty_lists_are_reported_as_zero_rows_not_silently( + self, example_module, credentials, capsys + ): + """An account the role sees nothing of prints a count of zero. + + Hiding the sections would look like the example worked when nothing + was read. + """ + instrumented = make_introspector(_RecordingExecutor(silent=True)) + backend = MagicMock() + backend._connection = None + backend.introspector = instrumented + + with patch.object( + example_module, "SnowflakeBackend", return_value=backend + ): + exit_code = example_module.main([]) + + assert exit_code == 0 + out = capsys.readouterr().out + assert "(0 row(s); executor questions cold=1 warm=0)" in out + assert "No table to measure" in out diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/introspection/test_introspection.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/introspection/test_introspection.py index 0fdb18e..d970539 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/introspection/test_introspection.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/introspection/test_introspection.py @@ -3,34 +3,24 @@ Tests cover: - SnowflakeIntrospectionMixin format_*_query methods -- SnowflakeIntrospectorMixin _parse_* methods +- SnowflakeIntrospectorMixin parse_* methods - SyncSnowflakeIntrospector with mock backend - AsyncSnowflakeIntrospector with mock backend """ import pytest from unittest.mock import MagicMock, patch, AsyncMock -from typing import Any, Dict, List, Optional from rhosocial.activerecord.backend.impl.snowflake.dialect import SnowflakeDialect -from rhosocial.activerecord.backend.impl.snowflake.mixins import SnowflakeIntrospectionMixin from rhosocial.activerecord.backend.impl.snowflake.introspection import ( SnowflakeIntrospectorMixin, SyncSnowflakeIntrospector, AsyncSnowflakeIntrospector, ) from rhosocial.activerecord.backend.introspection.types import ( - DatabaseInfo, - TableInfo, + IntrospectionDatabaseInfo, TableType, - ColumnInfo, ColumnNullable, - IndexInfo, - IndexColumnInfo, - IndexType, - ForeignKeyInfo, ReferentialAction, - ViewInfo, - TriggerInfo, ) from rhosocial.activerecord.backend.introspection.executor import SyncIntrospectorExecutor @@ -76,43 +66,39 @@ class TestSnowflakeIntrospectionSQLGeneration: """Test SQL generation for introspection queries.""" def test_format_database_info_query(self, dialect): - from rhosocial.activerecord.backend.expression.introspection import DatabaseInfoExpression - expr = DatabaseInfoExpression(dialect) + from rhosocial.activerecord.backend.expression.introspection import DatabaseInfoQueryStatement + expr = DatabaseInfoQueryStatement(dialect) sql, params = expr.to_sql() assert "CURRENT_DATABASE()" in sql assert "CURRENT_VERSION()" in sql assert params == () def test_format_table_list_query(self, dialect): - from rhosocial.activerecord.backend.expression.introspection import TableListExpression - expr = TableListExpression(dialect) - expr.schema("MY_SCHEMA") + from rhosocial.activerecord.backend.expression.introspection import TableListQueryStatement + expr = TableListQueryStatement(dialect, schema="MY_SCHEMA") sql, params = expr.to_sql() assert "INFORMATION_SCHEMA.TABLES" in sql assert "TABLE_SCHEMA = %s" in sql assert params == ("MY_SCHEMA",) def test_format_table_list_query_no_views(self, dialect): - from rhosocial.activerecord.backend.expression.introspection import TableListExpression - expr = TableListExpression(dialect) - expr.schema("MY_SCHEMA") + from rhosocial.activerecord.backend.expression.introspection import TableListQueryStatement + expr = TableListQueryStatement(dialect, schema="MY_SCHEMA") # include_views defaults to True sql, params = expr.to_sql() # When include_views=True, no TABLE_TYPE filter should be present assert "TABLE_TYPE = 'BASE TABLE'" not in sql def test_format_column_info_query(self, dialect): - from rhosocial.activerecord.backend.expression.introspection import ColumnInfoExpression - expr = ColumnInfoExpression(dialect, table_name="users") - expr.schema("MY_SCHEMA") + from rhosocial.activerecord.backend.expression.introspection import ColumnInfoQueryStatement + expr = ColumnInfoQueryStatement(dialect, table_name="users", schema="MY_SCHEMA") sql, params = expr.to_sql() assert "INFORMATION_SCHEMA.COLUMNS" in sql assert params == ("MY_SCHEMA", "users") def test_format_index_info_query(self, dialect): - from rhosocial.activerecord.backend.expression.introspection import IndexInfoExpression - expr = IndexInfoExpression(dialect, table_name="users") - expr.schema("MY_SCHEMA") + from rhosocial.activerecord.backend.expression.introspection import IndexInfoQueryStatement + expr = IndexInfoQueryStatement(dialect, table_name="users", schema="MY_SCHEMA") sql, params = expr.to_sql() assert "INFORMATION_SCHEMA.TABLE_CONSTRAINTS" in sql assert "INFORMATION_SCHEMA.KEY_COLUMN_USAGE" in sql @@ -120,9 +106,8 @@ def test_format_index_info_query(self, dialect): assert params == ("MY_SCHEMA", "users") def test_format_foreign_key_query(self, dialect): - from rhosocial.activerecord.backend.expression.introspection import ForeignKeyExpression - expr = ForeignKeyExpression(dialect, table_name="orders") - expr.schema("MY_SCHEMA") + from rhosocial.activerecord.backend.expression.introspection import ForeignKeyQueryStatement + expr = ForeignKeyQueryStatement(dialect, table_name="orders", schema="MY_SCHEMA") sql, params = expr.to_sql() assert "INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS" in sql assert "INFORMATION_SCHEMA.KEY_COLUMN_USAGE" in sql @@ -131,26 +116,23 @@ def test_format_foreign_key_query(self, dialect): assert params == ("MY_SCHEMA", "orders") def test_format_view_list_query(self, dialect): - from rhosocial.activerecord.backend.expression.introspection import ViewListExpression - expr = ViewListExpression(dialect) - expr.schema("MY_SCHEMA") + from rhosocial.activerecord.backend.expression.introspection import ViewListQueryStatement + expr = ViewListQueryStatement(dialect, schema="MY_SCHEMA") sql, params = expr.to_sql() assert "INFORMATION_SCHEMA.VIEWS" in sql assert params == ("MY_SCHEMA",) def test_format_view_info_query(self, dialect): - from rhosocial.activerecord.backend.expression.introspection import ViewInfoExpression - expr = ViewInfoExpression(dialect, view_name="my_view") - expr.schema("MY_SCHEMA") + from rhosocial.activerecord.backend.expression.introspection import ViewInfoQueryStatement + expr = ViewInfoQueryStatement(dialect, view_name="my_view", schema="MY_SCHEMA") sql, params = expr.to_sql() assert "INFORMATION_SCHEMA.VIEWS" in sql assert "TABLE_NAME = %s" in sql assert params == ("MY_SCHEMA", "my_view") def test_format_trigger_list_query_returns_empty(self, dialect): - from rhosocial.activerecord.backend.expression.introspection import TriggerListExpression - expr = TriggerListExpression(dialect) - expr.schema("MY_SCHEMA") + from rhosocial.activerecord.backend.expression.introspection import TriggerListQueryStatement + expr = TriggerListQueryStatement(dialect, schema="MY_SCHEMA") sql, params = expr.to_sql() assert "1 = 0" in sql @@ -160,17 +142,21 @@ def test_format_trigger_list_query_returns_empty(self, dialect): # ================================================================== # class TestSnowflakeIntrospectorParsing: - """Test _parse_* methods for converting raw rows to data structures.""" + """Test parse_* methods for converting raw rows to data structures.""" @pytest.fixture def introspector_mixin(self): """Create a minimal introspector mixin with mock backend.""" class MockBackend: + dialect = SnowflakeDialect(version=(8, 32, 0)) _version = (8, 32, 0) config = MagicMock() config.schema = "PUBLIC" + def get_cached_server_version(self): + return (8, 32, 0) + mixin = SnowflakeIntrospectorMixin() mixin._backend = MockBackend() return mixin @@ -179,23 +165,39 @@ def test_parse_database_info(self, introspector_mixin): rows = [ {"CATALOG_NAME": "MY_DB", "SERVER_VERSION": "8.32.0"}, ] - result = introspector_mixin._parse_database_info(rows) - assert isinstance(result, DatabaseInfo) + result = introspector_mixin.parse_database_info(rows) + assert isinstance(result, IntrospectionDatabaseInfo) assert result.name == "MY_DB" assert result.vendor == "Snowflake" assert result.version_tuple == (8, 32, 0) def test_parse_database_info_empty_rows(self, introspector_mixin): - result = introspector_mixin._parse_database_info([]) - assert isinstance(result, DatabaseInfo) + result = introspector_mixin.parse_database_info([]) + assert isinstance(result, IntrospectionDatabaseInfo) assert result.vendor == "Snowflake" + def test_parse_database_info_reports_unknown_without_a_version(self): + class UnknownVersionBackend: + dialect = SnowflakeDialect(version=None) + config = MagicMock() + config.schema = "PUBLIC" + + def get_cached_server_version(self): + return None + + mixin = SnowflakeIntrospectorMixin() + mixin._backend = UnknownVersionBackend() + + result = mixin.parse_database_info([]) + assert result.version == "unknown" + assert result.version_tuple == (0, 0, 0) + def test_parse_tables(self, introspector_mixin): rows = [ {"TABLE_NAME": "users", "TABLE_TYPE": "BASE TABLE", "COMMENT": None}, {"TABLE_NAME": "v_users", "TABLE_TYPE": "VIEW", "COMMENT": "user view"}, ] - result = introspector_mixin._parse_tables(rows, "PUBLIC") + result = introspector_mixin.parse_tables(rows, "PUBLIC") assert len(result) == 2 assert result[0].name == "users" assert result[0].table_type == TableType.BASE_TABLE @@ -203,6 +205,13 @@ def test_parse_tables(self, introspector_mixin): assert result[1].table_type == TableType.VIEW assert result[1].comment == "user view" + def test_parse_tables_keeps_materialized_views_distinct(self, introspector_mixin): + rows = [ + {"TABLE_NAME": "mv_sales", "TABLE_TYPE": "MATERIALIZED VIEW"}, + ] + result = introspector_mixin.parse_tables(rows, "PUBLIC") + assert result[0].table_type == TableType.MATERIALIZED_VIEW + def test_parse_columns(self, introspector_mixin): rows = [ { @@ -230,7 +239,7 @@ def test_parse_columns(self, introspector_mixin): "COMMENT": "user name", }, ] - result = introspector_mixin._parse_columns(rows, "users", "PUBLIC") + result = introspector_mixin.parse_columns(rows, "users", "PUBLIC") assert len(result) == 2 assert result[0].name == "id" @@ -303,7 +312,7 @@ def _row(data_type, cml=None, precision=None, scale=None): } def _parsed(self, introspector_mixin, row): - columns = introspector_mixin._parse_columns([row], "t", "PUBLIC") + columns = introspector_mixin.parse_columns([row], "t", "PUBLIC") return columns[0].parsed_data_type @pytest.mark.parametrize("width", [1, 20, 50, 200, 134217728]) @@ -380,7 +389,7 @@ def test_a_sized_number_column_keeps_its_precision_and_scale( A ``NUMBER(10, 2)`` column used to answer a bare ``DecimalType``: ``NUMERIC_PRECISION`` and ``NUMERIC_SCALE`` were selected by the - query, recorded on ``ColumnInfo``, and then never read. + query, recorded on ``IntrospectionColumnInfo``, and then never read. ``(38, 0)`` is deliberately **not** in this list: it is the documented default and therefore the bare form, which the next test @@ -480,13 +489,13 @@ def test_the_float_family_takes_no_precision(self, introspector_mixin): assert parsed == expected(dialect), f"{word} -> {parsed!r}" def test_data_type_full_carries_the_size_and_data_type_does_not(self, introspector_mixin): - """The two ``ColumnInfo`` fields finally differ, which is their purpose. + """The two ``IntrospectionColumnInfo`` fields finally differ, which is their purpose. ``data_type`` is the word core's differ falls back to when a type cannot be parsed; ``data_type_full`` is the spelling that says how wide the column is. """ - columns = introspector_mixin._parse_columns( + columns = introspector_mixin.parse_columns( [self._row("VARCHAR", cml=50)], "t", "PUBLIC") assert columns[0].data_type == "varchar" assert columns[0].data_type_full == "VARCHAR(50)" @@ -542,7 +551,7 @@ def test_an_unreadable_type_degrades_one_column_not_the_table(self, introspector side_effect=ValueError("not a type this backend can read"), ): with pytest.warns(RuntimeWarning, match="could not read"): - columns = introspector_mixin._parse_columns( + columns = introspector_mixin.parse_columns( [good], "t", "PUBLIC") assert len(columns) == 1 assert columns[0].parsed_data_type is None @@ -591,10 +600,20 @@ def test_a_binary_column_is_left_at_the_bare_word(self, introspector_mixin, word def test_parse_indexes_as_constraints(self, introspector_mixin): """Snowflake maps PK/unique constraints to IndexInfo.""" rows = [ - {"CONSTRAINT_NAME": "PK_USERS", "CONSTRAINT_TYPE": "PRIMARY KEY", "COLUMN_NAME": "id", "ORDINAL_POSITION": 1}, - {"CONSTRAINT_NAME": "UK_USERS_EMAIL", "CONSTRAINT_TYPE": "UNIQUE", "COLUMN_NAME": "email", "ORDINAL_POSITION": 1}, + { + "CONSTRAINT_NAME": "PK_USERS", + "CONSTRAINT_TYPE": "PRIMARY KEY", + "COLUMN_NAME": "id", + "ORDINAL_POSITION": 1, + }, + { + "CONSTRAINT_NAME": "UK_USERS_EMAIL", + "CONSTRAINT_TYPE": "UNIQUE", + "COLUMN_NAME": "email", + "ORDINAL_POSITION": 1, + }, ] - result = introspector_mixin._parse_indexes(rows, "users", "PUBLIC") + result = introspector_mixin.parse_indexes(rows, "users", "PUBLIC") assert len(result) == 2 pk = next(i for i in result if i.is_primary) @@ -620,7 +639,7 @@ def test_parse_foreign_keys(self, introspector_mixin): "REFERENCED_COLUMN_NAME": "id", }, ] - result = introspector_mixin._parse_foreign_keys(rows, "orders", "PUBLIC") + result = introspector_mixin.parse_foreign_keys(rows, "orders", "PUBLIC") assert len(result) == 1 assert result[0].name == "FK_ORDERS_USER" assert result[0].columns == ["user_id"] @@ -650,7 +669,7 @@ def test_parse_composite_foreign_key(self, introspector_mixin): "REFERENCED_COLUMN_NAME": "product_id", }, ] - result = introspector_mixin._parse_foreign_keys(rows, "order_items", "PUBLIC") + result = introspector_mixin.parse_foreign_keys(rows, "order_items", "PUBLIC") assert len(result) == 1 assert result[0].columns == ["order_id", "product_id"] assert result[0].referenced_columns == ["id", "product_id"] @@ -664,7 +683,7 @@ def test_parse_views(self, introspector_mixin): "IS_UPDATABLE": "NO", }, ] - result = introspector_mixin._parse_views(rows, "PUBLIC") + result = introspector_mixin.parse_views(rows, "PUBLIC") assert len(result) == 1 assert result[0].name == "v_active_users" assert result[0].definition == "SELECT * FROM users WHERE active = TRUE" @@ -679,17 +698,17 @@ def test_parse_view_info(self, introspector_mixin): "IS_UPDATABLE": "NO", }, ] - result = introspector_mixin._parse_view_info(rows, "v_active_users", "PUBLIC") + result = introspector_mixin.parse_view_info(rows, "v_active_users", "PUBLIC") assert result is not None assert result.name == "v_active_users" def test_parse_view_info_not_found(self, introspector_mixin): - result = introspector_mixin._parse_view_info([], "nonexistent", "PUBLIC") + result = introspector_mixin.parse_view_info([], "nonexistent", "PUBLIC") assert result is None def test_parse_triggers_always_empty(self, introspector_mixin): """Snowflake doesn't support triggers.""" - result = introspector_mixin._parse_triggers([], "PUBLIC") + result = introspector_mixin.parse_triggers([], "PUBLIC") assert result == [] @@ -718,15 +737,15 @@ def mock_executor(self): def introspector(self, mock_backend, mock_executor): return SyncSnowflakeIntrospector(mock_backend, mock_executor) - def test_get_default_schema(self, introspector): - assert introspector._get_default_schema() == "PUBLIC" + def test_default_schema(self, introspector): + assert introspector.default_schema == "PUBLIC" def test_get_database_info(self, introspector, mock_executor): mock_executor.execute.return_value = [ {"CATALOG_NAME": "MY_DB", "SERVER_VERSION": "8.32.0"}, ] info = introspector.get_database_info() - assert isinstance(info, DatabaseInfo) + assert isinstance(info, IntrospectionDatabaseInfo) assert info.vendor == "Snowflake" assert info.name == "MY_DB" mock_executor.execute.assert_called_once() @@ -803,7 +822,7 @@ def introspector(self, mock_backend, mock_executor): @pytest.mark.asyncio async def test_get_database_info(self, introspector, mock_executor): info = await introspector.get_database_info() - assert isinstance(info, DatabaseInfo) + assert isinstance(info, IntrospectionDatabaseInfo) assert info.vendor == "Snowflake" @pytest.mark.asyncio diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/introspection/test_introspection_table_types.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/introspection/test_introspection_table_types.py new file mode 100644 index 0000000..d9fb218 --- /dev/null +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/introspection/test_introspection_table_types.py @@ -0,0 +1,142 @@ +# tests/rhosocial/activerecord_snowflake_test/feature/backend/introspection/test_introspection_table_types.py +"""Snowflake's ``TABLE_TYPE`` vocabulary, read and written. + +Two defects share one vocabulary: + +* the parser folded every spelling it did not know into ``BASE_TABLE`` with + no trace, so a managed, event, Iceberg, hybrid or dynamic table became + indistinguishable from a plain one; +* the listing's ``include_views=False`` and ``table_type`` filters spoke only + the catalogue's spelling -- and the first of them required exactly + ``BASE TABLE``, hiding every other table kind along with the views. + +These tests pin both ends against the same table of spellings. +""" +import pytest + +from rhosocial.activerecord.backend.expression.introspection import ( + TableListQueryStatement, +) +from rhosocial.activerecord.backend.impl.snowflake.dialect import SnowflakeDialect +from rhosocial.activerecord.backend.impl.snowflake.introspection import ( + SnowflakeIntrospectorMixin, +) +from rhosocial.activerecord.backend.introspection.types import TableType + + +@pytest.fixture +def dialect(): + return SnowflakeDialect(version=(8, 0, 0)) + + +@pytest.fixture +def parser(): + return SnowflakeIntrospectorMixin() + + +def table_list(dialect, **kwargs): + return TableListQueryStatement(dialect, schema="MY_SCHEMA", **kwargs).to_sql() + + +# ------------------------------------------------------------------ # +# Parsing: the catalogue's word survives beside the core member +# ------------------------------------------------------------------ # + +@pytest.mark.parametrize( + "spelling, member", + [ + ("BASE TABLE", TableType.BASE_TABLE), + ("MANAGED TABLE", TableType.BASE_TABLE), + ("EVENT TABLE", TableType.BASE_TABLE), + ("HYBRID TABLE", TableType.BASE_TABLE), + ("ICEBERG TABLE", TableType.BASE_TABLE), + ("DYNAMIC TABLE", TableType.BASE_TABLE), + ("VIEW", TableType.VIEW), + ("MATERIALIZED VIEW", TableType.MATERIALIZED_VIEW), + ("SYSTEM TABLE", TableType.SYSTEM_TABLE), + ("TEMPORARY TABLE", TableType.TEMPORARY), + ("EXTERNAL TABLE", TableType.EXTERNAL), + ], +) +def test_every_known_spelling_keeps_the_catalogue_word(parser, spelling, member): + tables = parser.parse_tables( + [{"TABLE_NAME": "ORDERS", "TABLE_TYPE": spelling}], "PUBLIC" + ) + assert tables[0].table_type is member + assert tables[0].extra["snowflake_table_type"] == spelling + + +def test_an_unknown_spelling_is_base_table_with_the_word_kept(parser): + tables = parser.parse_tables( + [{"TABLE_NAME": "ORDERS", "TABLE_TYPE": "SOMETHING NEW"}], "PUBLIC" + ) + assert tables[0].table_type is TableType.BASE_TABLE + assert tables[0].extra["snowflake_table_type"] == "SOMETHING NEW" + + +def test_the_catalogue_word_is_always_present(parser): + tables = parser.parse_tables([{"TABLE_NAME": "ORDERS"}], "PUBLIC") + assert tables[0].extra["snowflake_table_type"] == "BASE TABLE" + + +def test_a_lower_case_fixture_still_matches_the_vocabulary(parser): + tables = parser.parse_tables( + [{"TABLE_NAME": "ORDERS", "TABLE_TYPE": "materialized view"}], "PUBLIC" + ) + assert tables[0].table_type is TableType.MATERIALIZED_VIEW + assert tables[0].extra["snowflake_table_type"] == "MATERIALIZED VIEW" + + +# ------------------------------------------------------------------ # +# include_views=False removes views, not every non-base kind +# ------------------------------------------------------------------ # + +def test_no_views_removes_only_the_view_spellings(dialect): + sql, params = table_list(dialect, include_views=False) + assert "NOT (TABLE_TYPE IN ('VIEW', 'MATERIALIZED VIEW'))" in sql + assert "TABLE_TYPE = 'BASE TABLE'" not in sql + assert params == ("MY_SCHEMA",) + + +# ------------------------------------------------------------------ # +# table_type accepts both vocabularies +# ------------------------------------------------------------------ # + +def test_a_core_materialized_view_expands_to_the_catalogue_spelling(dialect): + sql, params = table_list(dialect, table_type="MATERIALIZED_VIEW") + assert "TABLE_TYPE IN (%s)" in sql + assert params == ("MY_SCHEMA", "MATERIALIZED VIEW") + + +def test_a_core_external_kind_expands_to_the_catalogue_spelling(dialect): + _sql, params = table_list(dialect, table_type="EXTERNAL") + assert params == ("MY_SCHEMA", "EXTERNAL TABLE") + + +def test_a_core_base_table_covers_every_table_spelling(dialect): + _sql, params = table_list(dialect, table_type="BASE_TABLE") + assert params == ( + "MY_SCHEMA", + "BASE TABLE", + "MANAGED TABLE", + "EVENT TABLE", + "HYBRID TABLE", + "ICEBERG TABLE", + "DYNAMIC TABLE", + ) + + +def test_a_core_member_object_is_read_as_its_name(dialect): + _sql, params = table_list(dialect, table_type=TableType.VIEW) + assert params == ("MY_SCHEMA", "VIEW") + + +def test_a_raw_catalogue_spelling_filters_exactly_that_spelling(dialect): + _sql, params = table_list(dialect, table_type="ICEBERG TABLE") + assert params == ("MY_SCHEMA", "ICEBERG TABLE") + + +def test_a_kind_the_catalogue_never_names_matches_nothing(dialect): + sql, params = table_list(dialect, table_type="FOREIGN_TABLE") + assert "1 = 2" in sql + assert params == ("MY_SCHEMA",) diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/protocol/test_protocol_conformance.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/protocol/test_protocol_conformance.py index 006bb08..69824da 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/protocol/test_protocol_conformance.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/protocol/test_protocol_conformance.py @@ -408,3 +408,43 @@ def test_impl_public_methods_are_declared_in_protocol(self, protocol, impl): assert not undeclared, ( f"{impl.__name__} has undeclared methods in {protocol.__name__}: {undeclared}" ) + + +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(SnowflakeDialect(version=(8, 0, 0))).__mro__ + own = "rhosocial.activerecord.backend.impl.snowflake" + 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_snowflake_test/feature/backend/schema/test_schema_diff.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/schema/test_schema_diff.py index a1c261e..0b38ebe 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/schema/test_schema_diff.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/schema/test_schema_diff.py @@ -9,7 +9,7 @@ SchemaSnapshot, SchemaDiff, ) from rhosocial.activerecord.backend.introspection.types import ( - ColumnInfo, ColumnNullable, TableInfo, TableType, DatabaseInfo, + IntrospectionColumnInfo, ColumnNullable, IntrospectionTableInfo, TableType, IntrospectionDatabaseInfo, ) from rhosocial.activerecord.backend.expression.types import ( DecimalType, IntegerType, VarCharType, @@ -20,7 +20,7 @@ def _make_col(name, data_type, ordinal=1, nullable=ColumnNullable.NULLABLE, parsed_dt=None): - return ColumnInfo( + return IntrospectionColumnInfo( name=name, table_name="test", schema="PUBLIC", @@ -37,7 +37,7 @@ def _make_snapshot(tables_dict): return SchemaSnapshot( dialect_class="SnowflakeDialect", captured_at=datetime.now(), - database_info=DatabaseInfo( + database_info=IntrospectionDatabaseInfo( name="test", version="8.0.0", version_tuple=(8, 0, 0), vendor="Snowflake", ), @@ -48,11 +48,11 @@ def _make_snapshot(tables_dict): def _introspected_column(name, declared_type, dialect): """One column as the *catalog* reports it, through the real introspector. - Every other test in this file builds ``ColumnInfo`` by hand, which is right + Every other test in this file builds ``IntrospectionColumnInfo`` by hand, which is right for testing the differ and wrong for testing the round trip: the whole question here is what the introspector hands the differ, so the column is built by running the declaration's catalog row through - ``SnowflakeIntrospectorMixin._parse_columns`` and taking what comes out. + ``SnowflakeIntrospectorMixin.parse_columns`` and taking what comes out. """ from unittest.mock import MagicMock @@ -99,7 +99,7 @@ class MockBackend: backend.dialect = dialect mixin = SnowflakeIntrospectorMixin() mixin._backend = backend - return mixin._parse_columns([row], "t", "PUBLIC")[0] + return mixin.parse_columns([row], "t", "PUBLIC")[0] class TestSnowflakeSchemaDiffer: @@ -107,7 +107,7 @@ class TestSnowflakeSchemaDiffer: def test_no_changes(self): col = _make_col("id", "NUMBER", ordinal=1, parsed_dt=IntegerType()) - snap = _make_snapshot({"t": TableInfo( + snap = _make_snapshot({"t": IntrospectionTableInfo( name="t", schema="PUBLIC", table_type=TableType.BASE_TABLE, columns=[col], )}) @@ -120,11 +120,11 @@ def test_added_column(self): new_col1 = _make_col("id", "NUMBER", ordinal=1) new_col2 = _make_col("name", "VARCHAR", ordinal=2) - old_snap = _make_snapshot({"t": TableInfo( + old_snap = _make_snapshot({"t": IntrospectionTableInfo( name="t", schema="PUBLIC", table_type=TableType.BASE_TABLE, columns=[old_col], )}) - new_snap = _make_snapshot({"t": TableInfo( + new_snap = _make_snapshot({"t": IntrospectionTableInfo( name="t", schema="PUBLIC", table_type=TableType.BASE_TABLE, columns=[new_col1, new_col2], )}) @@ -143,11 +143,11 @@ def test_removed_column(self): old_col2 = _make_col("name", "VARCHAR", ordinal=2) new_col = _make_col("id", "NUMBER", ordinal=1) - old_snap = _make_snapshot({"t": TableInfo( + old_snap = _make_snapshot({"t": IntrospectionTableInfo( name="t", schema="PUBLIC", table_type=TableType.BASE_TABLE, columns=[old_col1, old_col2], )}) - new_snap = _make_snapshot({"t": TableInfo( + new_snap = _make_snapshot({"t": IntrospectionTableInfo( name="t", schema="PUBLIC", table_type=TableType.BASE_TABLE, columns=[new_col], )}) @@ -165,11 +165,11 @@ def test_modified_column_type(self): new_col = _make_col("name", "VARCHAR", ordinal=1, parsed_dt=VarCharType(length=200)) - old_snap = _make_snapshot({"t": TableInfo( + old_snap = _make_snapshot({"t": IntrospectionTableInfo( name="t", schema="PUBLIC", table_type=TableType.BASE_TABLE, columns=[old_col], )}) - new_snap = _make_snapshot({"t": TableInfo( + new_snap = _make_snapshot({"t": IntrospectionTableInfo( name="t", schema="PUBLIC", table_type=TableType.BASE_TABLE, columns=[new_col], )}) @@ -184,7 +184,7 @@ def test_modified_column_type(self): def test_added_table(self): old_snap = _make_snapshot({}) col = _make_col("id", "NUMBER", ordinal=1) - new_snap = _make_snapshot({"new_table": TableInfo( + new_snap = _make_snapshot({"new_table": IntrospectionTableInfo( name="new_table", schema="PUBLIC", table_type=TableType.BASE_TABLE, columns=[col], )}) @@ -195,7 +195,7 @@ def test_added_table(self): def test_removed_table(self): col = _make_col("id", "NUMBER", ordinal=1) - old_snap = _make_snapshot({"old_table": TableInfo( + old_snap = _make_snapshot({"old_table": IntrospectionTableInfo( name="old_table", schema="PUBLIC", table_type=TableType.BASE_TABLE, columns=[col], )}) @@ -215,8 +215,8 @@ class TestSnowflakeSchemaDifferRoundTrip: against the declaration that created it — a change on a column nobody touched, which is what this class asserts does not happen. - These go through the real ``_parse_columns``, not a hand-built - ``ColumnInfo``, because the type the differ sees is the one the + These go through the real ``parse_columns``, not a hand-built + ``IntrospectionColumnInfo``, because the type the differ sees is the one the introspector composed. """ @@ -228,7 +228,7 @@ def dialect(self): return SnowflakeDialect(version=(10, 2, 0)) def _table(self, *columns): - return TableInfo( + return IntrospectionTableInfo( name="t", schema="PUBLIC", table_type=TableType.BASE_TABLE, columns=list(columns), ) diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/snowflake/test_show.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/snowflake/test_show.py index c0d1d72..3da31e6 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/snowflake/test_show.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/snowflake/test_show.py @@ -11,7 +11,7 @@ SnowflakeShowSupport, ) from rhosocial.activerecord.backend.impl.snowflake.expression import ( - SnowflakeShowExpression, + SnowflakeShowStatement, SnowflakeShowObjectType, SnowflakeShowScope, ) @@ -57,7 +57,7 @@ class TestSnowflakeShowBasic: ], ) def test_show_object_type(self, dialect, object_type, expected): - expr = SnowflakeShowExpression(dialect, object_type) + expr = SnowflakeShowStatement(dialect, object_type) sql, params = expr.to_sql() assert sql == expected assert params == () @@ -67,7 +67,7 @@ class TestSnowflakeShowScope: """SHOW statement IN scope generation.""" def test_show_in_schema_with_name(self, dialect): - expr = SnowflakeShowExpression( + expr = SnowflakeShowStatement( dialect, SnowflakeShowObjectType.TABLES, in_scope=SnowflakeShowScope.SCHEMA, @@ -77,7 +77,7 @@ def test_show_in_schema_with_name(self, dialect): assert sql == 'SHOW TABLES IN SCHEMA "my_schema"' def test_show_in_schema_no_name(self, dialect): - expr = SnowflakeShowExpression( + expr = SnowflakeShowStatement( dialect, SnowflakeShowObjectType.TABLES, in_scope=SnowflakeShowScope.SCHEMA, @@ -86,7 +86,7 @@ def test_show_in_schema_no_name(self, dialect): assert sql == "SHOW TABLES IN SCHEMA" def test_show_in_database_with_name(self, dialect): - expr = SnowflakeShowExpression( + expr = SnowflakeShowStatement( dialect, SnowflakeShowObjectType.SCHEMAS, in_scope=SnowflakeShowScope.DATABASE, @@ -96,7 +96,7 @@ def test_show_in_database_with_name(self, dialect): assert sql == 'SHOW SCHEMAS IN DATABASE "my_db"' def test_show_in_account(self, dialect): - expr = SnowflakeShowExpression( + expr = SnowflakeShowStatement( dialect, SnowflakeShowObjectType.ROLES, in_scope=SnowflakeShowScope.ACCOUNT, @@ -109,7 +109,7 @@ class TestSnowflakeShowFilters: """SHOW statement LIKE / LIMIT generation.""" def test_show_like(self, dialect): - expr = SnowflakeShowExpression( + expr = SnowflakeShowStatement( dialect, SnowflakeShowObjectType.TABLES, like="emp%", @@ -118,7 +118,7 @@ def test_show_like(self, dialect): assert sql == "SHOW TABLES LIKE 'emp%'" def test_show_like_escaped(self, dialect): - expr = SnowflakeShowExpression( + expr = SnowflakeShowStatement( dialect, SnowflakeShowObjectType.TABLES, like="it's", @@ -127,7 +127,7 @@ def test_show_like_escaped(self, dialect): assert sql == "SHOW TABLES LIKE 'it''s'" def test_show_limit(self, dialect): - expr = SnowflakeShowExpression( + expr = SnowflakeShowStatement( dialect, SnowflakeShowObjectType.TABLES, limit=100, @@ -136,7 +136,7 @@ def test_show_limit(self, dialect): assert sql == "SHOW TABLES LIMIT 100" def test_show_full_options(self, dialect): - expr = SnowflakeShowExpression( + expr = SnowflakeShowStatement( dialect, SnowflakeShowObjectType.TABLES, in_scope=SnowflakeShowScope.SCHEMA, diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_clause_pair_guard.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_clause_pair_guard.py index 9c38ad7..4a14761 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_clause_pair_guard.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_clause_pair_guard.py @@ -38,7 +38,7 @@ clause, not a collision between two answers; the guard still requires the refusal to differ from "neither". -Mandatory pairs (``AlterConstraint.enforced`` / ``not_enforced``) have no +Mandatory pairs (``AlterConstraintAction.enforced`` / ``not_enforced``) have no "neither" state at all: the action *is* the keyword. They are guarded by :class:`MandatoryPairCase`, which requires "neither" and "both" to raise ``ValueError`` and each requested spelling to be answered (rendered or refused @@ -61,7 +61,7 @@ import re from dataclasses import dataclass -from typing import Callable, Optional, Sequence, Tuple +from typing import Callable, Optional, Tuple import pytest @@ -74,40 +74,40 @@ Table, View, ) -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_alter import ( - AlterConstraint, + AlterConstraintAction, ) from rhosocial.activerecord.backend.expression.statements.ddl_schema import ( - DropSchemaExpression, + DropSchemaStatement, ) from rhosocial.activerecord.backend.expression.statements.ddl_sequence import ( - AlterSequenceExpression, - CreateSequenceExpression, + AlterSequenceStatement, + CreateSequenceStatement, ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - CreateTableAsExpression, - DropTableExpression, + CreateTableAsStatement, + DropTableStatement, IdentityClause, - TableConstraintType, + TableCheckConstraint, ) from rhosocial.activerecord.backend.expression.statements.ddl_truncate import ( - TruncateExpression, + TruncateStatement, ) from rhosocial.activerecord.backend.expression.statements.ddl_view import ( - CreateMaterializedViewExpression, - DropMaterializedViewExpression, - DropViewExpression, - RefreshMaterializedViewExpression, + CreateMaterializedViewStatement, + DropMaterializedViewStatement, + DropViewStatement, + 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.snowflake.dialect import SnowflakeDialect from rhosocial.activerecord.backend.impl.snowflake.expression.ddl.routine import ( - SnowflakeCreateFunctionExpression, + SnowflakeCreateFunctionStatement, ) from rhosocial.activerecord.backend.impl.snowflake.expression.pivot import ( SnowflakeUnpivotExpression, @@ -134,7 +134,7 @@ def _table(d, name="t"): def _query(d): - return QueryExpression(d, select=[Column(d, "id")], from_=_table(d)) + return QueryStatement(d, select=[Column(d, "id")], from_=_table(d)) def _render(build: Callable[[], object]) -> Outcome: @@ -171,7 +171,7 @@ class PairCase: def render(self, d, **kw) -> Outcome: return _render(lambda: self.build(d, **kw)) - def _check_spelling(self, sql: str, mine: Optional[str], other: Optional[str], label: str) -> None: + def check_spelling(self, sql: str, mine: Optional[str], other: Optional[str], label: str) -> None: if mine is not None: assert re.search(mine, sql), ( f"{self.case_id}: {label} was rendered but its spelling {mine!r} " @@ -187,8 +187,8 @@ def _check_spelling(self, sql: str, mine: Optional[str], other: Optional[str], l PAIR_CASES: Tuple[PairCase, ...] = ( # ---------------------------------------------------------- sequences -- PairCase( - "CreateSequenceExpression.cycle", - lambda d, **kw: CreateSequenceExpression(d, Sequence(d, "s"), **kw), + "CreateSequenceStatement.cycle", + lambda d, **kw: CreateSequenceStatement(d, Sequence(d, "s"), **kw), "cycle", "no_cycle", ("rendered", 'CREATE SEQUENCE "s"'), @@ -198,8 +198,8 @@ def _check_spelling(self, sql: str, mine: Optional[str], other: Optional[str], l note="Snowflake has neither CYCLE nor NO CYCLE; both requests are refused by name.", ), PairCase( - "CreateSequenceExpression.order", - lambda d, **kw: CreateSequenceExpression(d, Sequence(d, "s"), **kw), + "CreateSequenceStatement.order", + lambda d, **kw: CreateSequenceStatement(d, Sequence(d, "s"), **kw), "order", "no_order", ("rendered", 'CREATE SEQUENCE "s"'), @@ -210,8 +210,8 @@ def _check_spelling(self, sql: str, mine: Optional[str], other: Optional[str], l note="CREATE SEQUENCE [ { ORDER | NOORDER } ].", ), PairCase( - "CreateSequenceExpression.cache", - lambda d, **kw: CreateSequenceExpression(d, Sequence(d, "s"), **kw), + "CreateSequenceStatement.cache", + lambda d, **kw: CreateSequenceStatement(d, Sequence(d, "s"), **kw), "cache", "no_cache", ("rendered", 'CREATE SEQUENCE "s"'), @@ -222,8 +222,8 @@ def _check_spelling(self, sql: str, mine: Optional[str], other: Optional[str], l note="Snowflake has no CACHE clause on sequences.", ), PairCase( - "AlterSequenceExpression.cycle", - lambda d, **kw: AlterSequenceExpression(d, Sequence(d, "s"), **kw), + "AlterSequenceStatement.cycle", + lambda d, **kw: AlterSequenceStatement(d, Sequence(d, "s"), **kw), "cycle", "no_cycle", ("rendered", 'ALTER SEQUENCE "s"'), @@ -232,8 +232,8 @@ def _check_spelling(self, sql: str, mine: Optional[str], other: Optional[str], l same_refusal_ok=True, ), PairCase( - "AlterSequenceExpression.order", - lambda d, **kw: AlterSequenceExpression(d, Sequence(d, "s"), **kw), + "AlterSequenceStatement.order", + lambda d, **kw: AlterSequenceStatement(d, Sequence(d, "s"), **kw), "order", "no_order", ("rendered", 'ALTER SEQUENCE "s"'), @@ -244,8 +244,8 @@ def _check_spelling(self, sql: str, mine: Optional[str], other: Optional[str], l note="ALTER SEQUENCE ... SET [ { ORDER | NOORDER } ].", ), PairCase( - "AlterSequenceExpression.cache", - lambda d, **kw: AlterSequenceExpression(d, Sequence(d, "s"), **kw), + "AlterSequenceStatement.cache", + lambda d, **kw: AlterSequenceStatement(d, Sequence(d, "s"), **kw), "cache", "no_cache", ("rendered", 'ALTER SEQUENCE "s"'), @@ -290,8 +290,8 @@ def _check_spelling(self, sql: str, mine: Optional[str], other: Optional[str], l ), # ------------------------------------------------------- drop family -- PairCase( - "DropSchemaExpression.cascade", - lambda d, **kw: DropSchemaExpression(d, Schema(d, "s"), **kw), + "DropSchemaStatement.cascade", + lambda d, **kw: DropSchemaStatement(d, Schema(d, "s"), **kw), "cascade", "restrict", ("rendered", 'DROP SCHEMA "s"'), @@ -302,8 +302,8 @@ def _check_spelling(self, sql: str, mine: Optional[str], other: Optional[str], l note="DROP SCHEMA [ CASCADE | RESTRICT ]; Snowflake accepts both.", ), PairCase( - "DropTableExpression.cascade", - lambda d, **kw: DropTableExpression(d, _table(d), **kw), + "DropTableStatement.cascade", + lambda d, **kw: DropTableStatement(d, _table(d), **kw), "cascade", "restrict", ("rendered", 'DROP TABLE "t"'), @@ -317,8 +317,8 @@ def _check_spelling(self, sql: str, mine: Optional[str], other: Optional[str], l ), ), PairCase( - "DropViewExpression.cascade", - lambda d, **kw: DropViewExpression(d, View(d, "v"), **kw), + "DropViewStatement.cascade", + lambda d, **kw: DropViewStatement(d, View(d, "v"), **kw), "cascade", "restrict", ("rendered", 'DROP VIEW "v"'), @@ -327,8 +327,8 @@ def _check_spelling(self, sql: str, mine: Optional[str], other: Optional[str], l note="The DROP VIEW reference has neither keyword; both are refused by name.", ), PairCase( - "DropMaterializedViewExpression.cascade", - lambda d, **kw: DropMaterializedViewExpression(d, MaterializedView(d, "mv"), **kw), + "DropMaterializedViewStatement.cascade", + lambda d, **kw: DropMaterializedViewStatement(d, MaterializedView(d, "mv"), **kw), "cascade", "restrict", ("rendered", 'DROP MATERIALIZED VIEW "mv"'), @@ -344,8 +344,8 @@ def _check_spelling(self, sql: str, mine: Optional[str], other: Optional[str], l ), # ----------------------------------------------------------- truncate -- PairCase( - "TruncateExpression.cascade", - lambda d, **kw: TruncateExpression(d, _table(d), **kw), + "TruncateStatement.cascade", + lambda d, **kw: TruncateStatement(d, _table(d), **kw), "cascade", "restrict", ("rendered", 'TRUNCATE TABLE "t"'), @@ -354,8 +354,8 @@ def _check_spelling(self, sql: str, mine: Optional[str], other: Optional[str], l note="The TRUNCATE reference has neither keyword; both are refused by name.", ), PairCase( - "TruncateExpression.restart_identity", - lambda d, **kw: TruncateExpression(d, _table(d), **kw), + "TruncateStatement.restart_identity", + lambda d, **kw: TruncateStatement(d, _table(d), **kw), "restart_identity", "continue_identity", ("rendered", 'TRUNCATE TABLE "t"'), @@ -369,8 +369,8 @@ def _check_spelling(self, sql: str, mine: Optional[str], other: Optional[str], l # the pairs they gate are in scope here: each spelling is answered, never # dropped, and the answers are pinned to the reference grammar. PairCase( - "CreateTableAsExpression.with_data", - lambda d, **kw: CreateTableAsExpression(d, _table(d), _query(d), **kw), + "CreateTableAsStatement.with_data", + lambda d, **kw: CreateTableAsStatement(d, _table(d), _query(d), **kw), "with_data", "no_data", ("rendered", 'CREATE TABLE "t" AS SELECT "id" FROM "t"'), @@ -386,8 +386,8 @@ def _check_spelling(self, sql: str, mine: Optional[str], other: Optional[str], l ), ), PairCase( - "CTEExpression.materialized", - lambda d, **kw: CTEExpression(d, "c", _query(d), **kw), + "CTEClause.materialized", + lambda d, **kw: CTEClause(d, "c", _query(d), **kw), "materialized", "not_materialized", ("rendered", '"c" AS (SELECT "id" FROM "t")'), @@ -417,8 +417,8 @@ def _check_spelling(self, sql: str, mine: Optional[str], other: Optional[str], l note="UNPIVOT [ { INCLUDE | EXCLUDE } NULLS ]; omitting it is legal (default EXCLUDE).", ), PairCase( - "SnowflakeCreateFunctionExpression.immutable", - lambda d, **kw: SnowflakeCreateFunctionExpression( + "SnowflakeCreateFunctionStatement.immutable", + lambda d, **kw: SnowflakeCreateFunctionStatement( d, "f", returns="NUMBER", body="SELECT 1", **kw ), "immutable", @@ -437,8 +437,8 @@ def _check_spelling(self, sql: str, mine: Optional[str], other: Optional[str], l note="CREATE FUNCTION ... [ { VOLATILE | IMMUTABLE } ]; default VOLATILE.", ), PairCase( - "CreateMaterializedViewExpression.with_data", - lambda d, **kw: CreateMaterializedViewExpression( + "CreateMaterializedViewStatement.with_data", + lambda d, **kw: CreateMaterializedViewStatement( d, MaterializedView(d, "mv"), _query(d), **kw ), "with_data", @@ -454,8 +454,8 @@ def _check_spelling(self, sql: str, mine: Optional[str], other: Optional[str], l ), ), PairCase( - "BeginTransactionExpression.deferrable", - lambda d, **kw: BeginTransactionExpression(d, **kw), + "BeginTransactionStatement.deferrable", + lambda d, **kw: BeginTransactionStatement(d, **kw), "deferrable", "not_deferrable", ("rendered", "BEGIN"), @@ -467,8 +467,8 @@ def _check_spelling(self, sql: str, mine: Optional[str], other: Optional[str], l # TRANSACTION statement at all. The pair must refuse by name through # supports_transaction_wait() rather than be silently dropped. PairCase( - "BeginTransactionExpression.wait", - lambda d, **kw: BeginTransactionExpression(d, **kw), + "BeginTransactionStatement.wait", + lambda d, **kw: BeginTransactionStatement(d, **kw), "wait", "no_wait", ("rendered", "BEGIN"), @@ -482,8 +482,8 @@ def _check_spelling(self, sql: str, mine: Optional[str], other: Optional[str], l ), ), PairCase( - "SetTransactionExpression.wait", - lambda d, **kw: SetTransactionExpression(d, **kw), + "SetTransactionStatement.wait", + lambda d, **kw: SetTransactionStatement(d, **kw), "wait", "no_wait", ("rendered", ""), @@ -528,7 +528,7 @@ def test_a_set_answers_a_only(self, case): f"{case.case_id}: {case.a!r} -> {outcome!r}, expected {case.a_outcome!r}" ) if outcome[0] == "rendered": - case._check_spelling(outcome[1], case.a_pattern, case.b_pattern, repr(case.a)) + case.check_spelling(outcome[1], case.a_pattern, case.b_pattern, repr(case.a)) @pytest.mark.parametrize("case", PAIR_CASES, ids=PAIR_IDS) def test_b_set_answers_b_only(self, case): @@ -537,7 +537,7 @@ def test_b_set_answers_b_only(self, case): f"{case.case_id}: {case.b!r} -> {outcome!r}, expected {case.b_outcome!r}" ) if outcome[0] == "rendered": - case._check_spelling(outcome[1], case.b_pattern, case.a_pattern, repr(case.b)) + case.check_spelling(outcome[1], case.b_pattern, case.a_pattern, repr(case.b)) @pytest.mark.parametrize("case", PAIR_CASES, ids=PAIR_IDS) def test_both_set_is_refused_at_construction(self, case): @@ -594,13 +594,13 @@ class MandatoryPairCase: MANDATORY_CASES: Tuple[MandatoryPairCase, ...] = ( MandatoryPairCase( - "AlterConstraint.enforced", - lambda d, **kw: AlterConstraint( - d, "c", constraint_type=TableConstraintType.CHECK, **kw + "AlterConstraintAction.enforced", + lambda d, **kw: AlterConstraintAction( + d, "c", constraint_kind=TableCheckConstraint, **kw ), "enforced", "not_enforced", - "AlterConstraint requires exactly one of", + "AlterConstraintAction requires exactly one of", ("refused", "ALTER CONSTRAINT ENFORCED/NOT ENFORCED"), ("refused", "ALTER CONSTRAINT ENFORCED/NOT ENFORCED"), note=( @@ -643,7 +643,7 @@ class TestNewlyGatedMasterProbes: """The four master probes core's new gates consult, and Snowflake's answers. Core commit c4d6adc made three previously-decorative probes load-bearing -- - ``supports_materialized_cte`` on ``format_cte_expression``, + ``supports_materialized_cte`` on ``format_cte_clause``, ``supports_truncate`` on ``format_truncate_statement``, and the new ``supports_with_data_clause`` on CTAS / CREATE MATERIALIZED VIEW / REFRESH MATERIALIZED VIEW -- and added ``supports_transaction_wait`` for the new @@ -681,11 +681,11 @@ def test_materialized_cte_is_refused_by_name(self): ({"not_materialized": True}, "NOT MATERIALIZED CTE"), ): with pytest.raises(UnsupportedFeatureError) as exc_info: - CTEExpression(d, "c", _query(d), **kw).to_sql() + CTEClause(d, "c", _query(d), **kw).to_sql() assert exc_info.value.feature_name == feature assert exc_info.value.dialect_name == "Snowflake" # Neither spelling requested: the probe is not consulted, nothing refused. - sql, _params = CTEExpression(d, "c", _query(d)).to_sql() + sql, _params = CTEClause(d, "c", _query(d)).to_sql() assert "MATERIALIZED" not in sql def test_with_data_is_refused_by_name_on_every_consumer(self): @@ -695,7 +695,7 @@ def test_with_data_is_refused_by_name_on_every_consumer(self): ({"no_data": True}, "WITH NO DATA"), ): with pytest.raises(UnsupportedFeatureError) as exc_info: - CreateTableAsExpression(d, _table(d), _query(d), **kw).to_sql() + CreateTableAsStatement(d, _table(d), _query(d), **kw).to_sql() assert exc_info.value.feature_name == feature assert exc_info.value.dialect_name == "Snowflake" # CREATE MATERIALIZED VIEW refuses through Snowflake's own formatter, @@ -704,7 +704,7 @@ def test_with_data_is_refused_by_name_on_every_consumer(self): # WITH DATA METRIC FUNCTION clause). for kw in ({"with_data": True}, {"no_data": True}): with pytest.raises(UnsupportedFeatureError) as exc_info: - CreateMaterializedViewExpression( + CreateMaterializedViewStatement( d, MaterializedView(d, "mv"), _query(d), **kw ).to_sql() assert exc_info.value.feature_name == "MATERIALIZED VIEW WITH [NO] DATA" @@ -713,7 +713,7 @@ def test_with_data_is_refused_by_name_on_every_consumer(self): # is refused before the clause is read. for kw in ({}, {"with_data": True}, {"no_data": True}): with pytest.raises(UnsupportedFeatureError) as exc_info: - RefreshMaterializedViewExpression( + RefreshMaterializedViewStatement( d, MaterializedView(d, "mv"), **kw ).to_sql() assert exc_info.value.feature_name == "REFRESH MATERIALIZED VIEW" @@ -722,7 +722,7 @@ def test_with_data_is_refused_by_name_on_every_consumer(self): def test_truncate_renders_and_its_probe_gates_its_own_formatter(self): d = _dialect() assert d.supports_truncate() is True - sql, _params = TruncateExpression(d, _table(d)).to_sql() + sql, _params = TruncateStatement(d, _table(d)).to_sql() assert sql == 'TRUNCATE TABLE "t"' class _NoTruncate(SnowflakeDialect): @@ -731,26 +731,26 @@ def supports_truncate(self) -> bool: nd = _NoTruncate() with pytest.raises(UnsupportedFeatureError) as exc_info: - TruncateExpression(nd, _table(nd)).to_sql() + TruncateStatement(nd, _table(nd)).to_sql() assert exc_info.value.feature_name == "TRUNCATE" def test_wait_pair_refuses_by_name_on_every_transaction_statement(self): d = _dialect() for build, feature in ( ( - lambda: BeginTransactionExpression(d, wait=True), + lambda: BeginTransactionStatement(d, wait=True), "BEGIN TRANSACTION WAIT", ), ( - lambda: BeginTransactionExpression(d, no_wait=True), + lambda: BeginTransactionStatement(d, no_wait=True), "BEGIN TRANSACTION NO WAIT", ), ( - lambda: SetTransactionExpression(d, wait=True), + lambda: SetTransactionStatement(d, wait=True), "SET TRANSACTION WAIT", ), ( - lambda: SetTransactionExpression(d, no_wait=True), + lambda: SetTransactionStatement(d, no_wait=True), "SET TRANSACTION NO WAIT", ), ): @@ -782,36 +782,36 @@ def test_every_consumed_pair_is_covered(self): here; the report lists them as inherited. """ expected = { - "CreateSequenceExpression.cycle", - "CreateSequenceExpression.order", - "CreateSequenceExpression.cache", - "AlterSequenceExpression.cycle", - "AlterSequenceExpression.order", - "AlterSequenceExpression.cache", + "CreateSequenceStatement.cycle", + "CreateSequenceStatement.order", + "CreateSequenceStatement.cache", + "AlterSequenceStatement.cycle", + "AlterSequenceStatement.order", + "AlterSequenceStatement.cache", "IdentityClause.cycle", "IdentityClause.order", "IdentityClause.cache", - "DropSchemaExpression.cascade", - "DropTableExpression.cascade", - "DropViewExpression.cascade", - "DropMaterializedViewExpression.cascade", - "TruncateExpression.cascade", - "TruncateExpression.restart_identity", - "CreateTableAsExpression.with_data", - "CTEExpression.materialized", + "DropSchemaStatement.cascade", + "DropTableStatement.cascade", + "DropViewStatement.cascade", + "DropMaterializedViewStatement.cascade", + "TruncateStatement.cascade", + "TruncateStatement.restart_identity", + "CreateTableAsStatement.with_data", + "CTEClause.materialized", "SnowflakeUnpivotExpression.include_nulls", - "SnowflakeCreateFunctionExpression.immutable", - "CreateMaterializedViewExpression.with_data", - "BeginTransactionExpression.deferrable", - "BeginTransactionExpression.wait", - "SetTransactionExpression.wait", + "SnowflakeCreateFunctionStatement.immutable", + "CreateMaterializedViewStatement.with_data", + "BeginTransactionStatement.deferrable", + "BeginTransactionStatement.wait", + "SetTransactionStatement.wait", } covered = {case.case_id for case in PAIR_CASES} assert expected == covered, ( f"pairs in scope but not guarded: {sorted(expected - covered)}; " f"guarded but not in scope: {sorted(covered - expected)}" ) - assert {case.case_id for case in MANDATORY_CASES} == {"AlterConstraint.enforced"} + assert {case.case_id for case in MANDATORY_CASES} == {"AlterConstraintAction.enforced"} class TestSentinels: @@ -823,7 +823,7 @@ class TestSentinels: def test_wrong_expected_sql_is_rejected(self): d = _dialect() - sql = CreateSequenceExpression(d, Sequence(d, "s"), no_order=True).to_sql()[0] + sql = CreateSequenceStatement(d, Sequence(d, "s"), no_order=True).to_sql()[0] with pytest.raises(AssertionError): assert sql == 'CREATE SEQUENCE "s" ORDER' @@ -831,7 +831,7 @@ def test_wrong_expected_feature_name_is_rejected(self): d = _dialect() with pytest.raises(AssertionError): with pytest.raises(UnsupportedFeatureError) as exc_info: - CreateSequenceExpression(d, Sequence(d, "s"), no_cycle=True).to_sql() + CreateSequenceStatement(d, Sequence(d, "s"), no_cycle=True).to_sql() assert exc_info.value.feature_name == "SEQUENCE ORDER" def test_wrong_collision_claim_is_rejected(self): @@ -839,8 +839,8 @@ def test_wrong_collision_claim_is_rejected(self): d = _dialect() with pytest.raises(AssertionError): assert ( - CreateSequenceExpression(d, Sequence(d, "s")).to_sql()[0] - == CreateSequenceExpression(d, Sequence(d, "s"), no_order=True).to_sql()[0] + CreateSequenceStatement(d, Sequence(d, "s")).to_sql()[0] + == CreateSequenceStatement(d, Sequence(d, "s"), no_order=True).to_sql()[0] ) def test_wrong_unpivot_default_claim_is_rejected(self): @@ -864,6 +864,6 @@ def test_wrong_declaring_mixin_claim_is_rejected(self): def test_wrong_wait_drop_claim_is_rejected(self): """The measured defect: WAIT must not be silently dropped to BEGIN.""" - outcome = _render(lambda: BeginTransactionExpression(_dialect(), wait=True)) + outcome = _render(lambda: BeginTransactionStatement(_dialect(), wait=True)) with pytest.raises(AssertionError): assert outcome == ("rendered", "BEGIN") diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_snowflake_constraint_switches.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_snowflake_constraint_switches.py new file mode 100644 index 0000000..964aad6 --- /dev/null +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_snowflake_constraint_switches.py @@ -0,0 +1,620 @@ +# tests/rhosocial/activerecord_snowflake_test/feature/backend/test_snowflake_constraint_switches.py +"""Snowflake constraint capability switches: values, locality, and evidence level. + +**not server-verified.** There is no Snowflake instance reachable from this +repository, so nothing asserted here is a measurement. Every verdict is read +off the two official grammar pages transcribed in +``impl/snowflake/mixins/constraint.py`` (``create-table-constraint`` for the +inline/out-of-line productions and ``alter-table`` for ``constraintAction``), +and each switch's docstring carries that record next to the ``待云验`` marker. +A rendering assertion below pins *what this dialect does with* a switch; it says +nothing about what a server would do with the SQL. + +Three things are asserted, and the second two are the easy ones to lose: + +* **the value** -- what the declaration answers; +* **the locality** -- that the answer resolves to a class in *this* backend's + package. A switch that silently fell back to core's ``ConstraintMixin`` could + have its default moved out from under Snowflake by a commit in another + repository, with nothing failing here. Declaring the switch locally is what + makes such a move a visible test failure instead of a quiet contract change; +* **the probe shape** -- ``(self)`` and nothing else, so the parameterised + ``constraint_type`` signatures the draft carried cannot come back. A probe + answers one question; per-kind narrowing belongs to a formatter. +""" + +import inspect + +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.dialect.mixins.ddl_column import DDLColumnMixin +from rhosocial.activerecord.backend.dialect.mixins.ddl_table import ConstraintMixin +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 import ( + ColumnCheckConstraint, + ColumnConstraint, + ColumnDefaultConstraint, + ColumnForeignKeyConstraint, + ColumnNotNullConstraint, + ColumnNullableConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, + TableCheckConstraint, + TableConstraint, + TableForeignKeyConstraint, + TablePrimaryKeyConstraint, + TableUniqueConstraint, +) +from rhosocial.activerecord.backend.impl.snowflake.dialect import SnowflakeDialect +from rhosocial.activerecord.backend.impl.snowflake.mixins import ( + SnowflakeConstraintMixin, +) + +#: The version the render assertions use. 8.0.0 is the dialect's own default. +DIALECT_VERSION = (8, 0, 0) + +#: Every constraint switch core's renderers or the protocol consult, with +#: Snowflake's declared answer. This mapping is the contract; the locality and +#: probe-shape tests below walk it, so a switch cannot leave the backend -- +#: or grow a parameter -- without failing first. +CONSTRAINT_SWITCHES = { + # Constraint kinds. + "supports_primary_key_constraint": True, + "supports_unique_constraint": True, + "supports_not_null_constraint": True, + "supports_check_constraint": True, + "supports_foreign_key_constraint": True, + "supports_exclude_constraint": False, + # Referential actions and MATCH. + "supports_fk_on_delete": True, + "supports_fk_on_update": True, + "supports_fk_match": True, + "supports_foreign_key_on_delete": True, + "supports_foreign_key_on_update": True, + # Named constraints (SQL-92), consulted by both render paths. + "supports_constraint_naming": True, + # Clause attributes -- the render decision, deliberately False without a + # live server. See the module docstring of the mixin. + "supports_deferrable_constraint": False, + "supports_constraint_enforced": False, + "supports_alter_constraint_enforced": False, + "supports_constraint_novalidate": False, + # ALTER TABLE lifecycle. + "supports_add_constraint": True, + "supports_drop_constraint": True, + "supports_drop_constraint_if_exists": False, + "supports_validate_constraint": False, + # The two that live on other Snowflake mixins but answer constraint + # questions, so they are pinned here too. + "supports_column_comment": True, +} + +#: Probes that core does not declare at all. They are declared here so the +#: grammar answer is written down in one place, and they are kept apart from the +#: flat switches above because no formatter reads them: the flat probe is the +#: render decision, and these are its per-level / per-kind elaboration. +BACKEND_ONLY_SWITCHES = ( + "supports_column_primary_key_constraint", + "supports_column_unique_constraint", + "supports_column_check_constraint", + "supports_column_foreign_key_constraint", + "supports_table_primary_key_constraint", + "supports_table_unique_constraint", + "supports_table_check_constraint", + "supports_table_foreign_key_constraint", + "supports_column_naming_primary_key_constraint", + "supports_column_naming_unique_constraint", + "supports_column_naming_check_constraint", + "supports_column_naming_foreign_key_constraint", + "supports_table_naming_primary_key_constraint", + "supports_table_naming_unique_constraint", + "supports_table_naming_check_constraint", + "supports_table_naming_foreign_key_constraint", + "supports_column_enforced_primary_key_constraint", + "supports_column_enforced_unique_constraint", + "supports_column_enforced_foreign_key_constraint", + "supports_column_enforced_check_constraint", + "supports_table_enforced_primary_key_constraint", + "supports_table_enforced_unique_constraint", + "supports_table_enforced_foreign_key_constraint", + "supports_table_enforced_check_constraint", + "supports_column_deferrable_primary_key_constraint", + "supports_column_deferrable_unique_constraint", + "supports_column_deferrable_foreign_key_constraint", + "supports_column_deferrable_check_constraint", + "supports_table_deferrable_primary_key_constraint", + "supports_table_deferrable_unique_constraint", + "supports_table_deferrable_foreign_key_constraint", + "supports_table_deferrable_check_constraint", + "supports_column_constraint_enable_disable", + "supports_table_constraint_enable_disable", + "supports_column_constraint_novalidate", + "supports_table_constraint_novalidate", + "supports_column_constraint_rely", + "supports_table_constraint_rely", + "supports_column_fk_match", + "supports_table_fk_match", + "supports_column_on_update_action", + "supports_table_on_update_action", + "supports_table_constraint_comment", + "supports_add_constraint_if_not_exists", + "supports_table_add_primary_key_constraint", + "supports_table_add_unique_constraint", + "supports_table_add_check_constraint", + "supports_table_add_foreign_key_constraint", + "supports_drop_constraint_by_kind", + "supports_table_rename_constraint", + "supports_table_alter_constraint", +) + +#: The substring every switch docstring must carry: this backend cannot +#: measure, and a docstring that stops saying so reads as a measurement. +EVIDENCE_MARKER = "not server-verified" + +#: The two reference pages every production was transcribed from. Every +#: docstring names at least one grammar fragment, and the two constants are +#: asserted so the pages themselves cannot drift. +GRAMMAR_URL_FRAGMENTS = ("create-table-constraint", "alter-table") + + +def _dialect(): + return SnowflakeDialect(version=DIALECT_VERSION) + + +class TestConstraintSwitchValues: + """What the declaration answers.""" + + @pytest.mark.parametrize( + "switch, expected", + CONSTRAINT_SWITCHES.items(), + ids=sorted(CONSTRAINT_SWITCHES), + ) + def test_switch_answers_the_declared_value(self, switch, expected): + assert getattr(_dialect(), switch)() is expected + + def test_the_two_enforcement_probes_and_deferral_are_false(self): + """The render decision, taken deliberately without a server. + + The grammar has ``[ [ NOT ] ENFORCED ]`` and ``[ [ NOT ] DEFERRABLE ]`` + for the key kinds, so a ``True`` would be defensible as a *grammar* + reading. It is refused instead because the reference states the + non-default ``ENABLE`` / ``VALIDATE`` spelling means the constraint is + *not created*, silently -- emitting it would be worse than a syntax + error. Pinned so a later change has to argue with the reason. + """ + d = _dialect() + assert d.supports_constraint_enforced() is False + assert d.supports_alter_constraint_enforced() is False + assert d.supports_deferrable_constraint() is False + + def test_the_grammar_record_still_says_the_tokens_are_there(self): + """The per-kind record is the other half, and it is not reconciled away. + + ``supports_{column,table}_enforced_{primary_key,unique,foreign_key}_constraint`` + and the matching deferral probes answer ``True`` because the grammar + contains the clauses; only CHECK is ``False``, at both levels. The flat + probes answer the render question. Collapsing the two would lose the + record an eventual live run would be checked against. + """ + d = _dialect() + for kind in ("primary_key", "unique", "foreign_key"): + for level in ("column", "table"): + assert getattr(d, f"supports_{level}_enforced_{kind}_constraint")() is True + assert getattr(d, f"supports_{level}_deferrable_{kind}_constraint")() is True + for level in ("column", "table"): + assert getattr(d, f"supports_{level}_enforced_check_constraint")() is False + assert getattr(d, f"supports_{level}_deferrable_check_constraint")() is False + + +class TestConstraintSwitchSignature: + """Every probe is ``(self)``, with no exceptions.""" + + @pytest.mark.parametrize( + "switch", sorted(CONSTRAINT_SWITCHES), ids=sorted(CONSTRAINT_SWITCHES) + ) + def test_probe_takes_no_argument(self, switch): + """Checked on the bound method, so a defaulted parameter cannot slip in. + + ``(self, constraint_type=None)`` is still callable with no argument, and + that is exactly how the draft's two probes passed for a while. The + signature is read off the function, not off a call. + """ + d = _dialect() + bound = getattr(d, switch) + signature = inspect.signature(bound) + assert list(signature.parameters) == [], ( + f"{switch} takes {list(signature.parameters)}; every constraint probe " + f"answers one question with no arguments" + ) + + +class TestConstraintSwitchesAreDeclaredLocally: + """The verdict must be this backend's, not an inherited default.""" + + @pytest.mark.parametrize( + "switch", sorted(CONSTRAINT_SWITCHES), ids=sorted(CONSTRAINT_SWITCHES) + ) + def test_switch_is_declared_by_a_snowflake_mixin(self, switch): + owners = [ + cls + for cls in SnowflakeDialect.__mro__ + if switch in getattr(cls, "__dict__", {}) + ] + assert owners, f"{switch} is declared by no class in the MRO" + assert any( + cls.__module__.startswith( + "rhosocial.activerecord.backend.impl.snowflake" + ) + for cls in owners + ), ( + f"{switch} is declared only outside this package ({owners}); it would " + f"fall through to a core default this repository cannot pin" + ) + + @pytest.mark.parametrize( + "switch", sorted(CONSTRAINT_SWITCHES), ids=sorted(CONSTRAINT_SWITCHES) + ) + def test_resolved_switch_is_owned_by_this_backend(self, switch): + owner = getattr(_dialect(), switch).__qualname__ + module = inspect.getmodule(getattr(_dialect(), switch)).__name__ + assert module.startswith( + "rhosocial.activerecord.backend.impl.snowflake" + ), f"{switch} resolves to {owner} in {module}, not this backend" + + def test_backend_mixin_precedes_the_core_constraint_mixins(self): + """MRO order is what makes the local declarations win at all.""" + mro = SnowflakeDialect.__mro__ + local = mro.index(SnowflakeConstraintMixin) + assert local < mro.index(ConstraintMixin), ( + "SnowflakeConstraintMixin must precede core's ConstraintMixin" + ) + assert local < mro.index(DDLColumnMixin), ( + "SnowflakeConstraintMixin must precede DDLColumnMixin" + ) + + +class TestEveryDeclaredProbeIsPinned: + """The tables above are exact, in both directions.""" + + def test_no_switch_in_the_mixin_is_missing_from_the_tables(self): + declared = { + name + for name in vars(SnowflakeConstraintMixin) + if name.startswith("supports_") + } + pinned = set(CONSTRAINT_SWITCHES) | set(BACKEND_ONLY_SWITCHES) + # The two constraint surfaces that belong to other mixins and are + # pinned above because they answer constraint questions. + elsewhere = {"supports_column_comment", "supports_drop_constraint_if_exists"} + missing = declared - pinned + assert not missing, ( + f"{sorted(missing)} are declared on the mixin but pinned nowhere; " + f"add each to CONSTRAINT_SWITCHES or BACKEND_ONLY_SWITCHES" + ) + stale = (pinned - elsewhere) - declared + assert not stale, ( + f"{sorted(stale)} are pinned but no longer declared on the mixin" + ) + + def test_the_general_kind_switch_lives_on_the_capability_mixin(self): + """``supports_constraints`` is a general switch, not a per-kind one.""" + d = _dialect() + assert d.supports_constraints() is True + + +class TestSwitchDocstringsCarryTheirEvidenceLevel: + """A verdict that cannot be traced is a guess with a name.""" + + @pytest.mark.parametrize( + "switch", + sorted(set(CONSTRAINT_SWITCHES) | set(BACKEND_ONLY_SWITCHES)), + ids=sorted(set(CONSTRAINT_SWITCHES) | set(BACKEND_ONLY_SWITCHES)), + ) + def test_docstring_says_it_is_not_server_verified(self, switch): + # Scoped to this module: ``supports_column_comment`` and + # ``supports_drop_constraint_if_exists`` are declared by another + # Snowflake mixin and carry their own evidence, which is not this + # wave's to rewrite. They are still pinned for value and locality + # above, where the question is the same. + if switch not in vars(SnowflakeConstraintMixin): + pytest.skip(f"{switch} is declared by another Snowflake mixin") + doc = getattr(SnowflakeConstraintMixin, switch).__doc__ + assert doc, f"{switch} has no docstring: the evidence has nowhere to live" + assert EVIDENCE_MARKER in doc, ( + f"{switch} does not say {EVIDENCE_MARKER!r}; a switch that stops " + f"admitting it was not measured reads as a measurement" + ) + + def test_module_docstring_states_the_evidence_level_and_the_policy(self): + """The header is where the level and the modifier policy are argued.""" + doc = SnowflakeConstraintMixin.__doc__ or "" + module_doc = inspect.getmodule(SnowflakeConstraintMixin).__doc__ or "" + combined = doc + module_doc + assert "Nothing here was measured" in combined + assert EVIDENCE_MARKER in combined + assert "Modifier policy" in combined, ( + "the module docstring must argue why the enforcement and deferral " + "probes are False while the grammar has the tokens" + ) + for fragment in GRAMMAR_URL_FRAGMENTS: + assert fragment in combined, f"the reference page {fragment!r} is not cited" + + +class TestConstraintRendering: + """What this dialect does with each kind. A render, not a measurement.""" + + @pytest.fixture + def dialect(self): + return _dialect() + + def _table(self, dialect): + return Table(dialect, "other") + + def test_column_kinds_render_their_standard_spellings(self, dialect): + for branch, expected in ( + (ColumnPrimaryKeyConstraint(dialect), ' CONSTRAINT "c" PRIMARY KEY'), + (ColumnUniqueConstraint(dialect), ' CONSTRAINT "c" UNIQUE'), + (ColumnNotNullConstraint(dialect), ' CONSTRAINT "c" NOT NULL'), + (ColumnNullableConstraint(dialect), ' CONSTRAINT "c" NULL'), + (ColumnDefaultConstraint(dialect, "x"), " CONSTRAINT \"c\" DEFAULT 'x'"), + ( + ColumnCheckConstraint( + dialect, + ComparisonPredicate( + dialect, "=", Column(dialect, "a"), Column(dialect, "b") + ), + ), + ' CONSTRAINT "c" CHECK ("a" = "b")', + ), + ): + sql, params = ColumnConstraint(dialect, branch=branch, name="c").to_sql() + assert sql == expected, (type(branch).__name__, sql) + assert params == () + + def test_column_foreign_key_renders_references_and_actions(self, dialect): + branch = ColumnForeignKeyConstraint( + dialect, (self._table(dialect), ["id"]), on_delete="CASCADE" + ) + sql, params = ColumnConstraint(dialect, branch=branch).to_sql() + assert "REFERENCES" in sql + assert "ON DELETE CASCADE" in sql + assert params == () + + def test_match_is_rendered_because_the_probe_says_so(self, dialect): + """``MATCH`` is the one inherited answer that was refusing real grammar. + + Core's ``DDLColumnMixin`` declares ``supports_fk_match`` False and + precedes ``ConstraintMixin`` (True) in a dialect that declares nothing, + so the inherited answer refused a clause Snowflake's reference lists. + This is the render that confirms the local ``True`` reaches the path. + """ + branch = ColumnForeignKeyConstraint( + dialect, (self._table(dialect), ["id"]), match_type="FULL" + ) + sql, _ = ColumnConstraint(dialect, branch=branch).to_sql() + assert "MATCH FULL" in sql + + def test_table_kinds_render_their_standard_spellings(self, dialect): + cases = ( + (TablePrimaryKeyConstraint(dialect, ["a", "b"]), 'PRIMARY KEY ("a", "b")'), + (TableUniqueConstraint(dialect, ["a"]), 'UNIQUE ("a")'), + ( + TableCheckConstraint( + dialect, + ComparisonPredicate( + dialect, "=", Column(dialect, "a"), Column(dialect, "b") + ), + ), + 'CHECK (', + ), + ) + for branch, expected in cases: + sql, params = TableConstraint( + dialect, branch=branch, name="c" + ).to_sql() + assert sql.startswith('CONSTRAINT "c" '), sql + assert expected in sql, sql + assert params == () + + def test_table_foreign_key_renders_its_references(self, dialect): + branch = TableForeignKeyConstraint( + dialect, ["a"], (self._table(dialect), ["id"]) + ) + sql, params = TableConstraint(dialect, branch=branch, name="fk").to_sql() + assert sql.startswith('CONSTRAINT "fk" FOREIGN KEY ("a") REFERENCES'), sql + assert params == () + + def test_exclude_is_refused_by_name(self, dialect): + """Snowflake has no EXCLUDE production; the branch refuses rather than renders.""" + from rhosocial.activerecord.backend.expression.statements import ( + TableExcludeConstraint, + ) + + with pytest.raises(UnsupportedFeatureError) as exc_info: + TableConstraint( + dialect, branch=TableExcludeConstraint(dialect, ["a"]) + ).to_sql() + assert "EXCLUDE" in str(exc_info.value) + + def test_naming_prefix_is_gated_on_the_naming_probe(self, dialect): + """The prefix renders because the probe is True, and the probe is consulted. + + A column constraint with a name renders ``CONSTRAINT ``; the same + clause without a name does not. That alone would pass even if the probe + were never read, so the gate is also flipped: a dialect that answers + ``False`` must refuse the named clause instead of dropping the name. + """ + named, _ = ColumnConstraint( + dialect, branch=ColumnNotNullConstraint(dialect), name="c" + ).to_sql() + unnamed, _ = ColumnConstraint( + dialect, branch=ColumnNotNullConstraint(dialect) + ).to_sql() + assert named == ' CONSTRAINT "c" NOT NULL' + assert unnamed == " NOT NULL" + + class _NoNames(SnowflakeDialect): + def supports_constraint_naming(self) -> bool: + return False + + refusing = _NoNames(version=DIALECT_VERSION) + with pytest.raises(UnsupportedFeatureError) as exc_info: + ColumnConstraint( + refusing, branch=ColumnNotNullConstraint(refusing), name="c" + ).to_sql() + assert "CONSTRAINT name" in str(exc_info.value) + with pytest.raises(UnsupportedFeatureError) as exc_info: + TableConstraint( + refusing, + branch=TablePrimaryKeyConstraint(refusing, ["a"]), + name="c", + ).to_sql() + assert "CONSTRAINT name" in str(exc_info.value) + + +class TestModifierSpellingsAreRefusedByName: + """The fail-closed half of the modifier policy, asserted as refusals.""" + + @pytest.fixture + def dialect(self): + return _dialect() + + def _fk(self, dialect): + return ColumnForeignKeyConstraint(dialect, (Table(dialect, "t"), ["id"])) + + @pytest.mark.parametrize("flag", ("enforced", "not_enforced")) + def test_column_enforcement_is_refused(self, dialect, flag): + with pytest.raises(UnsupportedFeatureError) as exc_info: + ColumnConstraint( + dialect, branch=self._fk(dialect), **{flag: True} + ).to_sql() + assert "ENFORCED" in str(exc_info.value) + + @pytest.mark.parametrize("flag", ("enforced", "not_enforced")) + def test_table_enforcement_is_refused(self, dialect, flag): + with pytest.raises(UnsupportedFeatureError) as exc_info: + TableConstraint( + dialect, + branch=TablePrimaryKeyConstraint(dialect, ["a"]), + **{flag: True}, + ).to_sql() + assert "ENFORCED" in str(exc_info.value) + + def test_deferral_is_refused(self, dialect): + with pytest.raises(UnsupportedFeatureError) as exc_info: + ColumnConstraint( + dialect, branch=self._fk(dialect), deferrable=True + ).to_sql() + assert "DEFERRABLE" in str(exc_info.value) + + def test_deferral_on_a_check_is_refused_at_construction_first(self, dialect): + """The kind gate fires before the probe, and that is the node's job. + + A CHECK branch cannot carry deferral at all, so the refusal is core's + ``ValueError`` from ``validate_branch_traits`` -- earlier, and for a + different reason, than the dialect's ``UnsupportedFeatureError``. + """ + with pytest.raises(ValueError): + ColumnConstraint( + dialect, + branch=ColumnCheckConstraint( + dialect, + ComparisonPredicate( + dialect, "=", Column(dialect, "a"), Column(dialect, "b") + ), + ), + deferrable=True, + ) + + def test_not_valid_spelling_is_refused(self, dialect): + """``NOT VALID`` is not Snowflake syntax, and the probe keeps it out.""" + from rhosocial.activerecord.backend.expression.statements import ( + AddTableConstraintAction, + ConstraintValidation, + ) + + action = AddTableConstraintAction( + dialect, + TableConstraint( + dialect, + branch=TableCheckConstraint( + dialect, + ComparisonPredicate( + dialect, "=", Column(dialect, "a"), Column(dialect, "b") + ), + ), + name="ck", + validation=ConstraintValidation.NOVALIDATE, + ), + ) + with pytest.raises(UnsupportedFeatureError) as exc_info: + action.to_sql() + assert "NOT VALID" in str(exc_info.value) + + def test_alter_constraint_enforcement_is_refused_by_name(self, dialect): + from rhosocial.activerecord.backend.expression.statements import ( + AlterConstraintAction, + ) + + for flag in ("enforced", "not_enforced"): + with pytest.raises(UnsupportedFeatureError) as exc_info: + AlterConstraintAction( + dialect, + constraint_name="ck", + constraint_kind=TableCheckConstraint, + **{flag: True}, + ).to_sql() + assert "ALTER CONSTRAINT" in str(exc_info.value) + + def test_validate_constraint_has_no_standalone_statement(self, dialect): + from rhosocial.activerecord.backend.expression.statements import ( + ValidateConstraintAction, + ) + + with pytest.raises(UnsupportedFeatureError) as exc_info: + ValidateConstraintAction(dialect, constraint_name="ck").to_sql() + assert "VALIDATE CONSTRAINT" in str(exc_info.value) + + def test_add_column_if_not_exists_restriction_is_kind_based(self, dialect): + """The IF NOT EXISTS guard reads the branch class, not a deleted enum. + + Snowflake refuses ``ADD COLUMN IF NOT EXISTS`` on a column that also + carries DEFAULT / IDENTITY / PK / FK / UNIQUE. Every branch that trips + it is asserted, so the migration off ``ColumnConstraintType`` cannot have + silently emptied the set. + """ + from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ) + from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + AddColumnAction, + ) + from rhosocial.activerecord.backend.expression.types import IntegerType + + forbidden = ( + ColumnDefaultConstraint(dialect, 1), + ColumnPrimaryKeyConstraint(dialect), + ColumnForeignKeyConstraint(dialect, (Table(dialect, "t"), ["id"])), + ColumnUniqueConstraint(dialect), + ) + for branch in forbidden: + column = ColumnDefinition( + dialect, + "c", + IntegerType(dialect), + constraints=[ColumnConstraint(dialect, branch=branch)], + ) + with pytest.raises(UnsupportedFeatureError): + AddColumnAction(dialect, column, if_not_exists=True).to_sql() + + allowed = ColumnDefinition( + dialect, + "c", + IntegerType(dialect), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))], + ) + sql, _ = AddColumnAction(dialect, allowed, if_not_exists=True).to_sql() + assert sql == 'ADD COLUMN IF NOT EXISTS "c" INTEGER NOT NULL' diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_snowflake_database_ddl.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_snowflake_database_ddl.py index 785514a..7879bf0 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_snowflake_database_ddl.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_snowflake_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.snowflake.dialect import SnowflakeDialect @@ -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 \ No newline at end of file diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_snowflake_ddl_improvements.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_snowflake_ddl_improvements.py index 35a01cb..9f1cd85 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_snowflake_ddl_improvements.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_snowflake_ddl_improvements.py @@ -3,7 +3,7 @@ import pytest from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError -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 from rhosocial.activerecord.backend.expression.types import IntegerType @@ -61,7 +61,7 @@ def test_schema_if_exists_supported(self): class TestSnowflakeTableDeclarationGating: def test_table_declaration_defaults_are_absent(self): dialect = SnowflakeDialect(version=(8, 0, 0)) - expression = CreateTableExpression( + expression = CreateTableStatement( dialect, Table(dialect, "plain_table_defaults"), [ColumnDefinition(dialect, "id", IntegerType(dialect))], @@ -76,7 +76,7 @@ def test_table_declaration_defaults_are_absent(self): def test_table_inherits_is_propagated_and_rejected(self): dialect = SnowflakeDialect(version=(8, 0, 0)) assert dialect.supports_table_inheritance() is False - expression = CreateTableExpression( + expression = CreateTableStatement( dialect, Table(dialect, "inherited"), [ColumnDefinition(dialect, "id", IntegerType(dialect))], @@ -89,7 +89,7 @@ def test_table_inherits_is_propagated_and_rejected(self): def test_table_tablespace_is_propagated_and_rejected(self): dialect = SnowflakeDialect(version=(8, 0, 0)) assert dialect.supports_table_tablespace() is False - expression = CreateTableExpression( + expression = CreateTableStatement( dialect, Table(dialect, "tablespaced"), [ColumnDefinition(dialect, "id", IntegerType(dialect))], diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/types/test_data_type_parsing.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/types/test_data_type_parsing.py index 0c938ff..c6116f7 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/types/test_data_type_parsing.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/types/test_data_type_parsing.py @@ -524,9 +524,10 @@ 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 sweep is vendored beside the matrix: the testsuite deleted its + # shared one together with its round-trip harness, when round-trip + # coverage moved to each backend (mysql did the same). + from expression_roundtrip_harness import parse_roundtrip_failures from rhosocial.activerecord.backend.impl.snowflake.dialect import ( SnowflakeDialect as _SweepDialect, ) diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/types/test_snowflake_type_protocol.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/types/test_snowflake_type_protocol.py index b95822a..a73443c 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/types/test_snowflake_type_protocol.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/types/test_snowflake_type_protocol.py @@ -300,13 +300,17 @@ def _all_snowflake_types(): def test_the_family_is_not_empty(self): """Guard the guard: an empty scan would pass every assertion below. - Sixteen of these used to be ``(SnowflakeDataTypeMixin, CoreType)``; - ``SnowflakeUserDefinedType`` already sat directly on ``DataType``, and + Seventeen, measured. Thirteen derive from a core type; ``SnowflakeUuidType`` was added for the native ``UUID`` that arrived in - server release 10.2 — both are here to keep the count honest. + server release 10.2, and ``SnowflakeGeographyType``, + ``SnowflakeGeometryType`` and ``SnowflakeUserDefinedType`` sit directly + on ``DataType`` because core has no concept for them. The count was + eighteen while ``SnowflakeDataTypeMixin`` still existed; that mixin is + gone (every type now inherits its core class directly), and this number + is what keeps the arithmetic from drifting back. """ found = self._all_snowflake_types() - assert len(found) == 18 + assert len(found) == 17 assert SnowflakeUserDefinedType in found assert SnowflakeUuidType in found diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/query/test_collation_expression.py b/tests/rhosocial/activerecord_snowflake_test/feature/query/test_collation_expression.py index 67a01db..7bdc1b0 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/query/test_collation_expression.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/query/test_collation_expression.py @@ -8,6 +8,7 @@ import pytest from rhosocial.activerecord.backend.expression import Column, Literal +from rhosocial.activerecord.backend.expression.collation import collate from rhosocial.activerecord.backend.impl.snowflake.backend import SnowflakeBackend from rhosocial.activerecord.backend.impl.snowflake.collation import SnowflakeCollation from rhosocial.activerecord.backend.impl.snowflake.config import SnowflakeConnectionConfig @@ -84,7 +85,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(SnowflakeCollation.EN_CI) + # A Literal is a value node and carries no operation surface of its own + # (rules §4.3), so COLLATE is composed with the collate factory over + # the literal rather than as a chain on it. The binding is unchanged: + # the factory defers to the wrapped node's own inline decision. + expr = collate(Literal(dialect, "Alice"), SnowflakeCollation.EN_CI) sql, params = expr.to_sql() @@ -97,12 +102,6 @@ def test_rejects_schema_qualified_collation(self, dialect): with pytest.raises(Exception, match="COLLATE options: schema"): expr.to_sql() - def test_rejects_keyword_collation(self, dialect): - expr = Column(dialect, "name").collate("upper", keyword=True) - - with pytest.raises(Exception, match="COLLATE options: keyword"): - expr.to_sql() - def test_rejects_unsupported_collation(self, dialect): expr = Column(dialect, "name").collate("unknown-ci")