diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 714791b..d60ecb4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,8 +20,8 @@ permissions: env: # rhosocial-activerecord and rhosocial-activerecord-testsuite dev releases are # not published to PyPI; install them from the feature/parameter-inline branch. - CORE_PKG: "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/activerecord-derived-ddl" - TESTSUITE_PKG: "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" + CORE_PKG: "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" + TESTSUITE_PKG: "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" jobs: test: diff --git a/CLAUDE.md b/CLAUDE.md index 26f7ecb..6a7dcdb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,3 +64,9 @@ The backend config supports `api_endpoint` and `use_anonymous_credentials` for e - **Service account authentication**: Key file or application default credentials - **Query jobs**: All queries run as jobs; results fetched asynchronously - **Local Testing**: Uses `goccy/bigquery-emulator` (Docker) for CI/integration tests; no real GCP account needed + +## Search Scope + +- Use `rg` for repository searches; never use `grep`. +- Limit searches to this `python-activerecord` repository and its related testsuite/backend directories. +- Exclude `.git/`, `.venv*/`, `__pycache__/`, `build/`, `dist/`, and other cache directories. diff --git a/changelog.d/1.added.md b/changelog.d/1.added.md new file mode 100644 index 0000000..974bcda --- /dev/null +++ b/changelog.d/1.added.md @@ -0,0 +1 @@ +Support native BigQuery materialized views, which now render instead of being reported as supported and then dropped. Add a type domain negative contract, render DDL through direct DDL expressions, and cover `DDLSource` collection, table inheritance and tablespace. diff --git a/changelog.d/1.internal.md b/changelog.d/1.internal.md new file mode 100644 index 0000000..61e0d7b --- /dev/null +++ b/changelog.d/1.internal.md @@ -0,0 +1 @@ +Install core and testsuite from release branches in CI instead of the derived-DDL feature branch, and stop the pre-commit hook from blocking commits on revisions that predate `scripts/preflight.sh`. diff --git a/changelog.d/3.added.md b/changelog.d/3.added.md new file mode 100644 index 0000000..2ad2513 --- /dev/null +++ b/changelog.d/3.added.md @@ -0,0 +1 @@ +Add BigQuery materialized view support: `BigQueryCreateMaterializedViewExpression` (`OR REPLACE` / `IF NOT EXISTS` / `PARTITION BY` / `CLUSTER BY` / `OPTIONS(...)`), `BigQueryDropMaterializedViewExpression`, `BigQueryAlterMaterializedViewSetOptionsExpression`, `BigQueryCreateMaterializedViewReplicaExpression` (`AS REPLICA OF`), and the enumerated `BigQueryMaterializedViewOption` catalogue for `OPTIONS(...)` names. Generic-expression clauses BigQuery does not have (column aliases, `TABLESPACE`, storage parameters, `WITH [NO] DATA`, `CASCADE`) are rejected with `UnsupportedFeatureError` rather than silently dropped. `supports_materialized_view()` now reports `True`; `supports_refresh_materialized_view()` stays `False` because BigQuery has no `REFRESH MATERIALIZED VIEW` statement — refresh is scheduled through `OPTIONS(enable_refresh=…, refresh_interval_minutes=…)`. diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh index e06f0cb..81b3dda 100644 --- a/scripts/install-git-hooks.sh +++ b/scripts/install-git-hooks.sh @@ -4,21 +4,42 @@ # Installs an optional pre-commit hook that runs scripts/preflight.sh so a # broken import/lint is caught before the commit (and before a CI round-trip). # Bypass a single commit with: git commit --no-verify +# +# Re-run this after pulling, and re-run it from the repository root. To remove +# the hook, delete .git/hooks/pre-commit (or the core.hooksPath directory). set -euo pipefail cd "$(dirname "$0")/.." -HOOK=".git/hooks/pre-commit" -if [ ! -d .git/hooks ]; then - echo "install-git-hooks: .git/hooks not found (not a git repo?)" >&2 - exit 2 +# git ignores .git/hooks entirely once core.hooksPath is set, so a hook written +# there would never run while this script still reported success. +HOOKS_PATH="$(git config core.hooksPath || true)" +if [ -n "$HOOKS_PATH" ]; then + echo "install-git-hooks: core.hooksPath is set to '$HOOKS_PATH'." >&2 + echo "install-git-hooks: .git/hooks is ignored by git; writing the hook to" >&2 + echo " '$HOOKS_PATH/pre-commit' instead." >&2 + HOOK="$HOOKS_PATH/pre-commit" + mkdir -p "$HOOKS_PATH" +else + HOOK=".git/hooks/pre-commit" + if [ ! -d .git/hooks ]; then + echo "install-git-hooks: .git/hooks not found (not a git repo?)" >&2 + exit 2 + fi fi cat > "$HOOK" <<'HOOK_BODY' #!/usr/bin/env bash # Installed by scripts/install-git-hooks.sh — runs the local pre-flight. # Skip with: git commit --no-verify -exec "$(git rev-parse --show-toplevel)/scripts/preflight.sh" +# +# The guard below matters on older revisions: this hook is a local, untracked +# artifact, so it survives a checkout of a commit that predates +# scripts/preflight.sh. Without the guard, exec fails with a bare +# "No such file or directory" and blocks the commit with nothing to act on. +PREFLIGHT="$(git rev-parse --show-toplevel)/scripts/preflight.sh" +[ -x "$PREFLIGHT" ] || exit 0 +exec "$PREFLIGHT" HOOK_BODY chmod +x "$HOOK" diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/__init__.py b/src/rhosocial/activerecord/backend/impl/bigquery/__init__.py index 6ecd513..002d608 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/__init__.py @@ -22,6 +22,16 @@ from .dialect import BigQueryDialect from .transaction import BigQueryTransactionManager from .async_transaction import AsyncBigQueryTransactionManager +from .materialized_view_options import ( # noqa: F401 + BigQueryMaterializedViewOption, + BigQueryMaterializedViewOptionValueType, +) +from .expression.materialized_view import ( + BigQueryAlterMaterializedViewSetOptionsExpression, + BigQueryCreateMaterializedViewExpression, + BigQueryCreateMaterializedViewReplicaExpression, + BigQueryDropMaterializedViewExpression, +) from .types import BigQueryStruct, BigQueryArray, BigQueryJSON from .adapters import ( BigQueryStructAdapter, @@ -35,6 +45,7 @@ BigQueryArraySupport, BigQueryJSONSupport, BigQueryGeographySupport, + BigQueryMaterializedViewSupport, ) from .mixins import ( BigQueryStructMixin, @@ -50,6 +61,13 @@ "BigQueryDialect", "BigQueryTransactionManager", "AsyncBigQueryTransactionManager", + # Materialized views + "BigQueryMaterializedViewOption", + "BigQueryMaterializedViewOptionValueType", + "BigQueryCreateMaterializedViewExpression", + "BigQueryDropMaterializedViewExpression", + "BigQueryAlterMaterializedViewSetOptionsExpression", + "BigQueryCreateMaterializedViewReplicaExpression", "BigQueryStruct", "BigQueryArray", "BigQueryJSON", @@ -62,6 +80,7 @@ "BigQueryArraySupport", "BigQueryJSONSupport", "BigQueryGeographySupport", + "BigQueryMaterializedViewSupport", "BigQueryStructMixin", "BigQueryArrayMixin", "BigQueryJSONMixin", diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/dialect.py b/src/rhosocial/activerecord/backend/impl/bigquery/dialect.py index c5ec49a..5f9760c 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/dialect.py @@ -9,6 +9,7 @@ JoinSupport, ViewSupport, SchemaSupport, IndexSupport, ConstraintSupport, IntrospectionSupport, TransactionControlSupport, SQLFunctionSupport, JSONSupport, TruncateSupport, + UserDefinedTypeSupport, DomainSupport, ) from rhosocial.activerecord.backend.dialect.mixins import ( CTEMixin, WindowFunctionMixin, JSONMixin, @@ -19,12 +20,13 @@ # Core generic mixins (backend-agnostic implementations) PredicateMixin, ExpressionMixin, DQLMixin, DMLMixin, SetOperationMixin, DateTimeMixin, - DDLColumnMixin, DDLTypeMixin, TransactionControlMixin, - CollationMixin, + DDLColumnMixin, DDLTypeMixin, UserDefinedTypeMixin, DomainMixin, + TransactionControlMixin, CollationMixin, ) from .protocols import ( BigQueryStructSupport, BigQueryArraySupport, BigQueryJSONSupport, BigQueryGeographySupport, + BigQueryMaterializedViewSupport, ) from .mixins import ( BigQueryStructMixin, BigQueryArrayMixin, @@ -36,6 +38,7 @@ BigQueryDDLColumnMixin, BigQueryCapabilityMixin, BigQueryIdentifierMixin, + BigQueryMaterializedViewMixin, ) @@ -43,10 +46,13 @@ class BigQueryDialect( # New BigQuery-specific mixins (BEFORE SQLDialectBase and generic mixins they override) BigQueryCapabilityMixin, BigQueryTypeSupportMixin, + UserDefinedTypeMixin, + DomainMixin, BigQueryDQLMixin, BigQuerySchemaMixin, BigQueryDDLColumnMixin, BigQueryIdentifierMixin, + BigQueryMaterializedViewMixin, # Before ViewMixin to override materialized view DDL SQLDialectBase, # Generic mixins CTEMixin, WindowFunctionMixin, JSONMixin, @@ -68,6 +74,8 @@ class BigQueryDialect( SQLFunctionSupport, JSONSupport, TruncateSupport, BigQueryStructSupport, BigQueryArraySupport, BigQueryJSONSupport, BigQueryGeographySupport, + BigQueryMaterializedViewSupport, + UserDefinedTypeSupport, DomainSupport, ): def __init__(self, version: Tuple[int, ...] = (3, 0, 0), **kwargs): super().__init__(**kwargs) diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/bigquery/expression/__init__.py new file mode 100644 index 0000000..4ad80af --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/bigquery/expression/__init__.py @@ -0,0 +1,16 @@ +# src/rhosocial/activerecord/backend/impl/bigquery/expression/__init__.py +"""BigQuery-specific DDL expressions.""" + +from .materialized_view import ( + BigQueryAlterMaterializedViewSetOptionsExpression, + BigQueryCreateMaterializedViewExpression, + BigQueryCreateMaterializedViewReplicaExpression, + BigQueryDropMaterializedViewExpression, +) + +__all__ = [ + "BigQueryAlterMaterializedViewSetOptionsExpression", + "BigQueryCreateMaterializedViewExpression", + "BigQueryCreateMaterializedViewReplicaExpression", + "BigQueryDropMaterializedViewExpression", +] diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/expression/materialized_view.py b/src/rhosocial/activerecord/backend/impl/bigquery/expression/materialized_view.py new file mode 100644 index 0000000..619f172 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/bigquery/expression/materialized_view.py @@ -0,0 +1,215 @@ +# src/rhosocial/activerecord/backend/impl/bigquery/expression/materialized_view.py +"""BigQuery materialized view DDL expressions. + +GoogleSQL syntax (BigQuery Standard SQL DDL reference): + + CREATE [ OR REPLACE ] MATERIALIZED VIEW [ IF NOT EXISTS ] mv_name + [PARTITION BY partition_expression] + [CLUSTER BY clustering_column_list] + [OPTIONS(materialized_view_option_list)] + AS query_expression + + ALTER MATERIALIZED VIEW [IF EXISTS] mv_name + SET OPTIONS(materialized_view_set_options_list) + + DROP MATERIALIZED VIEW [IF EXISTS] mv_name + + CREATE MATERIALIZED VIEW replica_name + [OPTIONS(materialized_view_replica_option_list)] + AS REPLICA OF source_materialized_view_name + +Divergence from the SQL-standard statement +------------------------------------------ +* No ``TABLESPACE``, no ``WITH (storage_parameter)``, no ``WITH [NO] DATA`` + and no column-alias list — a BigQuery materialized view takes its column + names from the query. Those fields are rejected rather than dropped. +* ``OR REPLACE`` and ``IF NOT EXISTS`` are mutually exclusive. +* **There is no ``REFRESH MATERIALIZED VIEW`` statement.** BigQuery refreshes on + a schedule configured through ``OPTIONS(enable_refresh=…, + refresh_interval_minutes=…)``; use + :class:`BigQueryAlterMaterializedViewSetOptionsExpression` to change it. +* ``DROP`` has no ``CASCADE``. +""" +from typing import Any, Dict, List, Optional, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.statements.ddl_view import ( + CreateMaterializedViewExpression, + DropMaterializedViewExpression, +) + +from ..materialized_view_options import validate_materialized_view_options + +if TYPE_CHECKING: # pragma: no cover + from ..dialect import BigQueryDialect + + +__all__ = [ + "BigQueryAlterMaterializedViewSetOptionsExpression", + "BigQueryCreateMaterializedViewExpression", + "BigQueryCreateMaterializedViewReplicaExpression", + "BigQueryDropMaterializedViewExpression", +] + + +def _validate_name(value: Optional[str], field_name: str) -> None: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field_name} must be a non-empty string") + + +class BigQueryCreateMaterializedViewExpression(CreateMaterializedViewExpression): + """``CREATE [OR REPLACE] MATERIALIZED VIEW [IF NOT EXISTS] ... AS query``. + + Args: + dialect: the BigQuery dialect instance. + view_name: materialized view name; may be dataset-qualified + (``project.dataset.mv``). + query: defining query expression. + or_replace: emit ``OR REPLACE`` (mutually exclusive with ``if_not_exists``). + if_not_exists: emit ``IF NOT EXISTS``. + partition_by: partition expression, rendered verbatim. + cluster_by: clustering column list. + options: ``OPTIONS(...)`` entries, validated against + :class:`~..materialized_view_options.BigQueryMaterializedViewOption`. + + Raises: + ValueError: on a bad name, mutually exclusive flags, or an undocumented + option name. + """ + + def __init__( + self, + dialect: "BigQueryDialect", + view_name: str, + query: Any, + or_replace: bool = False, + if_not_exists: bool = False, + partition_by: Optional[str] = None, + cluster_by: Optional[List[str]] = None, + options: Optional[Dict[Any, Any]] = None, + ): + _validate_name(view_name, "view_name") + if or_replace and if_not_exists: + raise ValueError( + "BigQuery rejects OR REPLACE together with IF NOT EXISTS" + ) + if partition_by is not None and not str(partition_by).strip(): + raise ValueError("partition_by must be a non-empty expression when provided") + if cluster_by is not None: + if isinstance(cluster_by, str) or not cluster_by: + raise ValueError("cluster_by must be a non-empty sequence of columns") + if options: + validate_materialized_view_options(options) + + super().__init__( + dialect, + view_name=view_name, + query=query, + # BigQuery has no clause for these; the formatter rejects them, so the + # values are never silently rendered. + column_aliases=None, + tablespace=None, + with_data=True, + storage_options=None, + ) + self.or_replace = or_replace + self.if_not_exists = if_not_exists + self.partition_by = partition_by + self.cluster_by = list(cluster_by) if cluster_by else None + self.options = dict(options) if options else {} + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_create_materialized_view_statement" + + +class BigQueryDropMaterializedViewExpression(DropMaterializedViewExpression): + """``DROP MATERIALIZED VIEW [IF EXISTS] mv_name``. + + BigQuery has no ``CASCADE`` for materialized views. + """ + + def __init__( + self, + dialect: "BigQueryDialect", + view_name: str, + if_exists: bool = False, + cascade: bool = False, + ): + _validate_name(view_name, "view_name") + if cascade: + raise ValueError("BigQuery DROP MATERIALIZED VIEW has no CASCADE clause") + super().__init__(dialect, view_name=view_name, if_exists=if_exists, cascade=False) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_drop_materialized_view_statement" + + +class BigQueryAlterMaterializedViewSetOptionsExpression(BaseExpression): + """``ALTER MATERIALIZED VIEW [IF EXISTS] mv_name SET OPTIONS(...)``. + + This is BigQuery's only ``ALTER MATERIALIZED VIEW`` form — there is no way to + change the defining query in place, so the view must be recreated. + """ + + def __init__( + self, + dialect: "BigQueryDialect", + view_name: str, + options: Dict[Any, Any], + if_exists: bool = False, + ): + super().__init__(dialect) + _validate_name(view_name, "view_name") + if not isinstance(options, dict) or not options: + raise ValueError("options must be a non-empty dict") + validate_materialized_view_options(options) + self.view_name = view_name + self.options = dict(options) + self.if_exists = if_exists + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_alter_materialized_view_set_options_statement" + + +class BigQueryCreateMaterializedViewReplicaExpression(BaseExpression): + """``CREATE MATERIALIZED VIEW replica_name [OPTIONS(...)] AS REPLICA OF src``.""" + + #: Documented bounds for ``replication_interval_seconds``. + MIN_REPLICATION_INTERVAL_SECONDS = 60 + MAX_REPLICATION_INTERVAL_SECONDS = 3600 + DEFAULT_REPLICATION_INTERVAL_SECONDS = 300 + + def __init__( + self, + dialect: "BigQueryDialect", + replica_name: str, + source_view_name: str, + replication_interval_seconds: Optional[int] = None, + ): + super().__init__(dialect) + _validate_name(replica_name, "replica_name") + _validate_name(source_view_name, "source_view_name") + if replication_interval_seconds is not None and not ( + self.MIN_REPLICATION_INTERVAL_SECONDS + <= replication_interval_seconds + <= self.MAX_REPLICATION_INTERVAL_SECONDS + ): + raise ValueError( + "replication_interval_seconds must be between " + f"{self.MIN_REPLICATION_INTERVAL_SECONDS} and " + f"{self.MAX_REPLICATION_INTERVAL_SECONDS} inclusive" + ) + self.replica_name = replica_name + self.source_view_name = source_view_name + self.replication_interval_seconds = replication_interval_seconds + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_create_materialized_view_replica_statement" diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/materialized_view_options.py b/src/rhosocial/activerecord/backend/impl/bigquery/materialized_view_options.py new file mode 100644 index 0000000..6cb145c --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/bigquery/materialized_view_options.py @@ -0,0 +1,151 @@ +# src/rhosocial/activerecord/backend/impl/bigquery/materialized_view_options.py +"""BigQuery materialized view ``OPTIONS(...)`` catalogue. + +BigQuery configures a materialized view through a fixed, documented option set +(``materialized_view_option_list`` in the GoogleSQL DDL reference). The names +are enumerated here so callers validate against the catalogue instead of +emitting arbitrary option names. + +Reference: +- CREATE MATERIALIZED VIEW: https://cloud.google.com/bigquery/docs/reference/standard-sql/data-definition-language#create_materialized_view_statement +""" +from enum import Enum +from typing import Any, Dict, Optional, Tuple + + +__all__ = [ + "BigQueryMaterializedViewOption", + "BigQueryMaterializedViewOptionValueType", + "resolve_materialized_view_option", + "validate_materialized_view_options", +] + + +class BigQueryMaterializedViewOptionValueType(Enum): + """Value type of a materialized view option.""" + + BOOLEAN = "boolean" + FLOAT64 = "float64" + INT64 = "int64" + INTERVAL = "interval" + TIMESTAMP = "timestamp" + STRING = "string" + LABEL_ARRAY = "label_array" + + +class BigQueryMaterializedViewOption(str, Enum): + """Options accepted by ``CREATE`` / ``ALTER MATERIALIZED VIEW``. + + The string value is the exact BigQuery option name. + + Example: + >>> from rhosocial.activerecord.backend.impl.bigquery import ( + ... BigQueryMaterializedViewOption as O, + ... ) + >>> O.ENABLE_REFRESH.value + 'enable_refresh' + >>> O("refresh_interval_minutes") is O.REFRESH_INTERVAL_MINUTES + True + """ + + ENABLE_REFRESH = "enable_refresh" + REFRESH_INTERVAL_MINUTES = "refresh_interval_minutes" + EXPIRATION_TIMESTAMP = "expiration_timestamp" + MAX_STALENESS = "max_staleness" + ALLOW_NON_INCREMENTAL_DEFINITION = "allow_non_incremental_definition" + KMS_KEY_NAME = "kms_key_name" + FRIENDLY_NAME = "friendly_name" + DESCRIPTION = "description" + LABELS = "labels" + TAGS = "tags" + + @property + def value_type(self) -> BigQueryMaterializedViewOptionValueType: + """The value type BigQuery expects for this option.""" + return _VALUE_TYPES[self] + + @property + def default(self) -> Optional[str]: + """Documented default, as text; ``None`` when there is no default.""" + return _DEFAULTS.get(self) + + +_VALUE_TYPES: Dict[BigQueryMaterializedViewOption, BigQueryMaterializedViewOptionValueType] = { + BigQueryMaterializedViewOption.ENABLE_REFRESH: + BigQueryMaterializedViewOptionValueType.BOOLEAN, + BigQueryMaterializedViewOption.REFRESH_INTERVAL_MINUTES: + BigQueryMaterializedViewOptionValueType.FLOAT64, + BigQueryMaterializedViewOption.EXPIRATION_TIMESTAMP: + BigQueryMaterializedViewOptionValueType.TIMESTAMP, + BigQueryMaterializedViewOption.MAX_STALENESS: + BigQueryMaterializedViewOptionValueType.INTERVAL, + BigQueryMaterializedViewOption.ALLOW_NON_INCREMENTAL_DEFINITION: + BigQueryMaterializedViewOptionValueType.BOOLEAN, + BigQueryMaterializedViewOption.KMS_KEY_NAME: + BigQueryMaterializedViewOptionValueType.STRING, + BigQueryMaterializedViewOption.FRIENDLY_NAME: + BigQueryMaterializedViewOptionValueType.STRING, + BigQueryMaterializedViewOption.DESCRIPTION: + BigQueryMaterializedViewOptionValueType.STRING, + BigQueryMaterializedViewOption.LABELS: + BigQueryMaterializedViewOptionValueType.LABEL_ARRAY, + BigQueryMaterializedViewOption.TAGS: + BigQueryMaterializedViewOptionValueType.LABEL_ARRAY, +} + +# Defaults documented by Google. Only defaults that pin behaviour are recorded; +# options that are simply "not set" are left out. +_DEFAULTS: Dict[BigQueryMaterializedViewOption, str] = { + BigQueryMaterializedViewOption.ENABLE_REFRESH: "true", + BigQueryMaterializedViewOption.REFRESH_INTERVAL_MINUTES: "30", +} + + +def resolve_materialized_view_option(name: Any) -> Optional[BigQueryMaterializedViewOption]: + """Resolve an option from an enum member or its BigQuery name. + + Args: + name: A :class:`BigQueryMaterializedViewOption` member or its string value. + + Returns: + The matching member, or ``None`` when the name is not a documented option. + """ + if isinstance(name, BigQueryMaterializedViewOption): + return name + if isinstance(name, str): + try: + return BigQueryMaterializedViewOption(name) + except ValueError: + return None + return None + + +def validate_materialized_view_options( + options: Any, + field_name: str = "options", +) -> Tuple[BigQueryMaterializedViewOption, ...]: + """Validate option names against the documented catalogue. + + Args: + options: Mapping of option name (or enum member) to value. + field_name: Field name used in error messages. + + Returns: + The resolved members, in input order. + + Raises: + ValueError: If a name is not a documented materialized view option. + """ + if not isinstance(options, dict): + raise TypeError(f"{field_name} must be a dict of option name to value") + resolved = [] + for name in options: + option = resolve_materialized_view_option(name) + if option is None: + raise ValueError( + f"{field_name} contains an option BigQuery does not document for " + f"materialized views: {name!r}. Valid options: " + f"{', '.join(o.value for o in BigQueryMaterializedViewOption)}" + ) + resolved.append(option) + return tuple(resolved) diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/__init__.py index d93c062..c56e7bf 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/__init__.py @@ -7,6 +7,7 @@ from .ddl import BigQueryDDLColumnMixin from .capabilities import BigQueryCapabilityMixin from .identifier import BigQueryIdentifierMixin +from .materialized_view import BigQueryMaterializedViewMixin class BigQueryStructMixin: @@ -36,6 +37,7 @@ def supports_geography(self) -> bool: 'BigQueryDDLColumnMixin', 'BigQueryCapabilityMixin', 'BigQueryIdentifierMixin', + 'BigQueryMaterializedViewMixin', 'BigQueryStructMixin', 'BigQueryArrayMixin', 'BigQueryJSONMixin', diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/ddl.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/ddl.py index caa8992..c05a905 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/ddl.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/ddl.py @@ -40,7 +40,7 @@ def format_column_attribute(self, attr: Any) -> Tuple[str, tuple]: BigQuery requires the collation specification to be a quoted STRING literal (``STRING COLLATE 'und:ci'``), not a bare identifier. """ - from rhosocial.activerecord.base.ddl.attributes import CollationAttribute + from rhosocial.activerecord.base import CollationAttribute if isinstance(attr, CollationAttribute): escaped = attr.name.replace("'", "''") diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/materialized_view.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/materialized_view.py new file mode 100644 index 0000000..65e78e7 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/materialized_view.py @@ -0,0 +1,186 @@ +# src/rhosocial/activerecord/backend/impl/bigquery/mixins/materialized_view.py +"""BigQuery materialized view formatters. + +BigQuery's materialized view DDL diverges from the SQL-standard statement in +ways that must not be papered over — see +:mod:`..expression.materialized_view` for the grammar. Anything the generic +expressions carry that BigQuery cannot express is rejected with +``UnsupportedFeatureError`` instead of being silently dropped. +""" +from typing import Any, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +from ..materialized_view_options import resolve_materialized_view_option + +if TYPE_CHECKING: # pragma: no cover + from ..expression.materialized_view import ( + BigQueryAlterMaterializedViewSetOptionsExpression, + BigQueryCreateMaterializedViewExpression, + BigQueryCreateMaterializedViewReplicaExpression, + ) + + +class BigQueryMaterializedViewMixin: + """BigQuery MATERIALIZED VIEW support. + + Must be listed before the core ``ViewMixin`` in ``BigQueryDialect`` so these + formatters take precedence. + """ + + def supports_materialized_view(self) -> bool: + """BigQuery has native materialized views.""" + return True + + def supports_refresh_materialized_view(self) -> bool: + """BigQuery has no ``REFRESH MATERIALIZED VIEW`` statement. + + Refresh is scheduled through ``OPTIONS(enable_refresh=…, + refresh_interval_minutes=…)``; use + ``format_alter_materialized_view_set_options_statement`` to change it. + """ + return False + + def supports_materialized_view_options(self) -> bool: + """BigQuery configures materialized views through ``OPTIONS(...)``.""" + return True + + def supports_materialized_view_replica(self) -> bool: + """BigQuery can create a replica of a materialized view.""" + return True + + # ------------------------------------------------------------------ + # Formatters + # ------------------------------------------------------------------ + + def format_create_materialized_view_statement( + self, expr: "BigQueryCreateMaterializedViewExpression" + ) -> Tuple[str, tuple]: + """Format ``CREATE [OR REPLACE] MATERIALIZED VIEW [IF NOT EXISTS]``. + + Args: + expr: BigQuery (or generic) create expression. + + Returns: + Tuple of (SQL string, params tuple) from the defining query. + + Raises: + UnsupportedFeatureError: for clauses BigQuery does not have. + """ + self._reject_unsupported_clauses(expr, "CREATE MATERIALIZED VIEW") + + parts = ["CREATE"] + if getattr(expr, "or_replace", False): + parts.append("OR REPLACE") + parts.append("MATERIALIZED VIEW") + if getattr(expr, "if_not_exists", False): + parts.append("IF NOT EXISTS") + parts.append(self.format_identifier(expr.view_name)) + + partition_by = getattr(expr, "partition_by", None) + if partition_by: + parts.append(f"PARTITION BY {partition_by}") + + cluster_by = getattr(expr, "cluster_by", None) + if cluster_by: + cols = ", ".join(str(column) for column in cluster_by) + parts.append(f"CLUSTER BY {cols}") + + options = self._format_materialized_view_options(getattr(expr, "options", None)) + if options: + parts.append(f"OPTIONS({options})") + + query_sql, query_params = self._materialized_view_query_sql(expr) + parts.append(f"AS {query_sql}") + return " ".join(parts), query_params + + def format_drop_materialized_view_statement(self, expr: Any) -> Tuple[str, tuple]: + """Format ``DROP MATERIALIZED VIEW [IF EXISTS]``.""" + if getattr(expr, "cascade", False): + raise UnsupportedFeatureError( + self.name, "DROP MATERIALIZED VIEW CASCADE" + ) + parts = ["DROP MATERIALIZED VIEW"] + if getattr(expr, "if_exists", False): + parts.append("IF EXISTS") + parts.append(self.format_identifier(expr.view_name)) + return " ".join(parts), () + + def format_alter_materialized_view_set_options_statement( + self, expr: "BigQueryAlterMaterializedViewSetOptionsExpression" + ) -> Tuple[str, tuple]: + """Format ``ALTER MATERIALIZED VIEW [IF EXISTS] ... SET OPTIONS(...)``.""" + parts = ["ALTER MATERIALIZED VIEW"] + if getattr(expr, "if_exists", False): + parts.append("IF EXISTS") + parts.append(self.format_identifier(expr.view_name)) + options = self._format_materialized_view_options(expr.options) + parts.append(f"SET OPTIONS({options})") + return " ".join(parts), () + + def format_create_materialized_view_replica_statement( + self, expr: "BigQueryCreateMaterializedViewReplicaExpression" + ) -> Tuple[str, tuple]: + """Format ``CREATE MATERIALIZED VIEW replica ... AS REPLICA OF source``.""" + parts = ["CREATE MATERIALIZED VIEW", self.format_identifier(expr.replica_name)] + interval = getattr(expr, "replication_interval_seconds", None) + if interval is not None: + parts.append( + "OPTIONS(replication_interval_seconds = " f"{int(interval)})" + ) + parts.append(f"AS REPLICA OF {self.format_identifier(expr.source_view_name)}") + return " ".join(parts), () + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _reject_unsupported_clauses(self, expr: Any, feature: str) -> None: + """Reject generic-expression clauses BigQuery cannot express.""" + if getattr(expr, "column_aliases", None): + raise UnsupportedFeatureError( + self.name, + f"{feature} COLUMN ALIASES", + "BigQuery materialized views take their column names from the query.", + ) + if getattr(expr, "tablespace", None): + raise UnsupportedFeatureError(self.name, f"{feature} TABLESPACE") + if getattr(expr, "storage_options", None): + raise UnsupportedFeatureError( + self.name, + f"{feature} STORAGE PARAMETERS", + "BigQuery configures materialized views through OPTIONS(...).", + ) + if getattr(expr, "with_data", True) is False: + raise UnsupportedFeatureError( + self.name, + f"{feature} WITH NO DATA", + "BigQuery populates the view at creation time.", + ) + + def _materialized_view_query_sql(self, expr: Any) -> Tuple[str, tuple]: + """Return the defining query SQL, accepting an expression or raw SQL.""" + query = getattr(expr, "query", None) + if query is None: + raise ValueError("CREATE MATERIALIZED VIEW requires a defining query") + if isinstance(query, str): + return query, () + return query.to_sql() + + def _format_materialized_view_options(self, options: Any) -> str: + """Render ``OPTIONS(...)`` entries using documented option names.""" + if not options: + return "" + rendered = [] + for name, value in options.items(): + option = resolve_materialized_view_option(name) + key = option.value if option is not None else str(name) + if isinstance(value, bool): + literal = "true" if value else "false" + elif isinstance(value, str): + # _escape_sql_string escapes the body; the quotes are ours. + literal = f"'{self._escape_sql_string(value)}'" + else: + literal = str(value) + rendered.append(f"{key} = {literal}") + return ", ".join(rendered) diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/protocols.py b/src/rhosocial/activerecord/backend/impl/bigquery/protocols.py index 38e20a3..22b84d9 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/protocols.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/protocols.py @@ -1,5 +1,5 @@ """BigQuery-specific protocol definitions.""" -from typing import Protocol, runtime_checkable +from typing import Any, Protocol, runtime_checkable, Tuple @runtime_checkable @@ -20,3 +20,56 @@ def supports_json(self) -> bool: ... @runtime_checkable class BigQueryGeographySupport(Protocol): def supports_geography(self) -> bool: ... + + +@runtime_checkable +class BigQueryMaterializedViewSupport(Protocol): + """BigQuery MATERIALIZED VIEW support. + + GoogleSQL syntax:: + + CREATE [ OR REPLACE ] MATERIALIZED VIEW [ IF NOT EXISTS ] mv_name + [PARTITION BY partition_expression] + [CLUSTER BY clustering_column_list] + [OPTIONS(materialized_view_option_list)] + AS query_expression + + ALTER MATERIALIZED VIEW [IF EXISTS] mv_name SET OPTIONS(...) + DROP MATERIALIZED VIEW [IF EXISTS] mv_name + CREATE MATERIALIZED VIEW replica [OPTIONS(...)] AS REPLICA OF source + + There is no ``REFRESH MATERIALIZED VIEW`` statement: refresh is scheduled + through ``OPTIONS(enable_refresh=…, refresh_interval_minutes=…)``. + """ + + def supports_materialized_view(self) -> bool: + """BigQuery has native materialized views.""" + ... + + def supports_refresh_materialized_view(self) -> bool: + """Always ``False``: refresh is option-driven, not a statement.""" + ... + + def supports_materialized_view_options(self) -> bool: + """Whether ``OPTIONS(...)`` configuration is supported.""" + ... + + def supports_materialized_view_replica(self) -> bool: + """Whether ``CREATE MATERIALIZED VIEW ... AS REPLICA OF`` is supported.""" + ... + + def format_create_materialized_view_statement(self, expr: Any) -> Tuple[str, tuple]: + """Format ``CREATE [OR REPLACE] MATERIALIZED VIEW [IF NOT EXISTS]``.""" + ... + + def format_drop_materialized_view_statement(self, expr: Any) -> Tuple[str, tuple]: + """Format ``DROP MATERIALIZED VIEW [IF EXISTS]``.""" + ... + + def format_alter_materialized_view_set_options_statement(self, expr: Any) -> Tuple[str, tuple]: + """Format ``ALTER MATERIALIZED VIEW [IF EXISTS] ... SET OPTIONS(...)``.""" + ... + + def format_create_materialized_view_replica_statement(self, expr: Any) -> Tuple[str, tuple]: + """Format ``CREATE MATERIALIZED VIEW replica ... AS REPLICA OF source``.""" + ... diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/ddl/test_column_attributes.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/ddl/test_column_attributes.py index f79ca9a..5248326 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/ddl/test_column_attributes.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/ddl/test_column_attributes.py @@ -3,13 +3,13 @@ from rhosocial.activerecord.backend.expression.statements import ColumnDefinition from rhosocial.activerecord.backend.expression.types import IntegerType -from rhosocial.activerecord.base.ddl.attributes import CollationAttribute, IdentityAttribute +from rhosocial.activerecord.base import CollationAttribute, IdentityAttribute from rhosocial.activerecord.backend.impl.bigquery.dialect import BigQueryDialect def _column(dialect, attrs): col = ColumnDefinition(dialect, "id", IntegerType(dialect)) - col.attributes = dialect.select_column_attributes(attrs) + col.attributes = attrs return col diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/ddl/test_ddl_source_mixin.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/ddl/test_ddl_source_mixin.py new file mode 100644 index 0000000..031fe80 --- /dev/null +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/ddl/test_ddl_source_mixin.py @@ -0,0 +1,506 @@ +# tests/rhosocial/activerecord_bigquery_test/feature/backend/ddl/test_ddl_source_mixin.py + +from typing import Optional + +try: + from typing import Annotated +except ImportError: + from typing_extensions import Annotated + +import pytest + +from rhosocial.activerecord.backend.expression.core import Column +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraintType, + ColumnDefinition, + CreateTableExpression, + CreateTableOptions, + IndexDefinition, + StorageOptionsExpression, + TableConstraint, + TableConstraintType, +) +from rhosocial.activerecord.backend.expression.statements.ddl_partition import ( + PartitionClause, + PartitionStrategy, +) +from rhosocial.activerecord.backend.impl.bigquery.dialect import BigQueryDialect +from rhosocial.activerecord.backend.expression.types import JsonType, VarCharType +from rhosocial.activerecord.base import ( + CollationAttribute, + DDLSource, + IdentityAttribute, + UseColumn, + UseColumnAttributes, + UseComment, + UseConstraint, + UseGeneratedColumn, + UseIndex, + UseSqlType, +) +from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord + +NATIVE_TYPES = (VarCharType(length=32), JsonType()) +SQL_TYPE = UseSqlType(*NATIVE_TYPES) +VALUE_COLUMN = UseColumn("physical_value") +DEFAULT_CONSTRAINT = UseConstraint( + ColumnConstraintType.DEFAULT, + name="dflt_value", + default_value=17, +) +UNIQUE_CONSTRAINT = UseConstraint( + ColumnConstraintType.UNIQUE, + name="uq_value", +) +CHECK_CONDITION = Column(None, "physical_value") == "active" +CHECK_CONSTRAINT = UseConstraint( + ColumnConstraintType.CHECK, + name="ck_value", + check_condition=CHECK_CONDITION, +) +FIRST_INDEX_CONDITION = object() +FIRST_INCLUDE_COLUMNS = ["id"] +FIRST_INDEX = UseIndex( + "idx_value_first", + unique=True, + type="BTREE", + partial_condition=FIRST_INDEX_CONDITION, + include_columns=FIRST_INCLUDE_COLUMNS, + if_not_exists=True, + tablespace="ts_index", + concurrent=True, +) +SECOND_INDEX = UseIndex("idx_value_second", type="BTREE") +VALUE_ATTRIBUTES = ( + CollationAttribute(name="und:ci"), + IdentityAttribute(generation="ALWAYS", start=10, increment=2), +) +VALUE_ATTRIBUTES_MARKER = UseColumnAttributes(*VALUE_ATTRIBUTES) +VALUE_COMMENT = UseComment("physical value") + + +def generated_expression(dialect): + return object() + + +GENERATED_EXPRESSION = generated_expression +VALUE_GENERATED = UseGeneratedColumn(GENERATED_EXPRESSION) + +TABLE_CONSTRAINT_ONE = TableConstraint( + None, + TableConstraintType.UNIQUE, + name="uq_physical_quantity", + columns=["physical_value", "quantity"], +) +TABLE_CONSTRAINT_TWO = TableConstraint( + None, + TableConstraintType.CHECK, + name="ck_physical_quantity", + check_condition=Column(None, "physical_value") == "active", +) +TABLE_CONSTRAINTS = [TABLE_CONSTRAINT_ONE, TABLE_CONSTRAINT_TWO] +TABLE_INDEX_ONE = IndexDefinition( + None, + name="idx_table_physical", + columns=["physical_value"], +) +TABLE_INDEX_TWO = IndexDefinition( + None, + name="idx_table_quantity", + columns=["quantity"], +) +TABLE_INDEXES = [TABLE_INDEX_ONE, TABLE_INDEX_TWO] +TABLE_OPTIONS = ( + CreateTableOptions(None, or_replace=True), + CreateTableOptions(None), +) +STORAGE_OPTIONS = StorageOptionsExpression(None, {"engine": "MergeTree"}) +TABLE_PARTITION = PartitionClause( + None, + PartitionStrategy.RANGE, + [Column(None, "physical_value")], +) +TABLE_INHERITS = ["parent_a", "parent_b"] +TABLE_TABLESPACE = "ts_data" +COMPOSITE_CONSTRAINT = TableConstraint( + None, + TableConstraintType.CHECK, + name="ck_composite", + columns=["tenant_id", "order_id"], +) + + +class TableDeclarationOverrides: + @classmethod + def table_options(cls): + return TABLE_OPTIONS + + @classmethod + def table_storage_options(cls): + return STORAGE_OPTIONS + + @classmethod + def table_partition(cls): + return TABLE_PARTITION + + @classmethod + def table_inherits(cls): + return TABLE_INHERITS + + @classmethod + def table_tablespace(cls): + return TABLE_TABLESPACE + + @classmethod + def create_table_statement_classes(cls): + return CreateTableExpression + + +class DeclarationModel(TableDeclarationOverrides, ActiveRecord): + __table_name__ = "ddl_source_declarations" + __schema_name__ = "analytics" + __primary_key__ = "record_id" + + id: Annotated[int, UseColumn("record_id")] + value: Annotated[ + Optional[str], + VALUE_COLUMN, + SQL_TYPE, + DEFAULT_CONSTRAINT, + UNIQUE_CONSTRAINT, + CHECK_CONSTRAINT, + FIRST_INDEX, + SECOND_INDEX, + VALUE_ATTRIBUTES_MARKER, + VALUE_COMMENT, + VALUE_GENERATED, + ] = None + quantity: Annotated[int, UseConstraint(ColumnConstraintType.DEFAULT, default_value=7)] = 1 + + __table_constraints__ = TABLE_CONSTRAINTS + __table_indexes__ = TABLE_INDEXES + + +class AsyncDeclarationModel(TableDeclarationOverrides, AsyncActiveRecord): + __table_name__ = "ddl_source_declarations_async" + __schema_name__ = "analytics" + __primary_key__ = "record_id" + + id: Annotated[int, UseColumn("record_id")] + value: Annotated[ + Optional[str], + VALUE_COLUMN, + SQL_TYPE, + DEFAULT_CONSTRAINT, + UNIQUE_CONSTRAINT, + CHECK_CONSTRAINT, + FIRST_INDEX, + SECOND_INDEX, + VALUE_ATTRIBUTES_MARKER, + VALUE_COMMENT, + VALUE_GENERATED, + ] = None + quantity: Annotated[int, UseConstraint(ColumnConstraintType.DEFAULT, default_value=7)] = 1 + + __table_constraints__ = TABLE_CONSTRAINTS + __table_indexes__ = TABLE_INDEXES + + +class PlainModel(ActiveRecord): + __table_name__ = "ddl_source_plain" + + id: int + value: Optional[str] = None + + +class AsyncPlainModel(AsyncActiveRecord): + __table_name__ = "ddl_source_plain_async" + + id: int + value: Optional[str] = None + + +class CompositeModel(TableDeclarationOverrides, ActiveRecord): + __table_name__ = "ddl_source_composite" + __primary_key__ = ("tenant_id", "order_id") + + tenant_ref: Annotated[int, UseColumn("tenant_id")] + order_ref: Annotated[int, UseColumn("order_id")] + + __table_constraints__ = [COMPOSITE_CONSTRAINT] + __table_indexes__ = TABLE_INDEXES + + +class AsyncCompositeModel(TableDeclarationOverrides, AsyncActiveRecord): + __table_name__ = "ddl_source_composite_async" + __primary_key__ = ("tenant_id", "order_id") + + tenant_ref: Annotated[int, UseColumn("tenant_id")] + order_ref: Annotated[int, UseColumn("order_id")] + + __table_constraints__ = [COMPOSITE_CONSTRAINT] + __table_indexes__ = TABLE_INDEXES + + +MODEL_CLASSES = (DeclarationModel, AsyncDeclarationModel) +COMPOSITE_MODEL_CLASSES = (CompositeModel, AsyncCompositeModel) +PLAIN_MODEL_CLASSES = (PlainModel, AsyncPlainModel) + + +def _assert_declaration_model(model): + assert isinstance(model, DDLSource) + assert model.table_name().startswith("ddl_source_declarations") + assert model.schema_name() == "analytics" + assert model.primary_key_columns() == ("record_id",) + assert model.primary_key_field() == "id" + assert model.is_composite_pk() is False + assert model.ddl_field_names() == ("id", "value", "quantity") + assert model.is_derived_field("value") is False + assert model.field_python_type("value") is str + assert model.field_is_optional("value") is True + assert model.field_python_type("quantity") is int + assert model.field_is_optional("quantity") is False + assert model.column_name("id") == "record_id" + assert model.columns_name() == { + "id": "record_id", + "value": "physical_value", + "quantity": "quantity", + } + + metadata = model.ddl_field_metadata("value") + assert metadata is model.__table_ddl_fields__["value"] + expected_annotations = ( + VALUE_COLUMN, + SQL_TYPE, + DEFAULT_CONSTRAINT, + UNIQUE_CONSTRAINT, + CHECK_CONSTRAINT, + FIRST_INDEX, + SECOND_INDEX, + VALUE_ATTRIBUTES_MARKER, + VALUE_COMMENT, + VALUE_GENERATED, + ) + assert metadata.annotations == expected_annotations + assert model.column_type("value") is SQL_TYPE + assert model.column_type("value").data_types == NATIVE_TYPES + assert all( + actual is expected + for actual, expected in zip( + model.column_type("value").data_types, + NATIVE_TYPES, + strict=True, + ) + ) + + value_constraints = model.column_constraints("value") + assert [item.constraint_type for item in value_constraints] == [ + ColumnConstraintType.DEFAULT, + ColumnConstraintType.UNIQUE, + ColumnConstraintType.CHECK, + ] + 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 + + value_indexes = model.column_indexes("value") + assert [item.name for item in value_indexes] == [ + "idx_value_first", + "idx_value_second", + ] + assert value_indexes[0].columns == ["physical_value"] + assert value_indexes[0].unique is True + assert value_indexes[0].type == "BTREE" + assert value_indexes[0].partial_condition is FIRST_INDEX_CONDITION + assert value_indexes[0].include_columns is FIRST_INCLUDE_COLUMNS + assert value_indexes[0].if_not_exists is True + assert value_indexes[0].tablespace == "ts_index" + assert value_indexes[0].concurrent is True + assert value_indexes[1].columns == ["physical_value"] + assert value_indexes[1].unique is False + + assert model.column_attributes("value") == list(VALUE_ATTRIBUTES) + assert all( + actual is expected + for actual, expected in zip( + model.column_attributes("value"), + VALUE_ATTRIBUTES, + strict=True, + ) + ) + assert model.column_comment("value") == "physical value" + assert model.generated_column("value") is GENERATED_EXPRESSION + 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, + ] + 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 model.table_options() is TABLE_OPTIONS + assert [item.or_replace for item in model.table_options()] == [True, False] + assert model.table_storage_options() is STORAGE_OPTIONS + assert model.table_partition() is TABLE_PARTITION + 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.table_constraints() == TABLE_CONSTRAINTS + assert all( + actual is expected + for actual, expected in zip( + model.table_constraints(), + TABLE_CONSTRAINTS, + strict=True, + ) + ) + assert model.table_indexes() == TABLE_INDEXES + assert all( + actual is expected + for actual, expected in zip(model.table_indexes(), TABLE_INDEXES, strict=True) + ) + + +def _assert_batch_interfaces(model): + fields = ["value", "id", "quantity"] + assert list(model.columns_name()) == ["id", "value", "quantity"] + assert list(model.columns_name(fields)) == fields + assert model.columns_name(fields) == { + "value": "physical_value", + "id": "record_id", + "quantity": "quantity", + } + assert list(model.columns_type(fields)) == fields + assert model.columns_type(fields)["value"] is SQL_TYPE + assert model.columns_type(fields)["quantity"] is None + assert list(model.columns_constraints(fields)) == fields + assert model.columns_constraints(fields)["value"][0] is DEFAULT_CONSTRAINT.constraint + assert list(model.columns_attributes(fields)) == fields + assert model.columns_attributes(fields)["value"][0] is VALUE_ATTRIBUTES[0] + assert list(model.columns_indexes(fields)) == fields + assert model.columns_indexes(fields)["value"][0].name == "idx_value_first" + assert list(model.columns_comment(fields)) == fields + assert model.columns_comment(fields)["value"] == "physical value" + assert list(model.columns_generated(fields)) == fields + assert model.columns_generated(fields)["value"] is GENERATED_EXPRESSION + assert list(model.columns_options(fields)) == fields + assert model.columns_options(fields)["value"] is None + + +def _assert_plain_defaults(model): + assert isinstance(model, DDLSource) + assert model.ddl_field_names() == ("id", "value") + assert model.column_type("value") is None + assert model.column_constraints("value") == [] + assert model.column_attributes("value") == [] + assert model.column_indexes("value") == [] + assert model.column_comment("value") is None + assert model.generated_column("value") is None + assert model.column_options("value") is None + assert model.table_options() is None + assert model.table_storage_options() is None + assert model.table_partition() is None + assert model.table_inherits() is None + assert model.table_tablespace() is None + assert model.create_table_statement_classes() is None + assert model.table_indexes() == [] + assert model.table_constraints() == [] + + +def _assert_composite_model(model): + assert model.primary_key_columns() == ("tenant_id", "order_id") + assert model.primary_key_field() == ("tenant_ref", "order_ref") + assert model.is_composite_pk() is True + assert model.column_name("tenant_ref") == "tenant_id" + assert model.column_name("order_ref") == "order_id" + assert model.columns_name() == { + "tenant_ref": "tenant_id", + "order_ref": "order_id", + } + for field in ("tenant_ref", "order_ref"): + constraints = model.column_constraints(field) + assert [item.constraint_type for item in constraints] == [ + ColumnConstraintType.NOT_NULL, + ] + + 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 all( + item.constraint_type != TableConstraintType.PRIMARY_KEY + for item in constraints[:-1] + ) + + +@pytest.mark.parametrize("model", MODEL_CLASSES, ids=("sync", "async")) +def test_ddl_source_collects_field_and_table_declarations(model): + _assert_declaration_model(model) + _assert_batch_interfaces(model) + + +@pytest.mark.parametrize("model", COMPOSITE_MODEL_CLASSES, ids=("sync", "async")) +def test_composite_primary_key_is_collected_at_table_level(model): + _assert_composite_model(model) + + +@pytest.mark.parametrize("model", PLAIN_MODEL_CLASSES, ids=("sync", "async")) +def test_ddl_source_defaults_are_empty_or_none(model): + _assert_plain_defaults(model) + + +async def test_sync_and_async_declarations_are_identical(): + sync_model, async_model = MODEL_CLASSES + assert sync_model.ddl_field_names() == async_model.ddl_field_names() + assert sync_model.columns_name() == async_model.columns_name() + assert sync_model.column_type("value") is async_model.column_type("value") + assert sync_model.column_constraints("value") == async_model.column_constraints("value") + assert sync_model.column_attributes("value") == async_model.column_attributes("value") + sync_indexes = sync_model.column_indexes("value") + async_indexes = async_model.column_indexes("value") + assert [ + (item.name, item.columns, item.unique, item.type, item.partial_condition) + for item in sync_indexes + ] == [ + (item.name, item.columns, item.unique, item.type, item.partial_condition) + for item in async_indexes + ] + assert sync_model.column_comment("value") == async_model.column_comment("value") + assert sync_model.generated_column("value") is async_model.generated_column("value") + assert sync_model.table_options() is async_model.table_options() + assert sync_model.table_storage_options() is async_model.table_storage_options() + assert sync_model.table_partition() is async_model.table_partition() + assert sync_model.table_inherits() is async_model.table_inherits() + assert sync_model.table_tablespace() == async_model.table_tablespace() + assert sync_model.columns_options() == async_model.columns_options() + + +def test_collected_bigquery_type_and_option_render_minimal_table(): + dialect = BigQueryDialect() + data_type = DeclarationModel.column_type("value").data_types[0] + option = DeclarationModel.table_options()[0] + data_type.dialect = dialect + option.dialect = dialect + try: + column = ColumnDefinition(dialect, "physical_value", data_type) + expression = CreateTableExpression( + dialect, + "rendered", + [column], + table_options=option, + ) + rendered = expression.to_sql() + finally: + data_type.dialect = None + option.dialect = None + assert rendered == ( + "CREATE OR REPLACE TABLE `rendered` (`physical_value` STRING(32))", + (), + ) diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/ddl/test_type_domain_negative.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/ddl/test_type_domain_negative.py new file mode 100644 index 0000000..2173d17 --- /dev/null +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/ddl/test_type_domain_negative.py @@ -0,0 +1,281 @@ +# tests/rhosocial/activerecord_bigquery_test/feature/backend/ddl/test_type_domain_negative.py +"""Negative TYPE and DOMAIN DDL contracts for BigQuery.""" + +from __future__ import annotations + +from typing import Tuple + +import pytest + +from rhosocial.activerecord.backend.dialect import ( + DomainMixin, + DomainSupport, + UserDefinedTypeMixin, + UserDefinedTypeSupport, +) +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression import BaseExpression, Literal +from rhosocial.activerecord.backend.expression.statements import ( + AlterDomainExpression, + AlterTypeExpression, + CreateDomainExpression, + CreateSchemaExpression, + CreateTypeExpression, + DomainCheckConstraint, + DomainNullability, + DomainValueExpression, + DropDomainDefaultAction, + DropDomainExpression, + DropTypeExpression, + TypeAlterAction, + TypeDefinition, +) +from rhosocial.activerecord.backend.expression.types import ( + ArrayType, + DataType, + IntegerType, + JsonType, +) +from rhosocial.activerecord.backend.impl.bigquery import ( + BigQueryArray, + BigQueryJSON, + BigQueryStruct, +) +from rhosocial.activerecord.backend.impl.bigquery.dialect import BigQueryDialect +from rhosocial.activerecord.backend.impl.bigquery.mixins import ( + BigQueryArrayMixin, + BigQueryJSONMixin, + BigQuerySchemaMixin, + BigQueryStructMixin, + BigQueryTypeSupportMixin, +) +from rhosocial.activerecord.backend.impl.bigquery.protocols import ( + BigQueryArraySupport, + BigQueryJSONSupport, + BigQueryStructSupport, +) + + +class _NegativeTypeDefinition(TypeDefinition): + @property + def definition_kind(self) -> str: + return "negative" + + +class _NegativeTypeAlterAction(TypeAlterAction): + @property + def action_kind(self) -> str: + return "negative" + + +TYPE_DOMAIN_SUPPORT_FLAGS = ( + "supports_type_objects", + "supports_create_type", + "supports_alter_type", + "supports_drop_type", + "supports_create_type_if_not_exists", + "supports_create_type_or_replace", + "supports_alter_type_if_exists", + "supports_drop_type_if_exists", + "supports_multiple_type_alter_actions", + "supports_domains", + "supports_create_domain", + "supports_alter_domain", + "supports_drop_domain", + "supports_domain_default", + "supports_domain_checks", + "supports_named_domain_checks", + "supports_multiple_domain_checks", + "supports_domain_collation", + "supports_multiple_domain_alter_actions", + "supports_drop_domain_if_exists", + "supports_drop_domain_cascade", + "supports_drop_domain_restrict", + "supports_unnamed_domain_check_drop", +) + +TYPE_FORMATTERS = ( + "format_create_type_statement", + "format_alter_type_statement", + "format_drop_type_statement", + "format_type_definition", + "format_type_alter_action", +) + +DOMAIN_FORMATTERS = ( + "format_create_domain_statement", + "format_alter_domain_statement", + "format_drop_domain_statement", + "format_domain_value_expression", + "format_domain_check_constraint", + "format_domain_alter_action", +) + + +@pytest.fixture +def dialect() -> BigQueryDialect: + return BigQueryDialect() + + +def _type_nodes(dialect: BigQueryDialect) -> Tuple[BaseExpression, ...]: + definition = _NegativeTypeDefinition(dialect) + action = _NegativeTypeAlterAction(dialect) + return ( + CreateTypeExpression( + dialect, + "status", + definition, + if_not_exists=True, + ), + AlterTypeExpression( + dialect, + "status", + [action], + if_exists=True, + ), + DropTypeExpression(dialect, "status", if_exists=True), + definition, + action, + ) + + +def _domain_nodes(dialect: BigQueryDialect) -> Tuple[BaseExpression, ...]: + condition = DomainValueExpression(dialect) > Literal( + dialect, + 0, + inline_literals=True, + ) + check = DomainCheckConstraint(dialect, condition, name="nonnegative") + action = DropDomainDefaultAction(dialect) + return ( + CreateDomainExpression( + dialect, + "nonnegative", + IntegerType(dialect), + checks=[check], + collation="und:ci", + ), + AlterDomainExpression(dialect, "nonnegative", [action]), + DropDomainExpression(dialect, "nonnegative"), + DomainValueExpression(dialect), + check, + action, + ) + + +def test_type_and_domain_protocols_and_mixins_are_composed( + dialect: BigQueryDialect, +) -> None: + assert isinstance(dialect, UserDefinedTypeSupport) + assert isinstance(dialect, DomainSupport) + assert isinstance(dialect, UserDefinedTypeMixin) + assert isinstance(dialect, DomainMixin) + + mro = BigQueryDialect.__mro__ + for mixin, protocol in ( + (UserDefinedTypeMixin, UserDefinedTypeSupport), + (DomainMixin, DomainSupport), + ): + assert mixin in mro + assert protocol in mro + assert mro.index(mixin) < mro.index(protocol) + + for protocol in ( + BigQueryStructSupport, + BigQueryArraySupport, + BigQueryJSONSupport, + ): + assert protocol in mro + assert not issubclass(protocol, UserDefinedTypeSupport) + assert not issubclass(protocol, DomainSupport) + + for mixin in ( + BigQueryTypeSupportMixin, + BigQueryStructMixin, + BigQueryArrayMixin, + BigQueryJSONMixin, + BigQuerySchemaMixin, + ): + assert not issubclass(mixin, UserDefinedTypeMixin) + assert not issubclass(mixin, DomainMixin) + + +def test_core_type_and_domain_formatters_own_the_mro() -> None: + for method_name in TYPE_FORMATTERS: + assert getattr(BigQueryDialect, method_name) is getattr( + UserDefinedTypeMixin, + method_name, + ) + for method_name in DOMAIN_FORMATTERS: + assert getattr(BigQueryDialect, method_name) is getattr( + DomainMixin, + method_name, + ) + + +def test_schema_level_type_and_domain_support_is_false( + dialect: BigQueryDialect, +) -> None: + for method_name in TYPE_DOMAIN_SUPPORT_FLAGS: + assert getattr(dialect, method_name)() is False, method_name + + assert dialect.supported_type_definitions() == () + assert dialect.supports_type_definition(_NegativeTypeDefinition) is False + assert dialect.supports_type_alter_action(_NegativeTypeAlterAction) is False + for nullability in DomainNullability: + assert dialect.supports_domain_nullability(nullability) is False + assert dialect.supports_alter_domain_action(DropDomainDefaultAction) is False + + +def test_type_formatters_and_expressions_fail_fast( + dialect: BigQueryDialect, +) -> None: + nodes = _type_nodes(dialect) + for formatter_name, node in zip(TYPE_FORMATTERS, nodes, strict=True): + with pytest.raises(UnsupportedFeatureError): + getattr(dialect, formatter_name)(node) + with pytest.raises(UnsupportedFeatureError): + node.to_sql() + + +def test_domain_formatters_and_expressions_fail_fast( + dialect: BigQueryDialect, +) -> None: + nodes = _domain_nodes(dialect) + for formatter_name, node in zip(DOMAIN_FORMATTERS, nodes, strict=True): + with pytest.raises(UnsupportedFeatureError): + getattr(dialect, formatter_name)(node) + with pytest.raises(UnsupportedFeatureError): + node.to_sql() + + +def test_column_types_and_dataset_schema_remain_separate( + dialect: BigQueryDialect, +) -> None: + data_types = ( + JsonType(dialect), + ArrayType(dialect, IntegerType(dialect)), + ) + for data_type in data_types: + assert isinstance(data_type, DataType) + assert not isinstance(data_type, TypeDefinition) + + value_objects = ( + BigQueryStruct({"id": 1}), + BigQueryArray([1]), + BigQueryJSON({"id": 1}), + ) + for value_object in value_objects: + assert not isinstance(value_object, DataType) + assert not isinstance(value_object, TypeDefinition) + + assert dialect.supports_data_type_json() is True + assert dialect.supports_struct() is True + assert dialect.supports_array() is True + assert dialect.supports_json() is True + assert JsonType(dialect).to_sql() == ("JSON", ()) + + schema = CreateSchemaExpression(dialect, "analytics") + assert dialect.supports_create_schema() is True + assert dialect.supports_domains() is False + assert schema.to_sql() == ("CREATE SCHEMA `analytics`", ()) diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_materialized_view.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_materialized_view.py new file mode 100644 index 0000000..c74806c --- /dev/null +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_materialized_view.py @@ -0,0 +1,346 @@ +# tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_materialized_view.py +"""BigQuery MATERIALIZED VIEW DDL tests (SQL generation only, no database). + +Covers ``CREATE [OR REPLACE] MATERIALIZED VIEW [IF NOT EXISTS]`` with +``PARTITION BY`` / ``CLUSTER BY`` / ``OPTIONS(...)``, ``ALTER MATERIALIZED VIEW +SET OPTIONS``, ``DROP MATERIALIZED VIEW [IF EXISTS]``, ``AS REPLICA OF``, and +the clauses BigQuery does *not* have (column aliases, TABLESPACE, storage +parameters, ``WITH [NO] DATA``, ``CASCADE``, ``REFRESH``). +""" + +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression import Column, QueryExpression, TableExpression +from rhosocial.activerecord.backend.expression.statements.ddl_view import ( + CreateMaterializedViewExpression, + DropMaterializedViewExpression, + RefreshMaterializedViewExpression, +) +from rhosocial.activerecord.backend.impl.bigquery import ( + BigQueryAlterMaterializedViewSetOptionsExpression, + BigQueryCreateMaterializedViewExpression, + BigQueryCreateMaterializedViewReplicaExpression, + BigQueryDropMaterializedViewExpression, + BigQueryMaterializedViewOption, + BigQueryMaterializedViewOptionValueType, + BigQueryMaterializedViewSupport, +) +from rhosocial.activerecord.backend.impl.bigquery.dialect import BigQueryDialect +from rhosocial.activerecord.backend.impl.bigquery.materialized_view_options import ( + resolve_materialized_view_option, + validate_materialized_view_options, +) + + +@pytest.fixture +def dialect(): + return BigQueryDialect() + + +def _query(dialect): + return QueryExpression( + dialect=dialect, + select=[Column(dialect, "product_id")], + from_=TableExpression(dialect, "sales"), + ) + + +class TestCapabilities: + def test_materialized_view_supported(self, dialect): + assert dialect.supports_materialized_view() is True + assert dialect.supports_materialized_view_options() is True + assert dialect.supports_materialized_view_replica() is True + + def test_no_refresh_statement(self, dialect): + """BigQuery refreshes on a schedule; there is no REFRESH statement.""" + assert dialect.supports_refresh_materialized_view() is False + + def test_generic_refresh_raises(self, dialect): + expr = RefreshMaterializedViewExpression(dialect=dialect, view_name="mv") + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() + + def test_protocol_conformance(self, dialect): + assert isinstance(dialect, BigQueryMaterializedViewSupport) + + def test_formatter_is_owned_by_bigquery_mixin(self, dialect): + owner = dialect.format_create_materialized_view_statement.__qualname__ + assert owner.startswith("BigQueryMaterializedViewMixin.") + + +class TestCreateMaterializedView: + def test_minimal(self, dialect): + expr = BigQueryCreateMaterializedViewExpression( + dialect, "sales_summary", _query(dialect) + ) + sql, params = expr.to_sql() + assert sql == ( + "CREATE MATERIALIZED VIEW `sales_summary` AS " + "SELECT `product_id` FROM `sales`" + ) + assert params == () + + def test_or_replace(self, dialect): + expr = BigQueryCreateMaterializedViewExpression( + dialect, "mv", _query(dialect), or_replace=True + ) + assert expr.to_sql()[0].startswith("CREATE OR REPLACE MATERIALIZED VIEW") + + def test_if_not_exists(self, dialect): + expr = BigQueryCreateMaterializedViewExpression( + dialect, "mv", _query(dialect), if_not_exists=True + ) + assert "IF NOT EXISTS" in expr.to_sql()[0] + + def test_or_replace_and_if_not_exists_are_exclusive(self, dialect): + with pytest.raises(ValueError, match="OR REPLACE"): + BigQueryCreateMaterializedViewExpression( + dialect, "mv", _query(dialect), or_replace=True, if_not_exists=True + ) + + def test_partition_by_and_cluster_by(self, dialect): + expr = BigQueryCreateMaterializedViewExpression( + dialect, + "mv", + _query(dialect), + partition_by="DATE(_PARTITIONTIME)", + cluster_by=["product_id", "region"], + ) + sql, _ = expr.to_sql() + assert "PARTITION BY DATE(_PARTITIONTIME)" in sql + assert "CLUSTER BY product_id, region" in sql + + def test_clause_order_matches_google_sql(self, dialect): + expr = BigQueryCreateMaterializedViewExpression( + dialect, + "mv", + _query(dialect), + or_replace=True, + partition_by="DATE(ts)", + cluster_by=["c1"], + options={BigQueryMaterializedViewOption.ENABLE_REFRESH: True}, + ) + sql, _ = expr.to_sql() + assert sql.index("PARTITION BY") < sql.index("CLUSTER BY") + assert sql.index("CLUSTER BY") < sql.index("OPTIONS(") + assert sql.index("OPTIONS(") < sql.index(" AS ") + + def test_options_rendering(self, dialect): + expr = BigQueryCreateMaterializedViewExpression( + dialect, + "mv", + _query(dialect), + options={ + BigQueryMaterializedViewOption.ENABLE_REFRESH: False, + BigQueryMaterializedViewOption.REFRESH_INTERVAL_MINUTES: 20, + BigQueryMaterializedViewOption.FRIENDLY_NAME: "nightly rollup", + }, + ) + sql, _ = expr.to_sql() + assert "OPTIONS(enable_refresh = false, refresh_interval_minutes = 20" in sql + assert "friendly_name = 'nightly rollup')" in sql + + def test_dataset_qualified_name(self, dialect): + expr = BigQueryCreateMaterializedViewExpression( + dialect, "analytics.sales_summary", _query(dialect) + ) + assert "`analytics.sales_summary`" in expr.to_sql()[0] + + def test_raw_query_string_accepted(self, dialect): + expr = BigQueryCreateMaterializedViewExpression( + dialect, "mv", "SELECT 1 AS one" + ) + assert expr.to_sql()[0].endswith("AS SELECT 1 AS one") + + def test_empty_name_rejected(self, dialect): + with pytest.raises(ValueError, match="view_name"): + BigQueryCreateMaterializedViewExpression(dialect, " ", _query(dialect)) + + def test_empty_cluster_by_rejected(self, dialect): + with pytest.raises(ValueError, match="cluster_by"): + BigQueryCreateMaterializedViewExpression( + dialect, "mv", _query(dialect), cluster_by=[] + ) + + +class TestCreateRejectsNonBigQueryClauses: + """Clauses BigQuery lacks must be refused, never silently dropped.""" + + def test_generic_column_aliases_rejected(self, dialect): + expr = CreateMaterializedViewExpression( + dialect=dialect, + view_name="mv", + query=_query(dialect), + column_aliases=["alias"], + ) + with pytest.raises(UnsupportedFeatureError) as exc: + expr.to_sql() + assert "COLUMN ALIASES" in str(exc.value) + + def test_generic_tablespace_rejected(self, dialect): + expr = CreateMaterializedViewExpression( + dialect=dialect, view_name="mv", query=_query(dialect), tablespace="fast_ssd" + ) + with pytest.raises(UnsupportedFeatureError) as exc: + expr.to_sql() + assert "TABLESPACE" in str(exc.value) + + def test_generic_storage_options_rejected(self, dialect): + expr = CreateMaterializedViewExpression( + dialect=dialect, + view_name="mv", + query=_query(dialect), + storage_options={"fillfactor": 70}, + ) + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() + + def test_generic_with_no_data_rejected(self, dialect): + expr = CreateMaterializedViewExpression( + dialect=dialect, view_name="mv", query=_query(dialect), with_data=False + ) + with pytest.raises(UnsupportedFeatureError) as exc: + expr.to_sql() + assert "WITH NO DATA" in str(exc.value) + + def test_drop_cascade_rejected(self, dialect): + expr = DropMaterializedViewExpression( + dialect=dialect, view_name="mv", if_exists=True, cascade=True + ) + with pytest.raises(UnsupportedFeatureError) as exc: + expr.to_sql() + assert "CASCADE" in str(exc.value) + + +class TestDropMaterializedView: + def test_plain(self, dialect): + expr = BigQueryDropMaterializedViewExpression(dialect, "mv") + assert expr.to_sql()[0] == "DROP MATERIALIZED VIEW `mv`" + + def test_if_exists(self, dialect): + expr = BigQueryDropMaterializedViewExpression(dialect, "mv", if_exists=True) + assert expr.to_sql()[0] == "DROP MATERIALIZED VIEW IF EXISTS `mv`" + + def test_cascade_rejected_at_construction(self, dialect): + with pytest.raises(ValueError, match="CASCADE"): + BigQueryDropMaterializedViewExpression(dialect, "mv", cascade=True) + + def test_generic_expression_renders(self, dialect): + expr = DropMaterializedViewExpression(dialect=dialect, view_name="mv") + assert expr.to_sql()[0] == "DROP MATERIALIZED VIEW `mv`" + + +class TestAlterMaterializedViewSetOptions: + def test_basic(self, dialect): + expr = BigQueryAlterMaterializedViewSetOptionsExpression( + dialect, "mv", {BigQueryMaterializedViewOption.REFRESH_INTERVAL_MINUTES: 20} + ) + sql, params = expr.to_sql() + assert sql == ( + "ALTER MATERIALIZED VIEW `mv` " + "SET OPTIONS(refresh_interval_minutes = 20)" + ) + assert params == () + + def test_if_exists(self, dialect): + expr = BigQueryAlterMaterializedViewSetOptionsExpression( + dialect, "mv", {BigQueryMaterializedViewOption.ENABLE_REFRESH: False}, if_exists=True + ) + assert "ALTER MATERIALIZED VIEW IF EXISTS `mv`" in expr.to_sql()[0] + + def test_empty_options_rejected(self, dialect): + with pytest.raises(ValueError, match="options"): + BigQueryAlterMaterializedViewSetOptionsExpression(dialect, "mv", {}) + + def test_undocumented_option_rejected(self, dialect): + with pytest.raises(ValueError, match="does not document"): + BigQueryAlterMaterializedViewSetOptionsExpression( + dialect, "mv", {"enable_reffresh": True} + ) + + +class TestMaterializedViewReplica: + def test_basic(self, dialect): + expr = BigQueryCreateMaterializedViewReplicaExpression( + dialect, "mv_replica", "mv_source" + ) + assert expr.to_sql()[0] == ( + "CREATE MATERIALIZED VIEW `mv_replica` AS REPLICA OF `mv_source`" + ) + + def test_with_interval(self, dialect): + expr = BigQueryCreateMaterializedViewReplicaExpression( + dialect, "mv_replica", "mv_source", replication_interval_seconds=600 + ) + assert "OPTIONS(replication_interval_seconds = 600)" in expr.to_sql()[0] + + @pytest.mark.parametrize("seconds", [59, 3601, 0, -1]) + def test_interval_bounds_enforced(self, dialect, seconds): + """Documented range is 60..3600 inclusive.""" + with pytest.raises(ValueError, match="replication_interval_seconds"): + BigQueryCreateMaterializedViewReplicaExpression( + dialect, "mv_replica", "mv_source", replication_interval_seconds=seconds + ) + + def test_documented_defaults(self): + expr_cls = BigQueryCreateMaterializedViewReplicaExpression + assert expr_cls.MIN_REPLICATION_INTERVAL_SECONDS == 60 + assert expr_cls.MAX_REPLICATION_INTERVAL_SECONDS == 3600 + assert expr_cls.DEFAULT_REPLICATION_INTERVAL_SECONDS == 300 + + +class TestMaterializedViewOptionCatalogue: + """Option names are validated against the documented catalogue.""" + + def test_catalogue_contents(self): + values = {member.value for member in BigQueryMaterializedViewOption} + assert values == { + "enable_refresh", + "refresh_interval_minutes", + "expiration_timestamp", + "max_staleness", + "allow_non_incremental_definition", + "kms_key_name", + "friendly_name", + "description", + "labels", + "tags", + } + + def test_lookup_by_name_and_member(self): + assert resolve_materialized_view_option("enable_refresh") is ( + BigQueryMaterializedViewOption.ENABLE_REFRESH + ) + assert resolve_materialized_view_option( + BigQueryMaterializedViewOption.LABELS + ) is BigQueryMaterializedViewOption.LABELS + assert resolve_materialized_view_option("enable_reffresh") is None + assert resolve_materialized_view_option(None) is None + + def test_value_types(self): + assert ( + BigQueryMaterializedViewOption.ENABLE_REFRESH.value_type + is BigQueryMaterializedViewOptionValueType.BOOLEAN + ) + assert ( + BigQueryMaterializedViewOption.REFRESH_INTERVAL_MINUTES.value_type + is BigQueryMaterializedViewOptionValueType.FLOAT64 + ) + assert ( + BigQueryMaterializedViewOption.LABELS.value_type + is BigQueryMaterializedViewOptionValueType.LABEL_ARRAY + ) + + def test_documented_defaults(self): + assert BigQueryMaterializedViewOption.ENABLE_REFRESH.default == "true" + assert BigQueryMaterializedViewOption.REFRESH_INTERVAL_MINUTES.default == "30" + assert BigQueryMaterializedViewOption.LABELS.default is None + + def test_validate_rejects_undocumented(self): + with pytest.raises(ValueError, match="does not document"): + validate_materialized_view_options({"refresh_every": "1h"}) + + def test_validate_requires_mapping(self): + with pytest.raises(TypeError): + validate_materialized_view_options(["enable_refresh"]) diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/protocol/test_protocol_conformance.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/protocol/test_protocol_conformance.py index d7a2400..57a1062 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/protocol/test_protocol_conformance.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/protocol/test_protocol_conformance.py @@ -1,12 +1,23 @@ """Protocol conformance tests for BigQuery backend.""" -import pytest +from rhosocial.activerecord.backend.dialect import ( + DomainSupport, + UserDefinedTypeSupport, +) from rhosocial.activerecord.backend.impl.bigquery import ( BigQueryStructSupport, BigQueryArraySupport, BigQueryJSONSupport, BigQueryGeographySupport, ) +from rhosocial.activerecord.backend.impl.bigquery import protocols as bigquery_protocols + + +def test_bigquery_protocol_exports_match_protocol_definitions() -> None: + assert BigQueryStructSupport is bigquery_protocols.BigQueryStructSupport + assert BigQueryArraySupport is bigquery_protocols.BigQueryArraySupport + assert BigQueryJSONSupport is bigquery_protocols.BigQueryJSONSupport + assert BigQueryGeographySupport is bigquery_protocols.BigQueryGeographySupport class TestBigQueryStructSupport: @@ -45,3 +56,9 @@ def test_dialect_has_protocol_methods(self): assert dialect.supports_array() is True assert dialect.supports_json() is True assert dialect.supports_geography() is True + + def test_dialect_has_type_and_domain_protocols(self): + from rhosocial.activerecord.backend.impl.bigquery.dialect import BigQueryDialect + dialect = BigQueryDialect() + assert isinstance(dialect, UserDefinedTypeSupport) + assert isinstance(dialect, DomainSupport) diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_ddl_improvements.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_ddl_improvements.py index a1b7fe4..492028f 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_ddl_improvements.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_ddl_improvements.py @@ -1,17 +1,12 @@ # tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_ddl_improvements.py """Tests for BigQuery DDL improvements: capability gating, UnsupportedFeatureError.""" import pytest -from unittest.mock import patch, PropertyMock - -from rhosocial.activerecord.backend.expression import ( - Column, - TableExpression, - QueryExpression, - CreateViewExpression, - DropViewExpression, -) -from rhosocial.activerecord.backend.impl.bigquery.dialect import BigQueryDialect + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition +from rhosocial.activerecord.backend.expression.types import IntegerType +from rhosocial.activerecord.backend.impl.bigquery.dialect import BigQueryDialect class TestBigQueryViewCapabilityGating: @@ -32,10 +27,16 @@ def test_drop_view_if_exists_not_supported(self): dialect = BigQueryDialect() assert dialect.supports_if_exists_view() is False - def test_materialized_view_not_supported(self): - """BigQuery does not support materialized views via CREATE MATERIALIZED VIEW.""" + def test_materialized_view_supported(self): + """BigQuery has native materialized views. + + Refresh is option-driven (``OPTIONS(enable_refresh=…, + refresh_interval_minutes=…)``) because BigQuery has no + ``REFRESH MATERIALIZED VIEW`` statement. + """ dialect = BigQueryDialect() - assert dialect.supports_materialized_view() is False + assert dialect.supports_materialized_view() is True + assert dialect.supports_refresh_materialized_view() is False class TestBigQuerySchemaCapabilityGating: @@ -65,3 +66,45 @@ def test_schema_cascade_not_supported(self): """BigQuery does not support DROP SCHEMA CASCADE.""" dialect = BigQueryDialect() assert dialect.supports_schema_cascade() is False + + +class TestBigQueryTableDeclarationGating: + def test_table_declaration_defaults_are_absent(self): + dialect = BigQueryDialect() + expression = CreateTableExpression( + dialect, + "plain_table_defaults", + [ColumnDefinition(dialect, "id", IntegerType(dialect))], + ) + sql, params = expression.to_sql() + assert expression.inherits == [] + assert expression.tablespace is None + assert "plain_table_defaults" in sql.lower() + assert "id" in sql.lower() + assert params == () + + def test_table_inherits_is_propagated_and_rejected(self): + dialect = BigQueryDialect() + assert dialect.supports_table_inheritance() is False + expression = CreateTableExpression( + dialect, + "inherited", + [ColumnDefinition(dialect, "id", IntegerType(dialect))], + inherits=["parent_a", "parent_b"], + ) + assert expression.inherits == ["parent_a", "parent_b"] + with pytest.raises(UnsupportedFeatureError, match="INHERITS"): + expression.to_sql() + + def test_table_tablespace_is_propagated_and_rejected(self): + dialect = BigQueryDialect() + assert dialect.supports_table_tablespace() is False + expression = CreateTableExpression( + dialect, + "tablespaced", + [ColumnDefinition(dialect, "id", IntegerType(dialect))], + tablespace="ts_data", + ) + assert expression.tablespace == "ts_data" + with pytest.raises(UnsupportedFeatureError, match="TABLESPACE"): + expression.to_sql()