From cf1dd9f2fbb0d11aa48137a795bab44429480421 Mon Sep 17 00:00:00 2001 From: vistart Date: Sun, 11 Oct 2026 23:53:55 +0800 Subject: [PATCH 01/35] fix(types): refuse a non-boolean unsigned when the type is rendered The shape check now lives where the flag has to be honoured rather than where it is declared, so a value that would silently render the wrong DDL is named instead. The attribute is read directly: whether the field exists is settled by the class, so probing for it with a default would only turn a wrong class into a quiet signed column. CI installs the core library from fix/column-constraint-gates so this backend runs against the branch it depends on. --- .github/workflows/test.yml | 2 +- .../backend/impl/bigquery/mixins/types.py | 34 +++++++++++++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 683d912..f16023f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,7 +41,7 @@ env: # The previous pin, the fix/schema-name-propagation-gaps branch, predates all # of that layer, so the imports this backend now makes do not resolve against # it. - CORE_PKG: "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" + CORE_PKG: "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@fix/column-constraint-gates" TESTSUITE_PKG: "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" jobs: diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/types.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/types.py index 7120970..a55628d 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/types.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/types.py @@ -198,8 +198,24 @@ def _refuse_unsigned_integer( backend writes — all four render ``INT64``, so naming the requested width is the only way the message can say *which* of the four was refused. + + The shape check comes first because this is the only gate the field + passes through: the expression collects ``unsigned`` without judging it + — only the formatter knows which backend has to honour it — so a + non-boolean reaches here rather than being rejected at construction. A + truth test cannot see it: ``unsigned=0``, ``unsigned=""`` and + ``unsigned=None`` are all falsy, so they would pass the test below and + render a signed ``INT64`` and report success, which on this backend is + the loss of the caller's declaration with no change at all in the DDL + to show for it. """ - if not data_type.unsigned: + unsigned = data_type.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, @@ -416,8 +432,22 @@ def _refuse_unsigned_numeric(self, data_type, concept_word: str) -> None: grammar cannot express at all, not a wrong value. The two do not share a base class, and the project's cross-backend rule keeps them apart so a caller can catch one type for the same refusal everywhere. + + The shape check is first for the reason given in + :meth:`_refuse_unsigned_integer`: the field arrives unjudged, and a + falsy non-boolean — ``0``, ``""``, ``None``, an empty container — is + invisible to the truth test below, so it would render a signed + ``NUMERIC`` / ``FLOAT64`` and report success. ``REAL`` reaches this + gate too, through the substitution its own formatter documents, so the + shape is checked on the concept and not on the word it renders to. """ - if not data_type.unsigned: + unsigned = data_type.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, From cc9c0512848c244b3a2607a511c67c8dbaf082f7 Mon Sep 17 00:00:00 2001 From: vistart Date: Mon, 12 Oct 2026 01:19:17 +0800 Subject: [PATCH 02/35] refactor(bigquery): read declared fields directly instead of probing with getattr The formatter parameters are annotated with the classes that declare these fields, so their presence is statically determined and a probe with a default would only turn a wrong-class call into a quiet wrong answer. Both the materialized-view drop/alter formatters and the CHAR/VARCHAR/DECIMAL type formatters now read the attribute outright. --- .../backend/impl/bigquery/mixins/materialized_view.py | 8 ++++---- .../activerecord/backend/impl/bigquery/mixins/types.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/materialized_view.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/materialized_view.py index e532069..d9a8647 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/materialized_view.py @@ -118,16 +118,16 @@ def format_drop_materialized_view_statement( ``RESTRICT`` here. """ self._require_materialized_view(expr, "DropMaterializedViewExpression") - if getattr(expr, "cascade", False): + if expr.cascade: raise UnsupportedFeatureError( self.name, "DROP MATERIALIZED VIEW CASCADE" ) - if getattr(expr, "restrict", False): + if expr.restrict: raise UnsupportedFeatureError( self.name, "DROP MATERIALIZED VIEW RESTRICT" ) parts = ["DROP MATERIALIZED VIEW"] - if getattr(expr, "if_exists", False): + if expr.if_exists: parts.append("IF EXISTS") parts.append(self._materialized_view_name_sql(expr)) return " ".join(parts), () @@ -144,7 +144,7 @@ def format_alter_materialized_view_set_options_statement( expr, "BigQueryAlterMaterializedViewSetOptionsExpression" ) parts = ["ALTER MATERIALIZED VIEW"] - if getattr(expr, "if_exists", False): + if expr.if_exists: parts.append("IF EXISTS") parts.append(self._materialized_view_name_sql(expr)) options = self._format_materialized_view_options(expr.options) diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/types.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/types.py index a55628d..7e4fc1b 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/types.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/types.py @@ -777,8 +777,8 @@ def format_data_type_decimal(self, data_type: DecimalType) -> Tuple[str, tuple]: f"to go" ), ) - if getattr(data_type, "precision", None) is not None: - scale = getattr(data_type, "scale", 0) or 0 + if data_type.precision is not None: + scale = data_type.scale or 0 return f"NUMERIC({data_type.precision}, {scale})", () return "NUMERIC", () @@ -888,7 +888,7 @@ def format_data_type_char(self, data_type: CharType) -> Tuple[str, tuple]: states; see :meth:`_check_string_length`. """ self._check_spelling(data_type, CharType) - length = getattr(data_type, "length", None) + length = data_type.length self._check_string_length(length) # ``is not None`` rather than a truthiness test: a length of 0 is an # out-of-range value, not an absent one, and it is refused above instead @@ -907,7 +907,7 @@ def format_data_type_varchar(self, data_type: VarCharType) -> Tuple[str, tuple]: length is range-checked the same way; see :meth:`_check_string_length`. """ self._check_spelling(data_type, VarCharType) - length = getattr(data_type, "length", None) + length = data_type.length self._check_string_length(length) return (f"STRING({length})", ()) if length is not None else ("STRING", ()) From b8beb95e68ed495e704ebf4032ddbdb12cd7892e Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 15 Oct 2026 01:07:05 +0800 Subject: [PATCH 03/35] refactor(bigquery): follow the statement and expression renames Mechanical rename wave against core's W1/W2/W3a/W3b/S1-c breaks. The constraint surfaces still reference the enums core's S3 removed, so this commit is an expected red window -- the constraint mixin and its switch test follow in the next commit. W2, statement-level classes -> *Statement (core 34e1fb45): QueryStatement, CreateTableStatement, DropTableStatement, CreateTableAsStatement, CreateTableLikeStatement, CreateTableCloneStatement, CreateTableFromTemplateStatement, CreateSchemaStatement, DropSchemaStatement, CreateMaterializedViewStatement, DropMaterializedViewStatement, RefreshMaterializedViewStatement, TruncateStatement, CreateViewStatement, DropViewStatement, InsertStatement, UpdateStatement, DeleteStatement, MergeStatement, BeginTransactionStatement, and the ALTER/CREATE/DROP TYPE/DOMAIN/DATABASE/SEQUENCE/INDEX/FUNCTION/TRIGGER family, plus CommentOnStatement and the property-graph three. W2 formatter rename (functional, 1 hit): format_begin_transaction -> format_begin_transaction_statement in mixins/transaction.py, where the BigQuery-only WAIT / NO WAIT refusal lives; without it the override would have been dropped and the pair silently rendered. W3a, predicate/clause families (core 890aaaa3): ILIKEExpression -> ILikePredicate, XMLExistsExpression -> XMLExistsPredicate, FulltextMatchExpression -> FulltextMatch. The two dispatch-failure fragments the roundtrip matrix pins were the old formatter spellings (format_xmlexists_expression, format_ilike_expression) and are updated to format_xmlexists_predicate / format_ilike_predicate, because a class with no formatter is reported by the method it could not find. W3b, value/function/wrapping families (core 5dc64fd5): SQLValueExpression -> SQLValue, QualifiedIdentifierExpression -> QualifiedIdentifier, UUIDCastExpression -> UUIDCastFunction, CastExpression -> CastFunction, CaseExpression -> CaseFunction, TrimExpression -> TrimFunction (the BigQueryTrimMixin parameter annotation; the formatter name format_trim_expression is unchanged in core), CollateExpression -> CollateFunction, JSONDocumentExpression/JSONTextExpression -> JSONDocumentValue/JSONTextValue, ArrayValueExpression -> ArrayValue, ArrayExpression -> ArrayOperation, BooleanLogicExpression -> LogicalValue, _TemporalValueExpression -> _TemporalValue. W1, CTE family (core 683a183e): CTEExpression -> CTEClause in the master probe guard. The mixins/capabilities.py docstring reference to format_cte_expression becomes format_cte_clause. S1-c, PASSING: no change needed -- this backend constructs no XML passing clause. WildcardExpression and the format_wildcard override are deliberately NOT touched here; the wildcard split is its own wave. Verified: import smoke over every module in the backend package, 33 ok / 0 failing; compileall src+tests exit 0; ruff on the changed file set is identical to the HEAD baseline (the F401 line in test_expression_fields_match_formatters.py changes only the renamed symbol); targeted runs exit 0: test_bigquery_master_probe_guard.py dialect/test_bigquery_materialized_view.py test_bigquery_clause_pair_guard.py ddl/test_create_table_family.py --- README.md | 4 +- .../schema_namespace.md | 44 ++-- .../schema_namespace.md | 44 ++-- .../impl/bigquery/backend/async_backend.py | 8 +- .../backend/impl/bigquery/backend/backend.py | 8 +- .../bigquery/expression/materialized_view.py | 8 +- .../impl/bigquery/mixins/capabilities.py | 2 +- .../backend/impl/bigquery/mixins/dql.py | 6 +- .../impl/bigquery/mixins/materialized_view.py | 14 +- .../backend/impl/bigquery/mixins/schema.py | 12 +- .../impl/bigquery/mixins/transaction.py | 8 +- .../backend/impl/bigquery/mixins/trim.py | 4 +- tests/providers/query.py | 2 +- .../backend/ddl/test_create_table_family.py | 14 +- .../backend/ddl/test_ddl_source_mixin.py | 8 +- .../backend/ddl/test_type_domain_negative.py | 28 +-- .../test_bigquery_materialized_view.py | 30 +-- ...test_expression_fields_match_formatters.py | 36 ++-- .../test_bigquery_capability_assertions.py | 24 +-- .../test_bigquery_clause_pair_guard.py | 28 +-- .../backend/test_bigquery_ddl_improvements.py | 8 +- .../test_bigquery_master_probe_guard.py | 68 +++--- .../backend/test_expression_roundtrip_all.py | 204 +++++++++--------- 23 files changed, 306 insertions(+), 306 deletions(-) diff --git a/README.md b/README.md index 80e3516..f2d32af 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ from typing import ClassVar, Optional from rhosocial.activerecord.base import FieldProxy from rhosocial.activerecord.backend.impl.bigquery.backend import BigQueryBackend from rhosocial.activerecord.backend.impl.bigquery.config import BigQueryConnectionConfig -from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression import CreateTableStatement from rhosocial.activerecord.backend.expression.statements import ColumnDefinition from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType from rhosocial.activerecord.model import ActiveRecord @@ -73,7 +73,7 @@ User.configure( ) dialect = User.__backend__.dialect -User.__backend__.execute(*CreateTableExpression( +User.__backend__.execute(*CreateTableStatement( dialect, "users", columns=[ diff --git a/docs/en_US/bigquery_specific_features/schema_namespace.md b/docs/en_US/bigquery_specific_features/schema_namespace.md index a6740e3..3a35b34 100644 --- a/docs/en_US/bigquery_specific_features/schema_namespace.md +++ b/docs/en_US/bigquery_specific_features/schema_namespace.md @@ -340,18 +340,18 @@ A CTE is named for the rest of the query, not for the database, so its own name bare. The query inside it still carries the model's dataset: ```python -inner = QueryExpression( +inner = QueryStatement( dialect=d, select=[Column(d, "id", table="orders", schema_name="app")], from_=[TableExpression(d, "orders", schema_name="app")], ) -main = QueryExpression( +main = QueryStatement( dialect=d, select=[Column(d, "id", table="recent_orders")], from_=[TableExpression(d, "recent_orders")], ) -WithQueryExpression(d, [CTEExpression(d, "recent_orders", inner)], main).to_sql()[0] +WithStatement(d, [CTEClause(d, "recent_orders", inner)], main).to_sql()[0] # WITH `recent_orders` AS (SELECT `orders`.`id` FROM `app`.`orders`) # SELECT `recent_orders`.`id` FROM `recent_orders` ``` @@ -421,16 +421,16 @@ is built — a migration has to name the dataset it means — but every statemen names a dataset-bearing object accepts a `schema_name` of its own: ```python -CreateTableExpression( +CreateTableStatement( d, TableExpression(d, "orders", schema_name="app"), columns=cols ).to_sql()[0] # CREATE TABLE `app`.`orders` (`id` INT64) -DropTableExpression(d, TableExpression(d, "orders", schema_name="app"), +DropTableStatement(d, TableExpression(d, "orders", schema_name="app"), if_exists=True).to_sql()[0] # DROP TABLE IF EXISTS `app`.`orders` -CreateViewExpression(d, "v_orders", query, schema_name="app").to_sql()[0] +CreateViewStatement(d, "v_orders", query, schema_name="app").to_sql()[0] # CREATE VIEW `app`.`v_orders` AS SELECT `orders`.`id` FROM `app`.`orders` ``` @@ -441,18 +441,18 @@ Without a `WHERE` clause they render the range and nothing else, which is also the shortest way to see that no dataset reaches a predicate: ```python -InsertExpression( +InsertStatement( d, TableExpression(d, "users", schema_name="app"), ValuesSource(d, [[Literal(d, "x")]]), columns=["name"], ).to_sql() # ('INSERT INTO `app`.`users` (`name`) VALUES (?)', ('x',)) -UpdateExpression( +UpdateStatement( d, TableExpression(d, "users", schema_name="app"), {"name": Literal(d, "x")} ).to_sql() # ('UPDATE `app`.`users` SET `name` = ?', ('x',)) -DeleteExpression(d, [TableExpression(d, "users", schema_name="app")]).to_sql() +DeleteStatement(d, [TableExpression(d, "users", schema_name="app")]).to_sql() # ('DELETE FROM `app`.`users`', ()) ``` @@ -471,11 +471,11 @@ Two DDL statements are refused rather than qualified, because BigQuery does not have the clause: ```python -DropViewExpression(d, "v_orders", schema_name="app", if_exists=True).to_sql() +DropViewStatement(d, "v_orders", schema_name="app", if_exists=True).to_sql() # UnsupportedFeatureError: 'BigQuery' dialect does not support DROP VIEW IF # EXISTS. Suggestion: BigQuery does not support DROP VIEW IF EXISTS. -CreateViewExpression(d, "v_orders", query, schema_name="app", if_not_exists=True).to_sql() +CreateViewStatement(d, "v_orders", query, schema_name="app", if_not_exists=True).to_sql() # UnsupportedFeatureError: 'BigQuery' dialect does not support CREATE VIEW IF # NOT EXISTS. Suggestion: BigQuery does not support CREATE VIEW IF NOT EXISTS. ``` @@ -488,25 +488,25 @@ Both statements are rendered through the dataset name as a single quoted identifier: ```python -CreateSchemaExpression(d, "sales").to_sql()[0] +CreateSchemaStatement(d, "sales").to_sql()[0] # CREATE SCHEMA `sales` -CreateSchemaExpression(d, "sales", if_not_exists=True).to_sql()[0] +CreateSchemaStatement(d, "sales", if_not_exists=True).to_sql()[0] # CREATE SCHEMA IF NOT EXISTS `sales` -DropSchemaExpression(d, "sales", if_exists=True).to_sql()[0] +DropSchemaStatement(d, "sales", if_exists=True).to_sql()[0] # DROP SCHEMA IF EXISTS `sales` ``` `authorization=` is refused, and `cascade=True` is refused as well: ```python -CreateSchemaExpression(d, "sales", authorization="app_user").to_sql() +CreateSchemaStatement(d, "sales", authorization="app_user").to_sql() # UnsupportedFeatureError: 'BigQuery' dialect does not support CREATE SCHEMA # AUTHORIZATION. Suggestion: BigQuery does not support CREATE SCHEMA # AUTHORIZATION. -DropSchemaExpression(d, "sales", cascade=True).to_sql() +DropSchemaStatement(d, "sales", cascade=True).to_sql() # UnsupportedFeatureError: 'BigQuery' dialect does not support DROP SCHEMA # CASCADE. Suggestion: BigQuery does not support DROP SCHEMA CASCADE. ``` @@ -622,9 +622,9 @@ Sequence DDL is not implemented on this backend, so `schema_name` on it never reaches a renderer: ```python -CreateSequenceExpression(d, "s_orders", schema_name="app").to_sql() +CreateSequenceStatement(d, "s_orders", schema_name="app").to_sql() # AttributeError: BigQueryDialect has no formatting method -# 'format_create_sequence_statement' (required by CreateSequenceExpression). +# 'format_create_sequence_statement' (required by CreateSequenceStatement). ``` `ALTER TABLE ... ADD/DROP INDEX` is refused at the capability level @@ -634,22 +634,22 @@ the table is qualified by its own `TableExpression`, and a bare string for that table is refused at construction: ```python -CreateIndexExpression( +CreateIndexStatement( d, "idx_orders_id", TableExpression(d, "orders", schema_name="app"), ["id"], schema_name="app", ).to_sql()[0] # CREATE INDEX `app`.`idx_orders_id` ON `app`.`orders` (`id`) -CreateIndexExpression( +CreateIndexStatement( d, "idx_shared", TableExpression(d, "orders", schema_name="sales"), ["user_id"], schema_name="app", ).to_sql()[0] # CREATE INDEX `app`.`idx_shared` ON `sales`.`orders` (`user_id`) -CreateIndexExpression(d, "idx_orders_id", "orders", ["id"], schema_name="app") +CreateIndexStatement(d, "idx_orders_id", "orders", ["id"], schema_name="app") # TypeError: table must be a TableExpression, got str -DropIndexExpression(d, "idx_orders_id", schema_name="app").to_sql()[0] +DropIndexStatement(d, "idx_orders_id", schema_name="app").to_sql()[0] # DROP INDEX `app`.`idx_orders_id` ``` diff --git a/docs/zh_CN/bigquery_specific_features/schema_namespace.md b/docs/zh_CN/bigquery_specific_features/schema_namespace.md index 02ef863..23f79ad 100644 --- a/docs/zh_CN/bigquery_specific_features/schema_namespace.md +++ b/docs/zh_CN/bigquery_specific_features/schema_namespace.md @@ -314,18 +314,18 @@ CTE 是给余下这条查询起名字,不是给数据库起名字,因此它 查询仍然带着模型的 dataset: ```python -inner = QueryExpression( +inner = QueryStatement( dialect=d, select=[Column(d, "id", table="orders", schema_name="app")], from_=[TableExpression(d, "orders", schema_name="app")], ) -main = QueryExpression( +main = QueryStatement( dialect=d, select=[Column(d, "id", table="recent_orders")], from_=[TableExpression(d, "recent_orders")], ) -WithQueryExpression(d, [CTEExpression(d, "recent_orders", inner)], main).to_sql()[0] +WithStatement(d, [CTEClause(d, "recent_orders", inner)], main).to_sql()[0] # WITH `recent_orders` AS (SELECT `orders`.`id` FROM `app`.`orders`) # SELECT `recent_orders`.`id` FROM `recent_orders` ``` @@ -392,16 +392,16 @@ Dotted.query().select(Dotted.c.id).to_sql()[0] 参数: ```python -CreateTableExpression( +CreateTableStatement( d, TableExpression(d, "orders", schema_name="app"), columns=cols ).to_sql()[0] # CREATE TABLE `app`.`orders` (`id` INT64) -DropTableExpression(d, TableExpression(d, "orders", schema_name="app"), +DropTableStatement(d, TableExpression(d, "orders", schema_name="app"), if_exists=True).to_sql()[0] # DROP TABLE IF EXISTS `app`.`orders` -CreateViewExpression(d, "v_orders", query, schema_name="app").to_sql()[0] +CreateViewStatement(d, "v_orders", query, schema_name="app").to_sql()[0] # CREATE VIEW `app`.`v_orders` AS SELECT `orders`.`id` FROM `app`.`orders` ``` @@ -411,18 +411,18 @@ DML 语句接受带限定的 `TableExpression`,限定方式一致。不带 `WH 范围本身,这也是看清「谓词里没有 dataset」最短的一条路: ```python -InsertExpression( +InsertStatement( d, TableExpression(d, "users", schema_name="app"), ValuesSource(d, [[Literal(d, "x")]]), columns=["name"], ).to_sql() # ('INSERT INTO `app`.`users` (`name`) VALUES (?)', ('x',)) -UpdateExpression( +UpdateStatement( d, TableExpression(d, "users", schema_name="app"), {"name": Literal(d, "x")} ).to_sql() # ('UPDATE `app`.`users` SET `name` = ?', ('x',)) -DeleteExpression(d, [TableExpression(d, "users", schema_name="app")]).to_sql() +DeleteStatement(d, [TableExpression(d, "users", schema_name="app")]).to_sql() # ('DELETE FROM `app`.`users`', ()) ``` @@ -437,11 +437,11 @@ DeleteExpression(d, [TableExpression(d, "users", schema_name="app")]).to_sql() 有两条 DDL 是被拒绝而不是被限定,因为 BigQuery 没有对应的子句: ```python -DropViewExpression(d, "v_orders", schema_name="app", if_exists=True).to_sql() +DropViewStatement(d, "v_orders", schema_name="app", if_exists=True).to_sql() # UnsupportedFeatureError: 'BigQuery' dialect does not support DROP VIEW IF # EXISTS. Suggestion: BigQuery does not support DROP VIEW IF EXISTS. -CreateViewExpression(d, "v_orders", query, schema_name="app", if_not_exists=True).to_sql() +CreateViewStatement(d, "v_orders", query, schema_name="app", if_not_exists=True).to_sql() # UnsupportedFeatureError: 'BigQuery' dialect does not support CREATE VIEW IF # NOT EXISTS. Suggestion: BigQuery does not support CREATE VIEW IF NOT EXISTS. ``` @@ -453,25 +453,25 @@ CreateViewExpression(d, "v_orders", query, schema_name="app", if_not_exists=True 两条语句都把 dataset 名渲染成单个带引号的标识符: ```python -CreateSchemaExpression(d, "sales").to_sql()[0] +CreateSchemaStatement(d, "sales").to_sql()[0] # CREATE SCHEMA `sales` -CreateSchemaExpression(d, "sales", if_not_exists=True).to_sql()[0] +CreateSchemaStatement(d, "sales", if_not_exists=True).to_sql()[0] # CREATE SCHEMA IF NOT EXISTS `sales` -DropSchemaExpression(d, "sales", if_exists=True).to_sql()[0] +DropSchemaStatement(d, "sales", if_exists=True).to_sql()[0] # DROP SCHEMA IF EXISTS `sales` ``` `authorization=` 被拒绝,`cascade=True` 同样被拒绝: ```python -CreateSchemaExpression(d, "sales", authorization="app_user").to_sql() +CreateSchemaStatement(d, "sales", authorization="app_user").to_sql() # UnsupportedFeatureError: 'BigQuery' dialect does not support CREATE SCHEMA # AUTHORIZATION. Suggestion: BigQuery does not support CREATE SCHEMA # AUTHORIZATION. -DropSchemaExpression(d, "sales", cascade=True).to_sql() +DropSchemaStatement(d, "sales", cascade=True).to_sql() # UnsupportedFeatureError: 'BigQuery' dialect does not support DROP SCHEMA # CASCADE. Suggestion: BigQuery does not support DROP SCHEMA CASCADE. ``` @@ -580,9 +580,9 @@ sequence 相关的 DDL 在本后端没有实现,因此它上面的 `schema_nam 渲染器: ```python -CreateSequenceExpression(d, "s_orders", schema_name="app").to_sql() +CreateSequenceStatement(d, "s_orders", schema_name="app").to_sql() # AttributeError: BigQueryDialect has no formatting method -# 'format_create_sequence_statement' (required by CreateSequenceExpression). +# 'format_create_sequence_statement' (required by CreateSequenceStatement). ``` `ALTER TABLE ... ADD/DROP INDEX` 在能力标志层面被拒绝 @@ -591,22 +591,22 @@ CreateSequenceExpression(d, "s_orders", schema_name="app").to_sql() `TableExpression` 限定,而给那个表传裸字符串会在构造期被拒绝: ```python -CreateIndexExpression( +CreateIndexStatement( d, "idx_orders_id", TableExpression(d, "orders", schema_name="app"), ["id"], schema_name="app", ).to_sql()[0] # CREATE INDEX `app`.`idx_orders_id` ON `app`.`orders` (`id`) -CreateIndexExpression( +CreateIndexStatement( d, "idx_shared", TableExpression(d, "orders", schema_name="sales"), ["user_id"], schema_name="app", ).to_sql()[0] # CREATE INDEX `app`.`idx_shared` ON `sales`.`orders` (`user_id`) -CreateIndexExpression(d, "idx_orders_id", "orders", ["id"], schema_name="app") +CreateIndexStatement(d, "idx_orders_id", "orders", ["id"], schema_name="app") # TypeError: table must be a TableExpression, got str -DropIndexExpression(d, "idx_orders_id", schema_name="app").to_sql()[0] +DropIndexStatement(d, "idx_orders_id", schema_name="app").to_sql()[0] # DROP INDEX `app`.`idx_orders_id` ``` diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/backend/async_backend.py b/src/rhosocial/activerecord/backend/impl/bigquery/backend/async_backend.py index 34c7ee3..257c49e 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/backend/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/backend/async_backend.py @@ -223,9 +223,9 @@ async def bulk_insert(self, options) -> QueryResult: async def bulk_update(self, options) -> QueryResult: # See BigQueryBackend.bulk_update: explicit ELSE branch per CASE. from rhosocial.activerecord.backend.expression import ( - Column, Literal, UpdateExpression, ComparisonPredicate, InPredicate, + Column, Literal, UpdateStatement, ComparisonPredicate, InPredicate, ) - from rhosocial.activerecord.backend.expression.advanced_functions import CaseExpression + from rhosocial.activerecord.backend.expression.advanced_functions import CaseFunction from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType @@ -238,12 +238,12 @@ async def bulk_update(self, options) -> QueryResult: condition = ComparisonPredicate(self.dialect, "=", pk_col, Literal(self.dialect, pk_val)) result_expr = Literal(self.dialect, field_val) cases.append((condition, result_expr)) - assignments[field_name] = CaseExpression( + assignments[field_name] = CaseFunction( self.dialect, cases=cases, else_result=Column(self.dialect, field_name), ) pk_literals = Literal(self.dialect, options.pk_values) where_predicate = InPredicate(self.dialect, pk_col, pk_literals) - update_expr = UpdateExpression( + update_expr = UpdateStatement( dialect=self.dialect, # One construction whether or not a dataset was given; see # BigQueryBackend.bulk_update. diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/backend/backend.py b/src/rhosocial/activerecord/backend/impl/bigquery/backend/backend.py index 28688f9..89ee512 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/backend/backend.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/backend/backend.py @@ -619,9 +619,9 @@ def bulk_update(self, options) -> QueryResult: # column itself is also the safe semantics. from rhosocial.activerecord.backend.base.operations import _is_sql_expression from rhosocial.activerecord.backend.expression import ( - Column, Literal, UpdateExpression, ComparisonPredicate, InPredicate, + Column, Literal, UpdateStatement, ComparisonPredicate, InPredicate, ) - from rhosocial.activerecord.backend.expression.advanced_functions import CaseExpression + from rhosocial.activerecord.backend.expression.advanced_functions import CaseFunction from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType @@ -634,13 +634,13 @@ def bulk_update(self, options) -> QueryResult: condition = ComparisonPredicate(self.dialect, "=", pk_col, Literal(self.dialect, pk_val)) result_expr = Literal(self.dialect, field_val) cases.append((condition, result_expr)) - assignments[field_name] = CaseExpression( + assignments[field_name] = CaseFunction( self.dialect, cases=cases, else_result=Column(self.dialect, field_name), ) pk_literals = Literal(self.dialect, options.pk_values) where_predicate = InPredicate(self.dialect, pk_col, pk_literals) - update_expr = UpdateExpression( + update_expr = UpdateStatement( dialect=self.dialect, # One construction whether or not a dataset was given. The # conditional branch here existed only to avoid passing diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/expression/materialized_view.py b/src/rhosocial/activerecord/backend/impl/bigquery/expression/materialized_view.py index 23b476b..d4dc148 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/expression/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/expression/materialized_view.py @@ -48,8 +48,8 @@ from rhosocial.activerecord.backend.expression.bases import BaseExpression from rhosocial.activerecord.backend.expression.objects import MaterializedView from rhosocial.activerecord.backend.expression.statements.ddl_view import ( - CreateMaterializedViewExpression, - DropMaterializedViewExpression, + CreateMaterializedViewStatement, + DropMaterializedViewStatement, ) from ..materialized_view_options import validate_materialized_view_options @@ -66,7 +66,7 @@ ] -class BigQueryCreateMaterializedViewExpression(CreateMaterializedViewExpression): +class BigQueryCreateMaterializedViewExpression(CreateMaterializedViewStatement): """``CREATE [OR REPLACE] MATERIALIZED VIEW [IF NOT EXISTS] ... AS query``. Args: @@ -135,7 +135,7 @@ def format_method(self) -> str: return "format_create_materialized_view_statement" -class BigQueryDropMaterializedViewExpression(DropMaterializedViewExpression): +class BigQueryDropMaterializedViewExpression(DropMaterializedViewStatement): """``DROP MATERIALIZED VIEW [IF EXISTS] mv_name``. BigQuery has neither ``CASCADE`` nor ``RESTRICT`` for materialized views; diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/capabilities.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/capabilities.py index d051481..286a22d 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/capabilities.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/capabilities.py @@ -50,7 +50,7 @@ def supports_materialized_cte(self) -> bool: materialization itself (recursive CTEs are materialized, non-recursive ones are not). - Declared here because core's ``format_cte_expression`` now consults the + Declared here because core's ``format_cte_clause`` now consults the probe when a hint is requested, so the refusal is this dialect's documented answer rather than an inherited default. diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/dql.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/dql.py index 5aee6e0..d026864 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/dql.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/dql.py @@ -5,7 +5,7 @@ from typing import Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError -from rhosocial.activerecord.backend.expression.query_sources import SetOperationExpression +from rhosocial.activerecord.backend.expression.query_sources import SetOperationStatement if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.core import Column, WildcardExpression @@ -14,7 +14,7 @@ class BigQueryDQLMixin: """BigQuery DQL formatting overrides.""" - def format_set_operation_expression(self, expr: SetOperationExpression) -> Tuple[str, tuple]: + def format_set_operation_statement(self, expr: SetOperationStatement) -> Tuple[str, tuple]: """Format a set operation with GoogleSQL's required qualifier. BigQuery's grammar has no bare set operator:: @@ -47,7 +47,7 @@ def format_set_operation_expression(self, expr: SetOperationExpression) -> Tuple """ def _render(node) -> Tuple[str, list]: sql, params = node.to_sql() - if isinstance(node, SetOperationExpression): + if isinstance(node, SetOperationStatement): sql = f"({sql})" return sql, list(params) diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/materialized_view.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/materialized_view.py index d9a8647..838c0ef 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/materialized_view.py @@ -16,8 +16,8 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.expression.statements.ddl_view import ( - CreateMaterializedViewExpression, - DropMaterializedViewExpression, + CreateMaterializedViewStatement, + DropMaterializedViewStatement, ) from ..expression.materialized_view import ( @@ -59,7 +59,7 @@ def supports_materialized_view_replica(self) -> bool: # ------------------------------------------------------------------ def format_create_materialized_view_statement( - self, expr: "CreateMaterializedViewExpression" + self, expr: "CreateMaterializedViewStatement" ) -> Tuple[str, tuple]: """Format ``CREATE [OR REPLACE] MATERIALIZED VIEW [IF NOT EXISTS]``. @@ -76,7 +76,7 @@ def format_create_materialized_view_statement( statement would still be well-formed SQL. UnsupportedFeatureError: for clauses BigQuery does not have. """ - self._require_materialized_view(expr, "CreateMaterializedViewExpression") + self._require_materialized_view(expr, "CreateMaterializedViewStatement") self._reject_unsupported_clauses(expr, "CREATE MATERIALIZED VIEW") parts = ["CREATE"] @@ -105,7 +105,7 @@ def format_create_materialized_view_statement( return " ".join(parts), query_params def format_drop_materialized_view_statement( - self, expr: "DropMaterializedViewExpression" + self, expr: "DropMaterializedViewStatement" ) -> Tuple[str, tuple]: """Format ``DROP MATERIALIZED VIEW [IF EXISTS]``. @@ -117,7 +117,7 @@ def format_drop_materialized_view_statement( UnsupportedFeatureError: BigQuery has no ``CASCADE`` or ``RESTRICT`` here. """ - self._require_materialized_view(expr, "DropMaterializedViewExpression") + self._require_materialized_view(expr, "DropMaterializedViewStatement") if expr.cascade: raise UnsupportedFeatureError( self.name, "DROP MATERIALIZED VIEW CASCADE" @@ -195,7 +195,7 @@ def _require_materialized_view(expr: Any, statement: str) -> None: Every one of these four formatters is reached by name -- a view's ``format_method`` says which formatter renders it -- so nothing about the dispatch tells them whether the object they were handed is the kind the - statement acts on. Left unchecked, ``CreateMaterializedViewExpression`` + statement acts on. Left unchecked, ``CreateMaterializedViewStatement`` given a ``Table`` renders ``CREATE MATERIALIZED VIEW`orders` ...``: the object's own ``format_table_object`` produces valid SQL, so the result is a well-formed statement about the wrong object and nothing says so. diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/schema.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/schema.py index c9b8260..446c0fa 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/schema.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/schema.py @@ -8,8 +8,8 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements.ddl_schema import ( - CreateSchemaExpression, - DropSchemaExpression, + CreateSchemaStatement, + DropSchemaStatement, ) from rhosocial.activerecord.backend.expression.objects import Schema @@ -74,7 +74,7 @@ def supports_schema_restrict(self) -> bool: """ return True - def format_create_schema_statement(self, expr: CreateSchemaExpression) -> Tuple[str, tuple]: + def format_create_schema_statement(self, expr: CreateSchemaStatement) -> Tuple[str, tuple]: """Format ``CREATE SCHEMA`` (GoogleSQL: ``CREATE DATASET``). Raises: @@ -84,7 +84,7 @@ def format_create_schema_statement(self, expr: CreateSchemaExpression) -> Tuple[ """ if not isinstance(expr.schema, Schema): raise TypeError( - f"CreateSchemaExpression.schema must be a Schema, " + f"CreateSchemaStatement.schema must be a Schema, " f"got {type(expr.schema).__name__}" ) if expr.if_not_exists and not self.supports_schema_if_not_exists(): @@ -100,7 +100,7 @@ def format_create_schema_statement(self, expr: CreateSchemaExpression) -> Tuple[ if_not_exists_part = "IF NOT EXISTS " if expr.if_not_exists else "" return f"CREATE SCHEMA {if_not_exists_part}{expr.schema.to_sql()[0]}", () - def format_drop_schema_statement(self, expr: DropSchemaExpression) -> Tuple[str, tuple]: + def format_drop_schema_statement(self, expr: DropSchemaStatement) -> Tuple[str, tuple]: """Format ``DROP SCHEMA`` (GoogleSQL: ``DROP DATASET``). Raises: @@ -109,7 +109,7 @@ def format_drop_schema_statement(self, expr: DropSchemaExpression) -> Tuple[str, """ if not isinstance(expr.schema, Schema): raise TypeError( - f"DropSchemaExpression.schema must be a Schema, " + f"DropSchemaStatement.schema must be a Schema, " f"got {type(expr.schema).__name__}" ) if expr.if_exists and not self.supports_schema_if_exists(): diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/transaction.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/transaction.py index 6b0d158..fae8a51 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/transaction.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/transaction.py @@ -14,7 +14,7 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.expression.transaction import ( - BeginTransactionExpression, + BeginTransactionStatement, SetTransactionExpression, ) @@ -32,7 +32,7 @@ def supports_transaction_wait(self) -> bool: ``False``: the transactions reference documents ``BEGIN TRANSACTION``, ``COMMIT TRANSACTION`` and ``ROLLBACK TRANSACTION``; the string ``WAIT`` does not occur on it, and neither does ``SET TRANSACTION``. - The pair is carried by ``BeginTransactionExpression`` and + The pair is carried by ``BeginTransactionStatement`` and ``SetTransactionExpression`` all the same, so both spellings are refused by name below rather than dropped. @@ -41,8 +41,8 @@ def supports_transaction_wait(self) -> bool: """ return False - def format_begin_transaction( - self, expr: "BeginTransactionExpression" + def format_begin_transaction_statement( + self, expr: "BeginTransactionStatement" ) -> Tuple[str, tuple]: """Format a BEGIN statement, refusing the wait pair by name. diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/trim.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/trim.py index 3e49b31..489f4e6 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/trim.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/trim.py @@ -23,7 +23,7 @@ from typing import Tuple, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover - from ...expression.advanced_functions import TrimExpression + from ...expression.advanced_functions import TrimFunction class BigQueryTrimMixin: @@ -31,7 +31,7 @@ class BigQueryTrimMixin: _TRIM_FUNCTIONS = {"BOTH": "TRIM", "LEADING": "LTRIM", "TRAILING": "RTRIM"} - def format_trim_expression(self, expr: "TrimExpression") -> Tuple[str, Tuple]: + def format_trim_expression(self, expr: "TrimFunction") -> Tuple[str, Tuple]: """Format a TRIM node in BigQuery's form. Args: diff --git a/tests/providers/query.py b/tests/providers/query.py index 79fe59e..1a5b21f 100644 --- a/tests/providers/query.py +++ b/tests/providers/query.py @@ -9,7 +9,7 @@ (``python-activerecord-mysql/tests/providers/query.py``): * Raw DDL templates come from :mod:`providers.fixtures.query` as plain SQL - strings (no ``CreateTableExpression``), with tables created + strings (no ``CreateTableStatement``), with tables created dataset-qualified; models get ``__schema_name__`` set to the scenario dataset. * No ``FOREIGN_KEY_CHECKS`` toggling and no AUTO_INCREMENT — BigQuery has no enforced foreign keys and the backend generates primary keys client-side. diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/ddl/test_create_table_family.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/ddl/test_create_table_family.py index eb5d5c2..1d69a01 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/ddl/test_create_table_family.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/ddl/test_create_table_family.py @@ -9,11 +9,11 @@ from rhosocial.activerecord.backend.impl.bigquery.dialect import BigQueryDialect from rhosocial.activerecord.backend.expression import ( - CreateTableLikeExpression, - CreateTableCloneExpression, + CreateTableLikeStatement, + CreateTableCloneStatement, CreateTableCloneMode, CreateTableOptions, - CreateTableExpression, + CreateTableStatement, ) from rhosocial.activerecord.backend.expression.objects import Table @@ -31,21 +31,21 @@ def test_capabilities(self, dialect): assert dialect.supports_create_table_clone() 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") ).to_sql() assert sql == "CREATE TABLE `clone_t` CLONE `src`" assert params == () def test_create_table_copy(self, dialect): - sql, params = CreateTableCloneExpression( + sql, params = CreateTableCloneStatement( dialect, Table(dialect, "copy_t"), Table(dialect, "src"), @@ -55,7 +55,7 @@ def test_create_table_copy(self, dialect): assert params == () def test_create_or_replace(self, dialect): - expr = CreateTableExpression( + expr = CreateTableStatement( dialect, table=Table(dialect, "t"), columns=[], 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 index 9ffecc3..8621351 100644 --- 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 @@ -14,7 +14,7 @@ from rhosocial.activerecord.backend.expression.statements import ( ColumnConstraintType, ColumnDefinition, - CreateTableExpression, + CreateTableStatement, CreateTableOptions, IndexDefinition, StorageOptionsExpression, @@ -158,7 +158,7 @@ def table_tablespace(cls): @classmethod def create_table_statement_classes(cls): - return CreateTableExpression + return CreateTableStatement class DeclarationModel(TableDeclarationOverrides, ActiveRecord): @@ -356,7 +356,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 all( actual is expected @@ -497,7 +497,7 @@ def test_collected_bigquery_type_and_option_render_minimal_table(): try: column = ColumnDefinition(dialect, "physical_value", data_type) from rhosocial.activerecord.backend.expression.objects import Table - expression = CreateTableExpression( + expression = CreateTableStatement( dialect, Table(dialect, "rendered"), [column], 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 index 6629947..abb4a67 100644 --- 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 @@ -23,17 +23,17 @@ from rhosocial.activerecord.backend.expression import BaseExpression, Literal from rhosocial.activerecord.backend.expression.objects import Domain, Schema, Type from rhosocial.activerecord.backend.expression.statements import ( - AlterDomainExpression, - AlterTypeExpression, - CreateDomainExpression, - CreateSchemaExpression, - CreateTypeExpression, + AlterDomainStatement, + AlterTypeStatement, + CreateDomainStatement, + CreateSchemaStatement, + CreateTypeStatement, DomainCheckConstraint, DomainNullability, DomainValueExpression, DropDomainDefaultAction, - DropDomainExpression, - DropTypeExpression, + DropDomainStatement, + DropTypeStatement, TypeAlterAction, TypeDefinition, ) @@ -128,19 +128,19 @@ def _type_nodes(dialect: BigQueryDialect) -> Tuple[BaseExpression, ...]: definition = _NegativeTypeDefinition(dialect) action = _NegativeTypeAlterAction(dialect) return ( - CreateTypeExpression( + CreateTypeStatement( dialect, Type(dialect, "status"), definition, if_not_exists=True, ), - AlterTypeExpression( + AlterTypeStatement( dialect, Type(dialect, "status"), [action], if_exists=True, ), - DropTypeExpression(dialect, Type(dialect, "status"), if_exists=True), + DropTypeStatement(dialect, Type(dialect, "status"), if_exists=True), definition, action, ) @@ -155,15 +155,15 @@ def _domain_nodes(dialect: BigQueryDialect) -> Tuple[BaseExpression, ...]: check = DomainCheckConstraint(dialect, condition, name="nonnegative") action = DropDomainDefaultAction(dialect) return ( - CreateDomainExpression( + CreateDomainStatement( dialect, Domain(dialect, "nonnegative"), IntegerType(dialect), checks=[check], collation="und:ci", ), - AlterDomainExpression(dialect, Domain(dialect, "nonnegative"), [action]), - DropDomainExpression(dialect, Domain(dialect, "nonnegative")), + AlterDomainStatement(dialect, Domain(dialect, "nonnegative"), [action]), + DropDomainStatement(dialect, Domain(dialect, "nonnegative")), DomainValueExpression(dialect), check, action, @@ -289,7 +289,7 @@ def test_column_types_and_dataset_schema_remain_separate( assert dialect.supports_json() is True assert JsonType(dialect).to_sql() == ("JSON", ()) - schema = CreateSchemaExpression(dialect, Schema(dialect, "analytics")) + schema = CreateSchemaStatement(dialect, Schema(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 index d101609..f053e20 100644 --- 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 @@ -11,12 +11,12 @@ 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, - DropMaterializedViewExpression, - RefreshMaterializedViewExpression, + CreateMaterializedViewStatement, + DropMaterializedViewStatement, + RefreshMaterializedViewStatement, ) from rhosocial.activerecord.backend.impl.bigquery.expression import ( BigQueryAlterMaterializedViewSetOptionsExpression, @@ -42,7 +42,7 @@ def dialect(): def _query(dialect): - return QueryExpression( + return QueryStatement( dialect=dialect, select=[Column(dialect, "product_id")], from_=Table(dialect, "sales"), @@ -60,7 +60,7 @@ def test_no_refresh_statement(self, dialect): assert dialect.supports_refresh_materialized_view() is False def test_generic_refresh_raises(self, dialect): - expr = RefreshMaterializedViewExpression( + expr = RefreshMaterializedViewStatement( dialect=dialect, view=MaterializedView(dialect, "mv") ) with pytest.raises(UnsupportedFeatureError): @@ -209,7 +209,7 @@ class TestCreateRejectsNonBigQueryClauses: """Clauses BigQuery lacks must be refused, never silently dropped.""" def test_generic_column_aliases_rejected(self, dialect): - expr = CreateMaterializedViewExpression( + expr = CreateMaterializedViewStatement( dialect=dialect, view=MaterializedView(dialect, "mv"), query=_query(dialect), @@ -220,7 +220,7 @@ def test_generic_column_aliases_rejected(self, dialect): assert "COLUMN ALIASES" in str(exc.value) def test_generic_tablespace_rejected(self, dialect): - expr = CreateMaterializedViewExpression( + expr = CreateMaterializedViewStatement( dialect=dialect, view=MaterializedView(dialect, "mv"), query=_query(dialect), @@ -231,7 +231,7 @@ def test_generic_tablespace_rejected(self, dialect): assert "TABLESPACE" in str(exc.value) def test_generic_storage_options_rejected(self, dialect): - expr = CreateMaterializedViewExpression( + expr = CreateMaterializedViewStatement( dialect=dialect, view=MaterializedView(dialect, "mv"), query=_query(dialect), @@ -242,7 +242,7 @@ def test_generic_storage_options_rejected(self, dialect): def test_generic_with_data_rejected(self, dialect): """BigQuery populates at creation and has no ``WITH DATA`` clause.""" - expr = CreateMaterializedViewExpression( + expr = CreateMaterializedViewStatement( dialect=dialect, view=MaterializedView(dialect, "mv"), query=_query(dialect), @@ -253,7 +253,7 @@ def test_generic_with_data_rejected(self, dialect): assert "WITH DATA" in str(exc.value) def test_generic_with_no_data_rejected(self, dialect): - expr = CreateMaterializedViewExpression( + expr = CreateMaterializedViewStatement( dialect=dialect, view=MaterializedView(dialect, "mv"), query=_query(dialect), @@ -265,7 +265,7 @@ def test_generic_with_no_data_rejected(self, dialect): def test_generic_plain_expression_renders(self, dialect): """The pair defaults to neither, which is this grammar's shape.""" - expr = CreateMaterializedViewExpression( + expr = CreateMaterializedViewStatement( dialect=dialect, view=MaterializedView(dialect, "mv"), query=_query(dialect), @@ -275,7 +275,7 @@ def test_generic_plain_expression_renders(self, dialect): ) def test_drop_cascade_rejected(self, dialect): - expr = DropMaterializedViewExpression( + expr = DropMaterializedViewStatement( dialect=dialect, view=MaterializedView(dialect, "mv"), if_exists=True, @@ -287,7 +287,7 @@ def test_drop_cascade_rejected(self, dialect): def test_drop_restrict_rejected(self, dialect): """BigQuery's ``DROP MATERIALIZED VIEW`` has neither qualifier.""" - expr = DropMaterializedViewExpression( + expr = DropMaterializedViewStatement( dialect=dialect, view=MaterializedView(dialect, "mv"), if_exists=True, @@ -322,7 +322,7 @@ def test_restrict_rejected_at_construction(self, dialect): ) def test_generic_expression_renders(self, dialect): - expr = DropMaterializedViewExpression( + expr = DropMaterializedViewStatement( dialect=dialect, view=MaterializedView(dialect, "mv") ) assert expr.to_sql()[0] == "DROP MATERIALIZED VIEW `mv`" diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_expression_fields_match_formatters.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_expression_fields_match_formatters.py index 5dcc3f1..573e808 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_expression_fields_match_formatters.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_expression_fields_match_formatters.py @@ -52,12 +52,12 @@ def dialect(self): def test_create_materialized_view(self, dialect): """A class defined in this repository, qualified by a formatter here.""" - from rhosocial.activerecord.backend.expression import Column, QueryExpression + from rhosocial.activerecord.backend.expression import Column, QueryStatement from rhosocial.activerecord.backend.impl.bigquery.expression.materialized_view import ( BigQueryCreateMaterializedViewExpression, ) - query = QueryExpression(dialect, [Column(dialect, "id")], from_="orders") + query = QueryStatement(dialect, [Column(dialect, "id")], from_="orders") expr = BigQueryCreateMaterializedViewExpression( dialect, view=MaterializedView(dialect, "mv_orders"), query=query ) @@ -126,7 +126,7 @@ def test_each_mv_statement_refuses_a_table_in_place_of_the_view(self, dialect): stop, so each of the three single-object statements and the replica's two objects is asserted separately. """ - from rhosocial.activerecord.backend.expression import Column, QueryExpression + from rhosocial.activerecord.backend.expression import Column, QueryStatement from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.impl.bigquery.expression.materialized_view import ( BigQueryAlterMaterializedViewSetOptionsExpression, @@ -135,20 +135,20 @@ def test_each_mv_statement_refuses_a_table_in_place_of_the_view(self, dialect): BigQueryDropMaterializedViewExpression, ) - query = QueryExpression(dialect, [Column(dialect, "id")], from_="orders") + query = QueryStatement(dialect, [Column(dialect, "id")], from_="orders") wrong = Table(dialect, "orders") with pytest.raises(TypeError) as exc: BigQueryCreateMaterializedViewExpression( dialect, view=wrong, query=query ).to_sql() - assert "CreateMaterializedViewExpression.view must be a MaterializedView" in str( + assert "CreateMaterializedViewStatement.view must be a MaterializedView" in str( exc.value ) with pytest.raises(TypeError) as exc: BigQueryDropMaterializedViewExpression(dialect, view=wrong).to_sql() - assert "DropMaterializedViewExpression.view must be a MaterializedView" in str( + assert "DropMaterializedViewStatement.view must be a MaterializedView" in str( exc.value ) @@ -188,19 +188,19 @@ def test_create_schema_refuses_a_non_schema_object(self, dialect): """ from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( - CreateSchemaExpression, - DropSchemaExpression, + CreateSchemaStatement, + DropSchemaStatement, ) with pytest.raises(TypeError) as exc: - CreateSchemaExpression(dialect, Table(dialect, "orders")).to_sql() - assert "CreateSchemaExpression.schema must be a Schema, got Table" in str( + CreateSchemaStatement(dialect, Table(dialect, "orders")).to_sql() + assert "CreateSchemaStatement.schema must be a Schema, got Table" in str( exc.value ) with pytest.raises(TypeError) as exc: - DropSchemaExpression(dialect, Table(dialect, "orders")).to_sql() - assert "DropSchemaExpression.schema must be a Schema, got Table" in str( + DropSchemaStatement(dialect, Table(dialect, "orders")).to_sql() + assert "DropSchemaStatement.schema must be a Schema, got Table" in str( exc.value ) @@ -211,7 +211,7 @@ def test_all_three_mv_statements_render_one_carrier(self, dialect): reach one statement and miss the next. Building all three from one object is the assertion that the path is shared. """ - from rhosocial.activerecord.backend.expression import Column, QueryExpression + from rhosocial.activerecord.backend.expression import Column, QueryStatement from rhosocial.activerecord.backend.impl.bigquery.expression.materialized_view import ( BigQueryAlterMaterializedViewSetOptionsExpression, BigQueryCreateMaterializedViewExpression, @@ -219,7 +219,7 @@ def test_all_three_mv_statements_render_one_carrier(self, dialect): ) view = MaterializedView(dialect, "mv_orders", schema_name="app") - query = QueryExpression(dialect, [Column(dialect, "id")], from_="orders") + query = QueryStatement(dialect, [Column(dialect, "id")], from_="orders") rendered = [ BigQueryCreateMaterializedViewExpression(dialect, view=view, query=query) .to_sql()[0], @@ -238,10 +238,10 @@ def test_create_schema_carries_the_dataset_it_creates(self, dialect): thing that still matters: the formatter reads the field, the statement cannot be built without it, and the name reaches the SQL. """ - from rhosocial.activerecord.backend.expression import CreateSchemaExpression + from rhosocial.activerecord.backend.expression import CreateSchemaStatement from rhosocial.activerecord.backend.expression.objects import Schema - expr = CreateSchemaExpression(dialect, Schema(dialect, "app")) + expr = CreateSchemaStatement(dialect, Schema(dialect, "app")) assert expr.to_sql()[0] == "CREATE SCHEMA `app`", expr.to_sql()[0] def test_a_project_reaches_the_sql_through_the_object(self, dialect): @@ -251,7 +251,7 @@ def test_a_project_reaches_the_sql_through_the_object(self, dialect): project in ``catalog_name``. This asserts the outer slot is not dropped the way the inner one was before the outer namespace was declared. """ - from rhosocial.activerecord.backend.expression import Column, QueryExpression + from rhosocial.activerecord.backend.expression import Column, QueryStatement from rhosocial.activerecord.backend.impl.bigquery.expression.materialized_view import ( BigQueryDropMaterializedViewExpression, ) @@ -350,7 +350,7 @@ def test_qualified_expression_takes_a_view_object(self, import_path, class_name) def test_view_defaults_to_unqualified(self): """Where the object qualifies a name, no namespace has to mean unqualified. - ``CreateSchemaExpression`` is exempt on purpose: there the name *is* the + ``CreateSchemaStatement`` is exempt on purpose: there the name *is* the object, and requiring it is the correct shape. For the materialized views the namespace is optional, and "no dataset" is expressed by leaving the slot off the object rather than by passing ``None`` diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_capability_assertions.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_capability_assertions.py index 2a3e244..04e0765 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_capability_assertions.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_capability_assertions.py @@ -12,17 +12,17 @@ from rhosocial.activerecord.backend.expression import ( BinaryArithmeticExpression, Column, - CreateTableExpression, - CreateViewExpression, - QueryExpression, + CreateTableStatement, + CreateViewStatement, + QueryStatement, ) from rhosocial.activerecord.backend.expression.objects import Table, View from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, - DropTableExpression, + DropTableStatement, GeneratedColumnExpression, GeneratedColumnType, - TruncateExpression, + TruncateStatement, ) from rhosocial.activerecord.backend.expression.types import IntegerType from rhosocial.activerecord.backend.impl.bigquery.dialect import BigQueryDialect @@ -83,7 +83,7 @@ def test_generated_stored_column_renders(dialect): storage_type=GeneratedColumnType.STORED, ), ) - sql, _ = CreateTableExpression(dialect, Table(dialect, "t"), [column]).to_sql() + sql, _ = CreateTableStatement(dialect, Table(dialect, "t"), [column]).to_sql() assert "GENERATED ALWAYS AS (`a` + `b`) STORED" in sql @@ -99,7 +99,7 @@ def test_virtual_generated_column_rejected(dialect): ), ) with pytest.raises(UnsupportedFeatureError, match="VIRTUAL"): - CreateTableExpression(dialect, Table(dialect, "t"), [column]).to_sql() + CreateTableStatement(dialect, Table(dialect, "t"), [column]).to_sql() def test_truncate_and_drop_table(dialect): @@ -107,22 +107,22 @@ def test_truncate_and_drop_table(dialect): assert dialect.supports_if_exists_table() is True # The dataset rides in the table object's own slot, so an unqualified # truncate and a dataset-qualified one are one construction each. - assert TruncateExpression(dialect, Table(dialect, "t")).to_sql()[0] == ( + assert TruncateStatement(dialect, Table(dialect, "t")).to_sql()[0] == ( "TRUNCATE TABLE `t`" ) - assert TruncateExpression( + assert TruncateStatement( dialect, Table(dialect, "t", schema_name="app") ).to_sql()[0] == "TRUNCATE TABLE `app`.`t`" - assert DropTableExpression(dialect, Table(dialect, "t"), if_exists=True).to_sql()[0] == ( + assert DropTableStatement(dialect, Table(dialect, "t"), if_exists=True).to_sql()[0] == ( "DROP TABLE IF EXISTS `t`" ) def test_views_supported_and_render(dialect): assert dialect.supports_views() is True - query = QueryExpression( + query = QueryStatement( dialect, select=[Column(dialect, "id")], from_=Table(dialect, "t") ) - sql, _ = CreateViewExpression(dialect, view=View(dialect, "v"), query=query).to_sql() + sql, _ = CreateViewStatement(dialect, view=View(dialect, "v"), query=query).to_sql() assert sql.startswith("CREATE VIEW `v`") assert "SELECT `id` FROM `t`" in sql diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_clause_pair_guard.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_clause_pair_guard.py index 42067e8..65fd905 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_clause_pair_guard.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_clause_pair_guard.py @@ -42,20 +42,20 @@ Table, ) from rhosocial.activerecord.backend.expression.query_sources import ( - SetOperationExpression, + SetOperationStatement, ) from rhosocial.activerecord.backend.expression.statements.ddl_schema import ( - DropSchemaExpression, + DropSchemaStatement, ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ( IdentityClause, ) from rhosocial.activerecord.backend.expression.statements.ddl_view import ( - CreateMaterializedViewExpression, - DropMaterializedViewExpression, + CreateMaterializedViewStatement, + DropMaterializedViewStatement, ) from rhosocial.activerecord.backend.expression.statements.dql import ( - QueryExpression, + QueryStatement, ) from rhosocial.activerecord.backend.impl.bigquery.dialect import BigQueryDialect from rhosocial.activerecord.backend.impl.bigquery.expression import ( @@ -70,11 +70,11 @@ def dialect(): def _query(d, table="t"): - return QueryExpression(d, select=[Column(d, "a")], from_=Table(d, table)) + return QueryStatement(d, select=[Column(d, "a")], from_=Table(d, table)) class TestSetOperationQualifier: - """``SetOperationExpression.all_`` / ``distinct``. + """``SetOperationStatement.all_`` / ``distinct``. BigQuery's grammar has no bare set operator:: @@ -97,7 +97,7 @@ class TestSetOperationQualifier: """ def _expr(self, dialect, operation="UNION", **kwargs): - return SetOperationExpression( + return SetOperationStatement( dialect, left=_query(dialect, "t1"), right=_query(dialect, "t2"), @@ -213,7 +213,7 @@ def test_start_and_increment_still_render(self, dialect): class TestMaterializedViewCreateClauses: - """``CreateMaterializedViewExpression.with_data`` / ``no_data``. + """``CreateMaterializedViewStatement.with_data`` / ``no_data``. BigQuery's ``CREATE MATERIALIZED VIEW`` grammar has no ``WITH [NO] DATA`` clause -- the view is populated at creation time -- so both explicit @@ -222,7 +222,7 @@ class TestMaterializedViewCreateClauses: """ def _generic(self, dialect, **kwargs): - return CreateMaterializedViewExpression( + return CreateMaterializedViewStatement( dialect, view=MaterializedView(dialect, "mv"), query=_query(dialect), **kwargs ) @@ -256,7 +256,7 @@ def test_bigquery_subclass_renders_the_plain_form(self, dialect): class TestMaterializedViewDropClauses: - """``DropMaterializedViewExpression.cascade`` / ``restrict``. + """``DropMaterializedViewStatement.cascade`` / ``restrict``. BigQuery's ``DROP MATERIALIZED VIEW [IF EXISTS] mv_name`` grammar has no CASCADE and no RESTRICT, so both explicit spellings are refused: the @@ -265,7 +265,7 @@ class TestMaterializedViewDropClauses: """ def _generic(self, dialect, **kwargs): - return DropMaterializedViewExpression( + return DropMaterializedViewStatement( dialect, view=MaterializedView(dialect, "mv"), **kwargs ) @@ -304,7 +304,7 @@ def test_bigquery_subclass_renders_the_plain_form(self, dialect): class TestDropSchemaClauses: - """``DropSchemaExpression.cascade`` / ``restrict``. + """``DropSchemaStatement.cascade`` / ``restrict``. BigQuery's grammar is ``DROP SCHEMA [IF EXISTS] name [CASCADE | RESTRICT]`` with RESTRICT as the default; both spellings are therefore expressible @@ -313,7 +313,7 @@ class TestDropSchemaClauses: """ def _expr(self, dialect, **kwargs): - return DropSchemaExpression(dialect, Schema(dialect, "s"), **kwargs) + return DropSchemaStatement(dialect, Schema(dialect, "s"), **kwargs) def test_neither_renders(self, dialect): assert self._expr(dialect).to_sql()[0] == "DROP SCHEMA `s`" 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 d528449..6e1c7e2 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 @@ -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 @@ -84,7 +84,7 @@ def test_schema_cascade_and_restrict_supported(self): class TestBigQueryTableDeclarationGating: def test_table_declaration_defaults_are_absent(self): dialect = BigQueryDialect() - expression = CreateTableExpression( + expression = CreateTableStatement( dialect, Table(dialect, "plain_table_defaults"), [ColumnDefinition(dialect, "id", IntegerType(dialect))], @@ -99,7 +99,7 @@ def test_table_declaration_defaults_are_absent(self): def test_table_inherits_is_propagated_and_rejected(self): dialect = BigQueryDialect() assert dialect.supports_table_inheritance() is False - expression = CreateTableExpression( + expression = CreateTableStatement( dialect, Table(dialect, "inherited"), [ColumnDefinition(dialect, "id", IntegerType(dialect))], @@ -112,7 +112,7 @@ def test_table_inherits_is_propagated_and_rejected(self): def test_table_tablespace_is_propagated_and_rejected(self): dialect = BigQueryDialect() 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_bigquery_test/feature/backend/test_bigquery_master_probe_guard.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_master_probe_guard.py index 6759263..edff05e 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_master_probe_guard.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_master_probe_guard.py @@ -8,13 +8,13 @@ = =================================== ========================================== # probe now consulted by = =================================== ========================================== -1 ``supports_materialized_cte()`` ``format_cte_expression`` +1 ``supports_materialized_cte()`` ``format_cte_clause`` 2 ``supports_truncate()`` ``format_truncate_statement`` 3 ``supports_with_data_clause()`` ``WITH [NO] DATA`` on CTAS, CREATE MATERIALIZED VIEW and REFRESH MATERIALIZED VIEW 4 ``supports_transaction_wait()`` the ``wait`` / ``no_wait`` pair on - ``BeginTransactionExpression`` and + ``BeginTransactionStatement`` and ``SetTransactionExpression`` = =================================== ========================================== @@ -62,23 +62,23 @@ Table, ) from rhosocial.activerecord.backend.expression.query_sources import ( - CTEExpression, + CTEClause, ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - CreateTableAsExpression, + CreateTableAsStatement, ) from rhosocial.activerecord.backend.expression.statements.ddl_truncate import ( - TruncateExpression, + TruncateStatement, ) from rhosocial.activerecord.backend.expression.statements.ddl_view import ( - CreateMaterializedViewExpression, - RefreshMaterializedViewExpression, + CreateMaterializedViewStatement, + RefreshMaterializedViewStatement, ) from rhosocial.activerecord.backend.expression.statements.dql import ( - QueryExpression, + QueryStatement, ) from rhosocial.activerecord.backend.expression.transaction import ( - BeginTransactionExpression, + BeginTransactionStatement, SetTransactionExpression, ) from rhosocial.activerecord.backend.impl.bigquery.dialect import BigQueryDialect @@ -90,7 +90,7 @@ def dialect(): def _query(d, table="t"): - return QueryExpression(d, select=[Column(d, "a")], from_=Table(d, table)) + return QueryStatement(d, select=[Column(d, "a")], from_=Table(d, table)) class TestProbeDeclarations: @@ -116,14 +116,14 @@ def test_transaction_wait_probe_is_declared_false(self, dialect): class TestMaterializedCteGate: - """``CTEExpression.materialized`` / ``not_materialized``: gate 1. + """``CTEClause.materialized`` / ``not_materialized``: gate 1. The documented ``WITH`` grammar has no hint, so both spellings are refused by name and the unspecified state renders the plain CTE. """ def _cte(self, dialect, **kwargs): - return CTEExpression(dialect, "c", _query(dialect), **kwargs) + return CTEClause(dialect, "c", _query(dialect), **kwargs) def test_neither_renders_the_plain_cte(self, dialect): sql, params = self._cte(dialect).to_sql() @@ -150,14 +150,14 @@ def test_both_parameters_are_api_misuse(self, dialect): class TestTruncateGate: - """``TruncateExpression``: gate 2. + """``TruncateStatement``: gate 2. The probe is True because the DML reference documents the statement, so the plain render stays reachable; the option pairs stay refused by name. """ def test_truncate_renders(self, dialect): - sql, params = TruncateExpression(dialect, Table(dialect, "t")).to_sql() + sql, params = TruncateStatement(dialect, Table(dialect, "t")).to_sql() assert sql == "TRUNCATE TABLE `t`", sql assert params == () @@ -169,19 +169,19 @@ def test_option_pairs_are_refused_by_name(self, dialect): ({"restrict": True}, "TRUNCATE RESTRICT"), ): with pytest.raises(UnsupportedFeatureError) as exc: - TruncateExpression(dialect, Table(dialect, "t"), **kwargs).to_sql() + TruncateStatement(dialect, Table(dialect, "t"), **kwargs).to_sql() assert exc.value.feature_name == feature def test_option_pairs_are_api_misuse_together(self, dialect): with pytest.raises(ValueError, match="restart_identity and continue_identity"): - TruncateExpression( + TruncateStatement( dialect, Table(dialect, "t"), restart_identity=True, continue_identity=True, ) with pytest.raises(ValueError, match="cascade and restrict"): - TruncateExpression( + TruncateStatement( dialect, Table(dialect, "t"), cascade=True, restrict=True ) @@ -199,7 +199,7 @@ class TestWithDataGate: """ def test_ctas_neither_renders(self, dialect): - sql, params = CreateTableAsExpression( + sql, params = CreateTableAsStatement( dialect, Table(dialect, "t"), _query(dialect) ).to_sql() assert sql == "CREATE TABLE `t` AS SELECT `a` FROM `t`", sql @@ -207,7 +207,7 @@ def test_ctas_neither_renders(self, dialect): def test_ctas_with_data_is_refused_by_name(self, dialect): with pytest.raises(UnsupportedFeatureError) as exc: - CreateTableAsExpression( + CreateTableAsStatement( dialect, Table(dialect, "t"), _query(dialect), with_data=True ).to_sql() assert exc.value.feature_name == "WITH DATA" @@ -215,7 +215,7 @@ def test_ctas_with_data_is_refused_by_name(self, dialect): def test_ctas_no_data_is_refused_by_name(self, dialect): with pytest.raises(UnsupportedFeatureError) as exc: - CreateTableAsExpression( + CreateTableAsStatement( dialect, Table(dialect, "t"), _query(dialect), no_data=True ).to_sql() assert exc.value.feature_name == "WITH NO DATA" @@ -225,7 +225,7 @@ def test_ctas_both_parameters_are_api_misuse(self, dialect): with pytest.raises( ValueError, match="with_data and no_data are mutually exclusive" ): - CreateTableAsExpression( + CreateTableAsStatement( dialect, Table(dialect, "t"), _query(dialect), @@ -240,14 +240,14 @@ def test_ctas_neither_is_gated_by_this_dialect_probe(self, dialect): CTAS could raise for an unrelated reason while these tests still passed. """ assert dialect.supports_with_data_clause() is False - sql = CreateTableAsExpression( + sql = CreateTableAsStatement( dialect, Table(dialect, "t"), _query(dialect) ).to_sql()[0] assert "WITH" not in sql.upper().replace("WITHOUT", "") def test_mv_create_with_data_is_refused_by_name(self, dialect): with pytest.raises(UnsupportedFeatureError) as exc: - CreateMaterializedViewExpression( + CreateMaterializedViewStatement( dialect, view=MaterializedView(dialect, "mv"), query=_query(dialect), @@ -258,7 +258,7 @@ def test_mv_create_with_data_is_refused_by_name(self, dialect): def test_mv_create_no_data_is_refused_by_name(self, dialect): with pytest.raises(UnsupportedFeatureError) as exc: - CreateMaterializedViewExpression( + CreateMaterializedViewStatement( dialect, view=MaterializedView(dialect, "mv"), query=_query(dialect), @@ -268,7 +268,7 @@ def test_mv_create_no_data_is_refused_by_name(self, dialect): assert exc.value.dialect_name == "BigQuery" def test_mv_create_neither_renders(self, dialect): - sql = CreateMaterializedViewExpression( + sql = CreateMaterializedViewStatement( dialect, view=MaterializedView(dialect, "mv"), query=_query(dialect) ).to_sql()[0] assert sql == "CREATE MATERIALIZED VIEW `mv` AS SELECT `a` FROM `t`", sql @@ -282,7 +282,7 @@ def test_mv_refresh_is_refused_at_the_statement(self, dialect): assert dialect.supports_refresh_materialized_view() is False for kwargs in ({}, {"with_data": True}, {"no_data": True}): with pytest.raises(UnsupportedFeatureError) as exc: - RefreshMaterializedViewExpression( + RefreshMaterializedViewStatement( dialect, view=MaterializedView(dialect, "mv"), **kwargs ).to_sql() assert exc.value.feature_name == "REFRESH MATERIALIZED VIEW" @@ -303,25 +303,25 @@ class TestTransactionWaitPair: """ def test_begin_neither_renders(self, dialect): - sql, params = BeginTransactionExpression(dialect).to_sql() + sql, params = BeginTransactionStatement(dialect).to_sql() assert sql == "BEGIN", sql assert params == () def test_begin_wait_is_refused_by_name(self, dialect): with pytest.raises(UnsupportedFeatureError) as exc: - BeginTransactionExpression(dialect, wait=True).to_sql() + BeginTransactionStatement(dialect, wait=True).to_sql() assert exc.value.feature_name == "transaction WAIT" assert exc.value.dialect_name == "BigQuery" def test_begin_no_wait_is_refused_by_name(self, dialect): with pytest.raises(UnsupportedFeatureError) as exc: - BeginTransactionExpression(dialect, no_wait=True).to_sql() + BeginTransactionStatement(dialect, no_wait=True).to_sql() assert exc.value.feature_name == "transaction NO WAIT" assert exc.value.dialect_name == "BigQuery" def test_begin_both_parameters_are_api_misuse(self, dialect): with pytest.raises(ValueError, match="wait and no_wait are mutually exclusive"): - BeginTransactionExpression(dialect, wait=True, no_wait=True) + BeginTransactionStatement(dialect, wait=True, no_wait=True) def test_set_transaction_neither_keeps_the_statement_refusal(self, dialect): """The pinned round-trip classification: no SET TRANSACTION at all.""" @@ -355,8 +355,8 @@ def test_probe_and_refusals_agree(self, dialect): """ assert dialect.supports_transaction_wait() is False for expr in ( - BeginTransactionExpression(dialect, wait=True), - BeginTransactionExpression(dialect, no_wait=True), + BeginTransactionStatement(dialect, wait=True), + BeginTransactionStatement(dialect, no_wait=True), SetTransactionExpression(dialect, wait=True), SetTransactionExpression(dialect, no_wait=True), ): @@ -380,11 +380,11 @@ def supports_transaction_wait(self) -> bool: waits = _Waits() assert waits.supports_transaction_wait() is True - assert BeginTransactionExpression(waits, wait=True).to_sql() == ( + assert BeginTransactionStatement(waits, wait=True).to_sql() == ( "BEGIN WAIT", (), ) - assert BeginTransactionExpression(waits, no_wait=True).to_sql() == ( + assert BeginTransactionStatement(waits, no_wait=True).to_sql() == ( "BEGIN NO WAIT", (), ) diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py index 6ef4c33..6059901 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py @@ -105,7 +105,7 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression import graph as graph_mod from rhosocial.activerecord.backend.expression.advanced_functions import ( - CaseExpression, + CaseFunction, WindowClause, WindowDefinition, WindowSpecification, @@ -182,7 +182,7 @@ 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, @@ -275,7 +275,7 @@ def _column_predicate(dialect): def _one_column_query(dialect): """A single-column ``SELECT`` over a table.""" - return QueryExpression(dialect, select=[Column(dialect, "id")], from_=_table(dialect)) + return QueryStatement(dialect, select=[Column(dialect, "id")], from_=_table(dialect)) def _integer_column(dialect, name="col"): @@ -307,34 +307,34 @@ def register_specials(): # -- tables ------------------------------------------------------------- register_special_constructor( - "statements.ddl_table.CreateTableExpression", - lambda d: ddl_table.CreateTableExpression(d, _table(d), [_integer_column(d)]), + "statements.ddl_table.CreateTableStatement", + lambda d: ddl_table.CreateTableStatement(d, _table(d), [_integer_column(d)]), ) register_special_constructor( - "statements.ddl_table.DropTableExpression", - lambda d: ddl_table.DropTableExpression(d, _table(d)), + "statements.ddl_table.DropTableStatement", + lambda d: ddl_table.DropTableStatement(d, _table(d)), ) register_special_constructor( - "statements.ddl_table.CreateTableLikeExpression", - lambda d: ddl_table.CreateTableLikeExpression(d, _table(d), Table(d, "other")), + "statements.ddl_table.CreateTableLikeStatement", + lambda d: ddl_table.CreateTableLikeStatement(d, _table(d), Table(d, "other")), ) register_special_constructor( - "statements.ddl_table.CreateTableCloneExpression", - lambda d: ddl_table.CreateTableCloneExpression(d, _table(d), Table(d, "other")), + "statements.ddl_table.CreateTableCloneStatement", + lambda d: ddl_table.CreateTableCloneStatement(d, _table(d), Table(d, "other")), ) register_special_constructor( - "statements.ddl_table.CreateTableAsExpression", - lambda d: ddl_table.CreateTableAsExpression(d, _table(d), _one_column_query(d)), + "statements.ddl_table.CreateTableAsStatement", + lambda d: ddl_table.CreateTableAsStatement(d, _table(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(d), _one_column_query(d) ), ) register_special_constructor( - "statements.ddl_truncate.TruncateExpression", - lambda d: ddl_truncate.TruncateExpression(d, _table(d)), + "statements.ddl_truncate.TruncateStatement", + lambda d: ddl_truncate.TruncateStatement(d, _table(d)), ) # Overrides the shared testsuite factory, which binds no dialect to its type. register_special_constructor( @@ -372,24 +372,24 @@ def register_specials(): # -- indexes, and the ALTER TABLE actions over them --------------------- 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(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(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(d) ), ) @@ -431,36 +431,36 @@ def register_specials(): # -- 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, @@ -468,16 +468,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")] ), ) @@ -496,18 +496,18 @@ def register_specials(): # -- 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(d), @@ -517,52 +517,52 @@ 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(d), comment="c"), + "statements.ddl_comment.CommentOnStatement", + lambda d: ddl_comment.CommentOnStatement(d, "table", _table(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( + "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(d), source=dml.ValuesSource(d, [[Literal(d, 1)]]) ), ) register_special_constructor( - "statements.dml.DeleteExpression", - lambda d: dml.DeleteExpression(d, _table(d)), + "statements.dml.DeleteStatement", + lambda d: dml.DeleteStatement(d, _table(d)), ) # The guess gives MERGE a bare string for its row source and no matched # action, and the action itself a sequence with no alias. register_special_constructor( - "statements.dml.MergeExpression", - lambda d: dml.MergeExpression( + "statements.dml.MergeStatement", + lambda d: dml.MergeStatement( d, target_table=_table(d), source=NamedRelationRef(d, Table(d, "src")), @@ -591,8 +591,8 @@ def register_specials(): # -- expressions that must not be empty --------------------------------- register_special_constructor( - "advanced_functions.CaseExpression", - lambda d: CaseExpression( + "advanced_functions.CaseFunction", + lambda d: CaseFunction( d, cases=[(_column_predicate(d), Literal(d, 1))], else_result=Literal(d, 0), @@ -681,16 +681,16 @@ def path_pattern(d): ), ) register_special_constructor( - "graph.CreatePropertyGraphExpression", - lambda d: graph_mod.CreatePropertyGraphExpression( + "graph.CreatePropertyGraphStatement", + lambda d: graph_mod.CreatePropertyGraphStatement( d, graph=PropertyGraph(d, "g"), vertex_tables=[graph_mod.VertexTable(d, node_table(d))] ), ) # The formatter accepts "add"/"drop" against "vertex tables"/"edge tables"/ # "tables"; anything else is refused. register_special_constructor( - "graph.AlterPropertyGraphExpression", - lambda d: graph_mod.AlterPropertyGraphExpression( + "graph.AlterPropertyGraphStatement", + lambda d: graph_mod.AlterPropertyGraphStatement( d, graph=PropertyGraph(d, "g"), action="add", @@ -699,8 +699,8 @@ def path_pattern(d): ), ) register_special_constructor( - "graph.DropPropertyGraphExpression", - lambda d: graph_mod.DropPropertyGraphExpression(d, graph=PropertyGraph(d, "g")), + "graph.DropPropertyGraphStatement", + lambda d: graph_mod.DropPropertyGraphStatement(d, graph=PropertyGraph(d, "g")), ) # -- SQL/XML ------------------------------------------------------------ @@ -763,7 +763,7 @@ def path_pattern(d): # rather than letting one of them fail it. "rhosocial.activerecord.backend.expression.uuid.UUIDConstantExpression", "rhosocial.activerecord.backend.expression.uuid.UUIDGenerationExpression", - "rhosocial.activerecord.backend.expression.uuid.UUIDCastExpression", + "rhosocial.activerecord.backend.expression.uuid.UUIDCastFunction", # CUSTOM type. `raw` names the SQL type string and defaults to the empty # string, so the filler skips it and the class validates that empty name # and raises InvalidTypeNameError while constructing. A registered @@ -824,8 +824,8 @@ def path_pattern(d): "rhosocial.activerecord.backend.expression.xml.XMLElementExpression": ( UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_xmlelement_expression'", ), - "rhosocial.activerecord.backend.expression.xml.XMLExistsExpression": ( - UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_xmlexists_expression'", + "rhosocial.activerecord.backend.expression.xml.XMLExistsPredicate": ( + UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_xmlexists_predicate'", ), "rhosocial.activerecord.backend.expression.xml.XMLForestExpression": ( UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_xmlforest_expression'", @@ -849,13 +849,13 @@ def path_pattern(d): UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_xmltable_expression'", ), # ---- SQL/PGQ property graphs: no CREATE PROPERTY GRAPH ------------------ - "rhosocial.activerecord.backend.expression.graph.CreatePropertyGraphExpression": ( + "rhosocial.activerecord.backend.expression.graph.CreatePropertyGraphStatement": ( UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_create_property_graph_statement'", ), - "rhosocial.activerecord.backend.expression.graph.AlterPropertyGraphExpression": ( + "rhosocial.activerecord.backend.expression.graph.AlterPropertyGraphStatement": ( UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_alter_property_graph_statement'", ), - "rhosocial.activerecord.backend.expression.graph.DropPropertyGraphExpression": ( + "rhosocial.activerecord.backend.expression.graph.DropPropertyGraphStatement": ( UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_drop_property_graph_statement'", ), "rhosocial.activerecord.backend.expression.graph.GraphTableExpression": ( @@ -889,38 +889,38 @@ def path_pattern(d): UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_table_properties_clause'", ), # ---- routines, triggers, sequences, databases: none exist -------------- - "rhosocial.activerecord.backend.expression.statements.ddl_function.CreateFunctionExpression": ( + "rhosocial.activerecord.backend.expression.statements.ddl_function.CreateFunctionStatement": ( UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_create_function_statement'", ), - "rhosocial.activerecord.backend.expression.statements.ddl_function.DropFunctionExpression": ( + "rhosocial.activerecord.backend.expression.statements.ddl_function.DropFunctionStatement": ( UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_drop_function_statement'", ), - "rhosocial.activerecord.backend.expression.statements.ddl_trigger.CreateTriggerExpression": ( + "rhosocial.activerecord.backend.expression.statements.ddl_trigger.CreateTriggerStatement": ( UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_create_trigger_statement'", ), - "rhosocial.activerecord.backend.expression.statements.ddl_trigger.DropTriggerExpression": ( + "rhosocial.activerecord.backend.expression.statements.ddl_trigger.DropTriggerStatement": ( UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_drop_trigger_statement'", ), - "rhosocial.activerecord.backend.expression.statements.ddl_sequence.CreateSequenceExpression": ( + "rhosocial.activerecord.backend.expression.statements.ddl_sequence.CreateSequenceStatement": ( UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_create_sequence_statement'", ), - "rhosocial.activerecord.backend.expression.statements.ddl_sequence.AlterSequenceExpression": ( + "rhosocial.activerecord.backend.expression.statements.ddl_sequence.AlterSequenceStatement": ( UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_alter_sequence_statement'", ), - "rhosocial.activerecord.backend.expression.statements.ddl_sequence.DropSequenceExpression": ( + "rhosocial.activerecord.backend.expression.statements.ddl_sequence.DropSequenceStatement": ( UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_drop_sequence_statement'", ), - "rhosocial.activerecord.backend.expression.statements.ddl_database.CreateDatabaseExpression": ( + "rhosocial.activerecord.backend.expression.statements.ddl_database.CreateDatabaseStatement": ( UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_create_database_statement'", ), - "rhosocial.activerecord.backend.expression.statements.ddl_database.AlterDatabaseExpression": ( + "rhosocial.activerecord.backend.expression.statements.ddl_database.AlterDatabaseStatement": ( UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_alter_database_statement'", ), - "rhosocial.activerecord.backend.expression.statements.ddl_database.DropDatabaseExpression": ( + "rhosocial.activerecord.backend.expression.statements.ddl_database.DropDatabaseStatement": ( UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_drop_database_statement'", ), # ---- COMMENT ON, PARTITION clause, PIVOT, ILIKE ------------------------- - "rhosocial.activerecord.backend.expression.statements.ddl_comment.CommentOnExpression": ( + "rhosocial.activerecord.backend.expression.statements.ddl_comment.CommentOnStatement": ( UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_comment_statement'", ), "rhosocial.activerecord.backend.expression.statements.ddl_partition.PartitionClause": ( @@ -932,8 +932,8 @@ def path_pattern(d): "rhosocial.activerecord.backend.expression.pivot.UnpivotExpression": ( UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_unpivot_expression'", ), - "rhosocial.activerecord.backend.expression.predicates.ILIKEExpression": ( - UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_ilike_expression'", + "rhosocial.activerecord.backend.expression.predicates.ILikePredicate": ( + UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_ilike_predicate'", ), # ---- time travel: a BigQuery FROM names no temporal version ------------ "rhosocial.activerecord.backend.expression.datetime.TemporalOptionsExpression": ( @@ -1005,8 +1005,8 @@ def path_pattern(d): LEGITIMATE_NOT_IMPLEMENTED = { # ---- bases that name an expression category, not a renderable thing ---- "rhosocial.activerecord.backend.expression.bases.SQLPredicate": _NO_DISPATCH, - "rhosocial.activerecord.backend.expression.bases.SQLValueExpression": _NO_DISPATCH, - "rhosocial.activerecord.backend.expression.datetime._TemporalValueExpression": ( + "rhosocial.activerecord.backend.expression.bases.SQLValue": _NO_DISPATCH, + "rhosocial.activerecord.backend.expression.datetime._TemporalValue": ( _NO_DISPATCH ), "rhosocial.activerecord.backend.expression.introspection.IntrospectionExpression": ( @@ -1103,7 +1103,7 @@ def _dispatched_formatter(exc): "format_xmlcomment_expression", "format_xmlconcat_expression", "format_xmlelement_expression", - "format_xmlexists_expression", + "format_xmlexists_predicate", "format_xmlforest_expression", "format_xmlpi_expression", "format_xmlparse_expression", @@ -1157,7 +1157,7 @@ def _dispatched_formatter(exc): "format_unpivot_expression", ), "ILIKE (BigQuery has no ILIKE; case-insensitive matching goes through " - "LOWER() comparisons)": ("format_ilike_expression",), + "LOWER() comparisons)": ("format_ilike_predicate",), "the core temporal-options clause (a BigQuery FROM names no temporal " "version; time travel is spelled FOR SYSTEM_TIME AS OF, which the dialect " "renders elsewhere)": ("format_temporal_options",), From af92c97d9124327b886de36c435597fc64421728 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 15 Oct 2026 01:25:39 +0800 Subject: [PATCH 04/35] refactor(bigquery): migrate constraints onto the two-level branch nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core's S3 wave replaced the fat constraint nodes with a production plus a branch: ``ColumnConstraint(branch=...)`` / ``TableConstraint(branch=...)`` name the branch, the branch names the kind, and the ``*ConstraintType`` enums are gone. This commit migrates the construction sites this backend owns and folds in the constraint capability mixin the B-series draft left uncommitted -- its grammar evidence, its local declarations and its (self) probes. Construction sites migrated (src + tests): test_ddl_source_mixin.py UseConstraint(ColumnConstraintType.DEFAULT, ...) -> UseConstraint(ColumnDefaultConstraint, value=...) the same for UNIQUE / CHECK; the table constraints become TableUniqueConstraint / TableCheckConstraint branches; the kind assertions read ``type(item.branch)`` instead of the removed ``constraint_type`` tag. test_bigquery_capability_assertions.py GeneratedColumnExpression (deleted from core) -> ColumnGeneratedConstraint, which is the same production with one node. test_expression_roundtrip_all.py the ColumnConstraint / TableConstraint / AddTableConstraint / AlterConstraint constructors move to ``branch=`` and ``constraint_kind=``. No construction site in ``src`` needed migrating: this backend builds no constraint node itself (S3 construction points in src: 0). The mixin. ``mixins/constraint.py`` is the constraint capability declaration the draft wrote before S3 and never committed. It is kept whole -- the grammar transcription, the 文档(待云验) evidence level, the one-notch finding and the switch values are all preserved verbatim -- and rewritten onto the new API: * both probes converge to ``(self)``. A probe answers whether the clause family exists on this dialect; the kind is not part of the question, so the ``constraint_type`` parameter (and the docstring passage that had it routed through core's deleted ``_call_constraint_capability``) are gone. * the per-kind answers move to the render path, where the branch class is the only thing that can name a kind. Two ``_`` helpers carry them -- ``_require_bigquery_table_kind`` (CHECK / UNIQUE have no table production) and ``_require_bigquery_enforcement`` (the marker rides PK / FK; ``ENFORCED`` does not exist here at all) -- and they are called from two new *public* overrides, ``format_column_constraint`` and ``format_table_constraint``. No private formatter is added. * the gap list is rewritten against what S3 actually did: the primary-key marker is now reachable (the old ``ValueError`` that made it impossible is gone), so what remains is that the marker is still opt-in (a core printing decision) and the explicit ``NULL`` modifier is unguarded. Wiring. ``BigQueryDialect`` gains ``BigQueryConstraintMixin`` twice (import and MRO) ahead of core's ``ConstraintMixin`` / ``DDLColumnMixin``, so a moved core default cannot answer instead of the local declaration; several values differ (``CHECK``, ``UNIQUE``, ``MATCH``, ``DEFERRABLE``, ``ON DELETE``, ``ON UPDATE`` are False here and True there). The ``OPTIONS(description=)`` column-comment path is untouched: it is ``supports_column_comment``, a separate capability, and it already answers True. Behaviour changes, all reached through the public formatters: * a column `CHECK` / `UNIQUE` was refused already (the local False pins); a *table-level* CHECK / UNIQUE is now refused by name too, where core gates no per-kind table switch and would previously have rendered it; * ``enforced=True`` (any kind, either level) is refused by name, because BigQuery has no bare ``ENFORCED`` token; * a BigQuery primary key can now carry its compulsory ``NOT ENFORCED`` (it could not before: core's per-kind ``ValueError`` turned the request away). Verification: * tests/.../feature/backend/ddl/test_ddl_source_mixin.py, exit 0 * tests/.../feature/backend/test_bigquery_capability_assertions.py, exit 0 * tests/.../feature/backend/ddl/test_type_domain_negative.py, exit 0 * testsuite feature/ddl shared contract (run from the core worktree), exit 0 * import smoke over every module in the backend package: 33 ok / 0 failing The switch-contract tests (value table, evidence markers, declaration and ``(self)`` guards, the render-path narrowing assertions) follow in the next commit; per the wave guide the constraint mixin's code and its probes are one unit, so only the contract test is held back. --- .../backend/impl/bigquery/dialect.py | 18 +- .../backend/impl/bigquery/mixins/__init__.py | 2 + .../impl/bigquery/mixins/constraint.py | 1408 +++++++++++++++++ .../backend/impl/bigquery/mixins/ddl.py | 52 + .../backend/ddl/test_ddl_source_mixin.py | 96 +- .../test_bigquery_capability_assertions.py | 6 +- .../backend/test_expression_roundtrip_all.py | 20 +- 7 files changed, 1554 insertions(+), 48 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/bigquery/mixins/constraint.py diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/dialect.py b/src/rhosocial/activerecord/backend/impl/bigquery/dialect.py index a937311..417dfce 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/dialect.py @@ -71,6 +71,15 @@ BigQuerySchemaMixin, BigQueryNamespaceMixin, BigQueryDDLColumnMixin, + # Constraint capability switches, all declared locally and all read off the + # official ``column_schema`` / ``constraint_definition`` / ``ALTER TABLE`` + # grammar (grammar-only: no BigQuery instance is reachable and the CI + # emulator implements neither ALTER constraint statement). Before this + # mixin the backend declared none of them and inherited core's, whose + # defaults say ``True`` for CHECK, UNIQUE, MATCH, DEFERRABLE and both + # referential actions -- all of which BigQuery's grammar rejects. Ahead of + # core's ConstraintMixin and DDLColumnMixin so those defaults cannot answer. + BigQueryConstraintMixin, BigQueryCapabilityMixin, BigQueryColumnTypeMixin, BigQueryIdentityColumnMixin, @@ -97,6 +106,13 @@ class BigQueryDialect( BigQueryDQLMixin, BigQuerySchemaMixin, BigQueryDDLColumnMixin, + # Constraint capability switches. Ahead of core's ConstraintMixin and + # DDLColumnMixin below: every constraint answer BigQuery gives is declared + # in this package with the official grammar it rests on, so a moved core + # default cannot silently become BigQuery's contract. Several values + # differ from those defaults -- CHECK, UNIQUE, MATCH, DEFERRABLE, ON DELETE, + # ON UPDATE are all False here and True there. + BigQueryConstraintMixin, # Identity is a parameterised column clause, so its spelling lives with the # column DDL mixin and ahead of core's IdentityColumnMixin. BigQuery's # grammar makes the option parentheses literal; the probes come from the @@ -195,7 +211,7 @@ def get_type_mappings(self) -> Dict[str, Any]: "DECIMAL": "BIGNUMERIC", } - # -- CREATE TABLE diff (CreateTableExpressionDiffSupport hooks) ----------- + # -- CREATE TABLE diff (the ALTER TABLE probes core consults) ------------- def supports_alter_column_type(self) -> bool: """BigQuery cannot change a column type in place.""" diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/__init__.py index 3268cdc..127e608 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/__init__.py @@ -6,6 +6,7 @@ from .dql import BigQueryDQLMixin from .schema import BigQuerySchemaMixin from .ddl import BigQueryDDLColumnMixin +from .constraint import BigQueryConstraintMixin from .capabilities import BigQueryCapabilityMixin from .column_type import BigQueryColumnTypeMixin from .identity_column import BigQueryIdentityColumnMixin @@ -41,6 +42,7 @@ def supports_geography(self) -> bool: 'BigQueryDQLMixin', 'BigQuerySchemaMixin', 'BigQueryDDLColumnMixin', + 'BigQueryConstraintMixin', 'BigQueryCapabilityMixin', 'BigQueryColumnTypeMixin', 'BigQueryIdentityColumnMixin', diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/constraint.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/constraint.py new file mode 100644 index 0000000..74f658a --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/constraint.py @@ -0,0 +1,1408 @@ +# src/rhosocial/activerecord/backend/impl/bigquery/mixins/constraint.py +"""BigQuery 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 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 BigQuery's contract with it. A declaration here is the pin. Before +this module the backend declared **zero** constraint switches, so every value +below was whatever core happened to say -- and core defaults most of them to +``True``, which is the opposite of what BigQuery's grammar allows for ``CHECK``, +``UNIQUE``, ``MATCH``, ``DEFERRABLE`` and both referential actions. + +Evidence level: 文档(待云验) +---------------------------- +**Every value below is official-grammar-only. Nothing here was measured.** +There is no BigQuery instance reachable from this repository's test +environment -- no cloud credentials exist -- and the emulator CI uses +(``goccy/bigquery-emulator`` on ``localhost:9050``) does **not** implement the +``ALTER TABLE ADD FOREIGN KEY`` or ``ALTER TABLE DROP CONSTRAINT`` statements at +all, so it cannot adjudicate these switches either. A ``待云验`` marker means +"a real BigQuery run must confirm this before a caller relies on it"; the +contract tests assert the *declaration*, never the server's agreement with it. + +Source, transcribed 2026-10-12 from the one page every production below is on: + + https://cloud.google.com/bigquery/docs/reference/standard-sql/data-definition-language + +Column level, verbatim:: + + column_schema := + { simple_type | STRUCT | ARRAY } + [PRIMARY KEY NOT ENFORCED | REFERENCES table_name(column_name) NOT ENFORCED] + [ DEFAULT default_expression | embedding_generation | identity_column ] + [NOT NULL] + [OPTIONS(column_option_list)] + +Table level, verbatim:: + + CREATE TABLE [ OR REPLACE ] [ TEMP | TEMPORARY ] TABLE [ IF NOT EXISTS ] + table_name + [ ( column | constraint_definition[, ...] ) ] ... + + constraint_definition := + [primary_key] + | [[CONSTRAINT constraint_name] foreign_key, ...] + + primary_key := PRIMARY KEY (column_name[, ...]) NOT ENFORCED + foreign_key := FOREIGN KEY (column_name[, ...]) foreign_reference + foreign_reference := + REFERENCES primary_key_table(column_name[, ...]) NOT ENFORCED + +ALTER TABLE, verbatim -- these two statements are the **only** constraint +operations the page defines:: + + ALTER TABLE [[project_name.]dataset_name.]fk_table_name + ADD [CONSTRAINT [IF NOT EXISTS] constraint_name] FOREIGN KEY (fk_column_name[, ...]) + REFERENCES pk_table_name(pk_column_name[,...]) NOT ENFORCED + [ADD...]; + + ALTER TABLE [[project_name.]dataset_name.]table_name + DROP CONSTRAINT [IF EXISTS] constraint_name; + +Three facts about BigQuery that invert what the SQL-standard backends do +----------------------------------------------------------------------------- + +**1. The column level has exactly two constraint kinds, and neither can be +named or enforced.** ``PRIMARY KEY`` and ``REFERENCES`` are the whole of it; +there is no ``CHECK``, no ``UNIQUE``, and -- unlike every other backend in this +group -- **no ``CONSTRAINT `` keyword at column level at all**. The +``NOT ENFORCED`` token is written into both productions, not into an optional +``[NOT]``-alternatives bracket, so it is *mandatory and unforgiving*: there is +no spelling of a BigQuery primary key or foreign key that omits it. + +**2. At table level only a FOREIGN KEY can be named.** ``primary_key`` appears +bare in ``constraint_definition`` -- the page fetched on 2026-10-12 has no +``[CONSTRAINT constraint_name]`` in front of it -- while the foreign-key branch +reads ``[[CONSTRAINT constraint_name] foreign_key, ...]``. The page's own +prose agrees about enforcement: *"BigQuery only supports unenforced primary +keys"* and *"BigQuery only supports unenforced foreign keys"*. + + > Note for the next reader: this corrects + > ``.claude/plan/2026-10-11/bigquery-constraint-coverage.md``, whose + > transcription of ``constraint_definition`` shows + > ``[ [ CONSTRAINT constraint_name ] primary_key ]``. The live page does not. + > The plan's *verdict* (only a foreign key can be named at table level) was + > right; only its transcription was stale. + +**3. ``ALTER TABLE ADD`` can only add a foreign key.** There is no general +``ADD CONSTRAINT`` form on this page at all -- the heading is literally +``ALTER TABLE ADD FOREIGN KEY statement`` and the sentence under it is *"Adds a +foreign key constraint to an existing table."* ``CHECK``, ``UNIQUE`` and +``PRIMARY KEY`` cannot be added to an existing BigQuery table by any statement. +``DROP CONSTRAINT`` is the mirror image: *"You can use this statement to drop +foreign key constraints from a table."* ``IF EXISTS`` is optional on ``DROP`` +and ``IF NOT EXISTS`` is optional on ``ADD``. + +So BigQuery's whole constraint capability is one notch: **CREATE** admits only +primary keys and foreign keys, **ALTER** admits only foreign keys, and all of +them are unenforced. That is narrower than every other backend measured this +session. + +What the switches answer, and what the render path narrows +--------------------------------------------------------- +Two questions are separate and this backend answers them in two places, which +is why the values below are not the whole story: + +* **The switches answer the grammar question** -- "does this dialect have the + clause at all" -- with no kind and no version. They take ``(self)`` and + nothing else. +* **The kind question** -- "which constraints may carry the clause" -- belongs + to the render path, because only a formatter can see the branch. BigQuery's + answer is the narrowest in the family: enforcement rides a primary key or a + foreign key and nothing else, and the two kinds that do not exist here + (``CHECK``, ``UNIQUE``) are refused by :meth:`_require_bigquery_table_kind` + at the table level, where core consults no probe of its own. + +Still gaps a switch cannot close (recorded, not worked around) +-------------------------------------------------------------- +These are **render-path** problems that remain after the narrowing above. Each +is named here so the next reader does not mistake the pins for working +behaviour: + +* **``NOT ENFORCED`` is mandatory, and it is only emitted on request.** + Core appends the token when a caller sets ``constraint.not_enforced = True``, + which is the shape BigQuery wants -- on both the primary-key and the + foreign-key path, since core's S3 moved the trait to the production level. + What core does not do is *insist* on it: with neither ``enforced`` nor + ``not_enforced`` set, ``_format_constraint_enforcement`` returns ``""`` and a + caller who omits the flag gets ``PRIMARY KEY`` / ``REFERENCES t(c)`` -- a + shape the grammar does not have. Insisting is a core printing decision, not + a switch value; see :meth:`_require_bigquery_enforcement`, which refuses the + *wrong* spelling but cannot add the right one. +* **The explicit ``NULL`` column modifier is unguarded.** ``column_schema`` has + ``[NOT NULL]`` and says the *absence* of it means NULLABLE mode; there is no + ``NULL`` token to write. Core renders ``" NULL"`` with no probe at all (it + treats an explicitly nullable column as belonging to every grammar), so that + spelling needs a gate this backend cannot supply without a core change. +* **``ADD CONSTRAINT`` renders the wrong shape for a foreign key.** Core emits + ``ADD FOREIGN KEY (a) REFERENCES b(id)``; BigQuery's grammar accepts exactly + that head (``ADD`` with no ``CONSTRAINT`` keyword), but the trailing + ``NOT ENFORCED`` is missing, per the first bullet. + +Name note +--------- +The per-level (``supports_{column,table}_*``) and per-kind +(``supports_*_check_constraint``, ``supports_table_add_*``) probes, the +``naming_*`` family, ``supports_add_constraint_if_not_exists``, +``supports_table_rename_constraint``, ``supports_table_alter_constraint`` and +``supports_table_drop_foreign_key_constraint`` 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/bigquery-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 below are the ones core's renderers actually consult today; the +two groups are kept apart deliberately, and every docstring says which it is. +""" + +from typing import TYPE_CHECKING, Tuple + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + ColumnConstraint, + TableConstraint, + ) + +#: The grammar every value in this module was transcribed from. Kept as a +#: constant so the docstrings, the tests and the plan document cannot drift onto +#: different pages. +OFFICIAL_GRAMMAR_URL = ( + "https://cloud.google.com/bigquery/docs/reference/standard-sql/data-definition-language" +) + + +class BigQueryConstraintMixin: + """BigQuery constraint capability declarations. + + Every value in this class comes from the official ``column_schema`` / + ``constraint_definition`` / ``ALTER TABLE`` grammar productions + transcribed at ``OFFICIAL_GRAMMAR_URL`` on 2026-10-12. **None of them is + a live measurement** -- no BigQuery instance is reachable from this + repository and the CI emulator implements neither of the two ``ALTER + TABLE`` constraint statements. 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 and the known gaps. + """ + + # ------------------------------------------------------------------ + # Column level -- ``column_schema`` + # + # [PRIMARY KEY NOT ENFORCED | REFERENCES table_name(column_name) NOT ENFORCED] + # [ DEFAULT default_expression | embedding_generation | identity_column ] + # [NOT NULL] + # [OPTIONS(column_option_list)] + # + # Two kinds, neither nameable, both forced to NOT ENFORCED. The four + # ``False`` values below are the corrections: core defaults every one of + # these questions to ``True``, which on this backend means rendering + # ``CHECK (...)``, ``UNIQUE`` or ``CONSTRAINT `` into a column + # definition that BigQuery's parser rejects. + # ------------------------------------------------------------------ + + def supports_column_primary_key_constraint(self) -> bool: + """True: ``[PRIMARY KEY NOT ENFORCED]`` is the column grammar's first slot. + + Official grammar (``column_schema``, first optional group), verbatim:: + + [PRIMARY KEY NOT ENFORCED | REFERENCES table_name(column_name) NOT ENFORCED] + + The keyword pair is written out in full -- there is no + ``[PRIMARY KEY [NOT] ENFORCED]`` form -- so a BigQuery primary key is + only expressible as a primary key, and it is never enforced (the page's + prose: *"BigQuery only supports unenforced primary keys"*). + + Distinct from :meth:`supports_primary_key_constraint`, which is the + flat probe core's ``format_column_primary_key_constraint`` consults and + which is also ``True``. Name note: the per-level pair is + documentation vocabulary -- no core probe is per-level, and core + consults the flat switch. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_column_foreign_key_constraint(self) -> bool: + """True: ``[REFERENCES table_name(column_name) NOT ENFORCED]`` is in the grammar. + + Official grammar (``column_schema``, same optional group), verbatim:: + + REFERENCES table_name(column_name) NOT ENFORCED + + A single referenced column -- note the production has no ``[, ...]`` + after ``(column_name)``, so a column-level reference names exactly one + column. A composite reference has to be spelled as a table-level + ``foreign_key`` instead. + + ``NOT ENFORCED`` is inside the production, so it is mandatory here as + it is everywhere else on this dialect (see the module docstring). + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_column_check_constraint(self) -> bool: + """False: there is no ``CHECK`` anywhere in ``column_schema``. + + Official grammar, verbatim:: + + column_schema := + { simple_type | STRUCT | ARRAY } + [PRIMARY KEY NOT ENFORCED | REFERENCES table_name(column_name) NOT ENFORCED] + [ DEFAULT default_expression | embedding_generation | identity_column ] + [NOT NULL] + [OPTIONS(column_option_list)] + + Four optional groups, none of them ``CHECK``. Declared locally because + core's ``ConstraintMixin`` answers ``True`` for this question, and that + inherited answer is what makes ``format_column_check_constraint`` emit + `` CHECK ()`` into a column definition BigQuery will not + parse. ``False`` turns that into an ``UnsupportedFeatureError`` by + name instead -- see the table-level counterpart for the two levels' + relationship. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + def supports_column_unique_constraint(self) -> bool: + """False: there is no ``UNIQUE`` anywhere in ``column_schema``. + + Official grammar: the same four-group ``column_schema`` production + quoted in :meth:`supports_column_check_constraint`; ``UNIQUE`` is in + none of them. + + Declared locally because core defaults this to ``True`` and core's + ``format_column_unique_constraint`` consults *this* probe for the + ``ColumnUniqueConstraint`` branch -- so the inherited ``True`` renders + a bare `` UNIQUE`` on a BigQuery column. With ``False`` the caller is + refused by name. + + Worth saying plainly, because it is the kind of absence a reader + questions: BigQuery does not have a ``UNIQUE`` *constraint* and never + had one at this level. Its uniqueness story is a clustering or + merge-time property, not DDL, so there is nothing to gate off. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + def supports_column_naming_primary_key_constraint(self) -> bool: + """False: the column level has no ``CONSTRAINT `` keyword. + + Official grammar, verbatim: ``column_schema``'s first optional group is + ``[PRIMARY KEY NOT ENFORCED | REFERENCES ... NOT ENFORCED]``. Neither + branch has a ``CONSTRAINT constraint_name`` in front of it -- the + keyword does not occur in the production at all, so a column-level + constraint cannot be named on BigQuery. + + This is the asymmetry that makes BigQuery unusual in this group: every + other backend that has ``CONSTRAINT `` has it at column level + too, and here it is table-level-only (and even there only for a foreign + key). + + Name note: no core probe is per-level or ``naming_*`` yet. + 文档(待云验): grammar-only, not server-verified. + """ + return False + + def supports_column_naming_foreign_key_constraint(self) -> bool: + """False: same reason as :meth:`supports_column_naming_primary_key_constraint`. + + Official grammar: the ``REFERENCES table_name(column_name) NOT + ENFORCED`` branch of ``column_schema`` carries no ``CONSTRAINT`` keyword. + Only the *table-level* foreign key can be named on BigQuery; see + :meth:`supports_table_naming_foreign_key_constraint`. + + Name note: no core probe is per-level or ``naming_*`` yet. + 文档(待云验): grammar-only, not server-verified. + """ + return False + + def supports_column_enforced_primary_key_constraint(self) -> bool: + """True: the column primary key carries a mandatory ``NOT ENFORCED``. + + Official grammar, verbatim:: + + [PRIMARY KEY NOT ENFORCED | REFERENCES table_name(column_name) NOT ENFORCED] + + ``NOT ENFORCED`` is part of the production, not an optional + alternative, so the enforcement marker is **always written** and there + is nothing to choose. The page says the same in prose: *"BigQuery only + supports unenforced primary keys."* + + This is why the value is ``True`` rather than "n/a": the marker is not + optional, it is compulsory. A renderer that could not emit it could + never produce a valid BigQuery primary key at all. + + Name note: per-kind enforcement probes are a core S3 question; core + consults the flat :meth:`supports_constraint_enforced` today. + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_column_enforced_foreign_key_constraint(self) -> bool: + """True: the column reference carries a mandatory ``NOT ENFORCED``. + + Official grammar: ``REFERENCES table_name(column_name) NOT ENFORCED``, + with the marker inside the production. Page prose: *"BigQuery only + supports unenforced foreign keys."* + + Same reasoning as :meth:`supports_column_enforced_primary_key_constraint` + -- the token is compulsory, so the capability is ``True`` and what is + missing is a renderer that emits it (see the module docstring's known + gaps). + + Name note: per-kind enforcement probes are a core S3 question. + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_column_enforced_check_constraint(self) -> bool: + """False: an enforcement marker on a constraint that cannot exist. + + The column level has no ``CHECK`` (see + :meth:`supports_column_check_constraint`), so there is nothing for + ``NOT ENFORCED`` to attach to. ``False`` rather than "n/a" because the + combination a caller would be asking for -- an unenforced column CHECK + -- is not expressible on BigQuery at all. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + def supports_column_enforced_unique_constraint(self) -> bool: + """False: an enforcement marker on a constraint that cannot exist. + + The column level has no ``UNIQUE`` (see + :meth:`supports_column_unique_constraint`); ``ENFORCED`` / + ``NOT ENFORCED`` on one is equally inexpressible. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + # ------------------------------------------------------------------ + # Table level -- ``constraint_definition`` + # + # constraint_definition := + # [primary_key] + # | [[CONSTRAINT constraint_name] foreign_key, ...] + # + # primary_key := PRIMARY KEY (column_name[, ...]) NOT ENFORCED + # foreign_key := FOREIGN KEY (column_name[, ...]) foreign_reference + # foreign_reference := + # REFERENCES primary_key_table(column_name[, ...]) NOT ENFORCED + # + # The same two kinds as the column level, but the column lists may be + # composite here, and only the foreign key may be named. + # ------------------------------------------------------------------ + + def supports_table_primary_key_constraint(self) -> bool: + """True: ``PRIMARY KEY (column_name[, ...]) NOT ENFORCED`` is a table element. + + Official grammar, verbatim:: + + constraint_definition := + [primary_key] + | [[CONSTRAINT constraint_name] foreign_key, ...] + + primary_key := PRIMARY KEY (column_name[, ...]) NOT ENFORCED + + The comma makes this the composite form the column level cannot + express. ``NOT ENFORCED`` is inside the production again, so it is + mandatory (page prose: *"BigQuery only supports unenforced primary + keys."*). + + This is the same ``True`` as :meth:`supports_column_primary_key_constraint` + seen from the other side of the grammar; it is not the core probe -- + see :meth:`supports_primary_key_constraint`. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_foreign_key_constraint(self) -> bool: + """True: the second branch of ``constraint_definition`` is the foreign key. + + Official grammar, verbatim:: + + constraint_definition := + [primary_key] + | [[CONSTRAINT constraint_name] foreign_key, ...] + + foreign_key := FOREIGN KEY (column_name[, ...]) foreign_reference + + foreign_reference := + REFERENCES primary_key_table(column_name[, ...]) NOT ENFORCED + + Note the trailing ``, ...`` on the branch itself: a table may carry a + comma-separated list of foreign keys (and each may be individually + named), which is the only place on this dialect where several + constraints of one kind share a production. + + This is also the **only** kind ``ALTER TABLE`` can add or drop; see + :meth:`supports_add_constraint`. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_check_constraint(self) -> bool: + """False: ``CHECK`` is not one of the two branches of ``constraint_definition``. + + Official grammar, verbatim:: + + constraint_definition := + [primary_key] + | [[CONSTRAINT constraint_name] foreign_key, ...] + + Two branches, and neither is ``CHECK``. There is no other production + on the page that declares a check constraint at any level, and + ``ALTER TABLE`` cannot add one either, so a BigQuery table can never + carry a ``CHECK``. + + Declared locally rather than left to core's ``True`` because the + two-level pair is the finding: the *column* half + (:meth:`supports_column_check_constraint`) is ``False`` for the same + reason, and having one ``True`` and one ``False`` would misrepresent + the engine in whichever direction it went. + + Load-bearing on the render path: core's ``format_table_constraint`` + dispatches by branch and consults no per-kind probe (only ``EXCLUDE`` + is gated there), so this ``False`` would not by itself refuse anything. + The refusal is this dialect's own: ``format_table_constraint`` calls + :meth:`_require_bigquery_table_kind` before the ``CONSTRAINT `` + prefix is rendered, and a table-level CHECK is refused by name. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + def supports_table_unique_constraint(self) -> bool: + """False: ``UNIQUE`` is not one of the two branches of ``constraint_definition``. + + Official grammar, verbatim:: + + constraint_definition := + [primary_key] + | [[CONSTRAINT constraint_name] foreign_key, ...] + + Same absence as :meth:`supports_table_check_constraint`, and the same + note about the column level agreeing; the refusal comes from the same + place too -- this dialect's ``format_table_constraint`` calls + :meth:`_require_bigquery_table_kind`, because core gates no per-kind + table switch. This backend's own DDL fixtures already state the + consequence in prose -- ``tests/providers/fixtures`` builds every + scenario without a ``UNIQUE`` clause and without foreign keys -- which + is only true because of this ``False``. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + def supports_table_naming_primary_key_constraint(self) -> bool: + """False: the ``primary_key`` branch is bare -- no ``CONSTRAINT ``. + + Official grammar, verbatim:: + + constraint_definition := + [primary_key] + | [[CONSTRAINT constraint_name] foreign_key, ...] + + Read the two branches against each other: the foreign-key branch + brackets the name (``[[CONSTRAINT constraint_name] foreign_key, ...]``) + and the primary-key branch does not (``[primary_key]``). So a BigQuery + table's primary key **cannot be named**, and ``ALTER TABLE DROP + CONSTRAINT`` therefore cannot address it either. + + This is the one value here that corrects the plan document rather than + confirming it: ``.claude/plan/2026-10-11/bigquery-constraint-coverage.md`` + transcribes this branch as + ``[ [ CONSTRAINT constraint_name ] primary_key ]``. The page fetched on + 2026-10-12 has no such brackets, and the plan's own summary row (only + a foreign key can be named at table level) agrees with the page. + Recorded in the module docstring so the two documents do not keep + disagreeing silently. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + def supports_table_naming_foreign_key_constraint(self) -> bool: + """True: this is the one place on BigQuery where a constraint may be named. + + Official grammar, verbatim:: + + constraint_definition := + [primary_key] + | [[CONSTRAINT constraint_name] foreign_key, ...] + + The name is optional and per-key (``[CONSTRAINT constraint_name] + foreign_key``, then ``, ...`` for the rest), which is why the ALTER + side can then use ``DROP CONSTRAINT [IF EXISTS] constraint_name`` to + remove one -- see :meth:`supports_drop_constraint`. Every other kind, + at every level, is unnameable on BigQuery. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_enforced_primary_key_constraint(self) -> bool: + """True: the table primary key carries a mandatory ``NOT ENFORCED``. + + Official grammar, verbatim:: + + primary_key := PRIMARY KEY (column_name[, ...]) NOT ENFORCED + + Inside the production, so mandatory. Page prose: *"BigQuery only + supports unenforced primary keys."* + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_enforced_foreign_key_constraint(self) -> bool: + """True: the table foreign key carries a mandatory ``NOT ENFORCED``. + + Official grammar, verbatim:: + + foreign_reference := + REFERENCES primary_key_table(column_name[, ...]) NOT ENFORCED + + The marker sits on the *reference*, not on the ``FOREIGN KEY`` head, so + it follows the referenced table and column list. Same compulsory + reading as the other three enforced entries, and the same page prose + (*"BigQuery only supports unenforced foreign keys."*). + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_enforced_check_constraint(self) -> bool: + """False: no ``CHECK`` exists at table level to carry a marker. + + See :meth:`supports_table_check_constraint` for the two-branch + ``constraint_definition`` that excludes it. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + def supports_table_enforced_unique_constraint(self) -> bool: + """False: no ``UNIQUE`` exists at table level to carry a marker. + + See :meth:`supports_table_unique_constraint`. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + def supports_exclude_constraint(self) -> bool: + """False: ``EXCLUDE`` is a PostgreSQL family feature, absent here. + + Official grammar: ``constraint_definition`` has two branches, a bare + ``[primary_key]`` and ``[[CONSTRAINT constraint_name] foreign_key, ...]`` + (quoted at :meth:`supports_table_primary_key_constraint`). Neither is an + exclusion constraint, and no other production on the page declares one. + + Declared locally because this switch is one of the few *table*-level + probes core's ``format_table_constraint`` actually gates on -- it is the + only kind there whose absence refuses by name instead of 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: BigQuery's ``ARRAY`` type does + not imply an exclusion constraint, and nothing on this dialect can + express one. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + # ------------------------------------------------------------------ + # 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 shape, these are what a formatter asks. Where the two could + # disagree they are documented at the entry that differs. + # ------------------------------------------------------------------ + + def supports_primary_key_constraint(self) -> bool: + """True: ``format_column_primary_key_constraint`` may emit ``PRIMARY KEY``. + + Official grammar: ``[PRIMARY KEY NOT ENFORCED | ...]`` at column level + and ``primary_key := PRIMARY KEY (column_name[, ...]) NOT ENFORCED`` at + table level -- the keyword is in both, so ``True`` at both. + + **What this ``True`` does not cover is the mandatory marker.** The + probe answers "may a primary key be spelled at all", which is ``True`` + on both levels. Whether the compulsory ``NOT ENFORCED`` is actually + printed is a separate matter core decides at render time: it emits the + token only when a caller asks for it, so a caller that omits the flag + gets a bare ``PRIMARY KEY`` -- a shape the grammar does not have. That + gap is recorded in the module docstring rather than worked around here, + and :meth:`_require_bigquery_enforcement` refuses the *wrong* spelling + (``enforced=True``) on the same path. + + Declared locally even though core already answers ``True``: the value is + load-bearing (it is the gate on the refusal) and must not be able to + move with a core default. + + 文档(待云验): 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: ``REFERENCES table_name(column_name) NOT ENFORCED`` in + ``column_schema``, and ``foreign_key := FOREIGN KEY (column_name[, ...]) + foreign_reference`` at table level. Both levels, so ``True``. + + Known coupling, inherited from core and worth restating: this one flat + probe is consulted for the *column* level + (``format_column_fk_constraint``), with no separate column-level + question available. BigQuery answers ``True`` at both levels, so the + coupling costs nothing here -- unlike SQL Server, where it does. The + column-level answer is recorded explicitly at + :meth:`supports_column_foreign_key_constraint` so that changes to one + do not silently change the other. + + Declared locally so the ``True`` is BigQuery's statement. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_check_constraint(self) -> bool: + """False, and this is the correction with the widest blast radius. + + Official grammar: ``column_schema`` has four optional groups and + ``constraint_definition`` has two branches; **neither mentions + ``CHECK``** (both quoted in :meth:`supports_column_check_constraint` and + :meth:`supports_table_check_constraint`). + + Before this declaration the backend inherited core's ``True``, which + made core's ``format_column_check_constraint`` render `` CHECK + ()`` into a BigQuery column definition. ``False`` now makes + that path raise ``UnsupportedFeatureError`` naming "CHECK column + constraint" instead. + + Note the two-level relationship, because it is the one place a reader + may expect a contradiction: ``False`` here *and* + :meth:`supports_table_check_constraint` ``False``, while + ClickHouse's equivalent pair is ``False`` at column level and ``True`` at + table level. BigQuery genuinely has no ``CHECK`` anywhere -- the + official grammar admits only primary keys and foreign keys -- so the + asymmetry between the two backends is the engines'. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + def supports_unique_constraint(self) -> bool: + """False: no ``UNIQUE`` at column level, and core consults *this* probe there. + + Official grammar: ``UNIQUE`` is in none of ``column_schema``'s four + optional groups, and in neither branch of ``constraint_definition``. + + The load-bearing detail is *where* core reads this probe: + ``format_column_unique_constraint`` pairs the ``ColumnUniqueConstraint`` + branch with ``self.supports_unique_constraint`` and raises when it + answers ``False``. So the inherited ``True`` was rendering a bare + `` UNIQUE`` on BigQuery columns, and this ``False`` turns that into a + refusal by name. See :meth:`supports_column_unique_constraint` for the + level breakdown. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + def supports_not_null_constraint(self) -> bool: + """True: ``[NOT NULL]`` is the third optional group of ``column_schema``. + + Official grammar, verbatim:: + + [ DEFAULT default_expression | embedding_generation | identity_column ] + [NOT NULL] + [OPTIONS(column_option_list)] + + Two facts in that quote that matter more than the ``True``: + + * the page explains the semantics -- *"When the NOT NULL constraint is + present for a column or field, the column or field is created with + REQUIRED mode. Conversely, when the NOT NULL constraint is absent, + the column or field is created with NULLABLE mode."* Absent is how + you spell nullable; there is no ``NULL`` token to write. + * **it comes AFTER the ``DEFAULT`` / identity group.** On most backends + ``NOT NULL`` precedes ``DEFAULT``; here the order is fixed the other + way, and the render path has to honour it. See the ordering note on + the class body of + :class:`~rhosocial.activerecord.backend.impl.bigquery.mixins.ddl.BigQueryDDLColumnMixin`. + + One documented restriction the ``True`` does not cover: *"Columns and + fields of ARRAY type do not support the NOT NULL modifier."* That is a + per-column condition, not a capability, so it does not belong in a + dialect-level switch. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + # ------------------------------------------------------------------ + # Referential actions, MATCH, deferral -- none of which are in + # ``column_schema``, ``constraint_definition`` or ``foreign_reference``. + # + # Every ``False`` in this block is a **change** from the inherited core + # value: ``ConstraintMixin`` answers ``True`` for all of them, and + # ``DDLColumnMixin`` answers ``True`` for both ``ON DELETE`` / ``ON UPDATE`` + # pairs. Each inherited ``True`` would let a caller attach a clause + # BigQuery's parser rejects. + # ------------------------------------------------------------------ + + def supports_fk_on_delete(self) -> bool: + """False: ``foreign_reference`` has no ``ON DELETE``. + + Official grammar, verbatim:: + + foreign_reference := + REFERENCES primary_key_table(column_name[, ...]) NOT ENFORCED + + Four tokens after ``REFERENCES`` and nothing else: no + ``[ MATCH ... ]``, no ``[ ON DELETE ... ]``, no ``[ ON UPDATE ... ]``, + no deferral attributes. A BigQuery foreign key is therefore purely + declarative -- it records the relationship, and BigQuery does not act + on it (the page is explicit that the keys are unenforced), so there is + no cascade to ask for. + + Changed from core's ``True``. The switch core's renderer consults for + this clause is the pair below; this one is the flat declaration of the + same fact, pinned locally so the two cannot disagree by accident. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + def supports_fk_on_update(self) -> bool: + """False: same production, same absence -- ``foreign_reference`` has no ``ON UPDATE``. + + Official grammar:: + + foreign_reference := + REFERENCES primary_key_table(column_name[, ...]) NOT ENFORCED + + Oracle 21c is the one backend measured this session that has no + ``ON UPDATE`` at all; BigQuery joins it, and joins it for the stronger + reason that BigQuery's reference clause has no referential attributes + of any kind. + + Changed from core's ``True``. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + def supports_fk_match(self) -> bool: + """False: no ``MATCH {SIMPLE|PARTIAL|FULL}`` in the reference clause. + + Official grammar:: + + foreign_reference := + REFERENCES primary_key_table(column_name[, ...]) NOT ENFORCED + + ``MATCH`` is an SQL:1999 optional clause that PostgreSQL, MySQL, + MariaDB and Firebird all spell; BigQuery's reference clause does not + reach it. Core's ``ConstraintMixin`` answers ``True`` for this + question, and ``format_references_clause`` raises + ``UnsupportedFeatureError`` only when the probe declines, so the + inherited value would render ``MATCH FULL`` onto a BigQuery reference. + + Distinct from MySQL's trap, which is worth not conflating: MySQL + *accepts* ``MATCH FULL`` and silently discards it, which is why it has + to be refused there even though the server is happy. BigQuery has no + token at all, so this is the ordinary case. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + def supports_deferrable_constraint(self) -> bool: + """False: no ``DEFERRABLE`` / ``NOT DEFERRABLE`` / ``INITIALLY ...``. + + Official grammar: ``foreign_reference`` is four tokens wide (see + :meth:`supports_fk_on_delete`), and neither ``column_schema`` nor + ``constraint_definition`` mentions deferral either. + + Core's ``ConstraintMixin`` answers ``True``, and + ``format_references_clause`` consults it whenever any of the four + deferral flags is set -- so the inherited value renders `` DEFERRABLE`` + / `` INITIALLY DEFERRED`` onto a BigQuery reference clause. + + Changed from core's ``True``. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + # ------------------------------------------------------------------ + # Enforcement control. + # ------------------------------------------------------------------ + + def supports_constraint_enforced(self) -> bool: + """True -- and the reason is that ``NOT ENFORCED`` is **compulsory**. + + Official grammar, in both places the marker appears:: + + [PRIMARY KEY NOT ENFORCED | REFERENCES table_name(column_name) NOT ENFORCED] + ... + primary_key := PRIMARY KEY (column_name[, ...]) NOT ENFORCED + foreign_reference := + REFERENCES primary_key_table(column_name[, ...]) NOT ENFORCED + ... + ALTER TABLE ... ADD [CONSTRAINT [IF NOT EXISTS] constraint_name] + FOREIGN KEY (fk_column_name[, ...]) + REFERENCES pk_table_name(pk_column_name[,...]) NOT ENFORCED + + **The token is inside every production**, not inside a ``[NOT]`` + optional group. There is no BigQuery constraint spelling that omits + ``NOT ENFORCED`` and no spelling that says ``ENFORCED``. The page's + prose is the same statement: *"BigQuery only supports unenforced primary + keys"* and *"BigQuery only supports unenforced foreign keys."* + + So the honest answer to "may this dialect spell an enforcement + marker?" is ``True`` -- it must, or it cannot express a primary key or + a foreign key at all. Answering ``False`` would be the wrong fix: it + would refuse the *only* marker BigQuery has, and leave every primary key + and foreign key unspellable. + + **The probe takes no kind, by the round's protocol** (rule 1): a probe + answers whether the clause family exists on this dialect, and nothing + else. Which constraints may carry it is the render path's question, + and BigQuery's answer is the narrow one: PK and FK only, expressed by + :meth:`_require_bigquery_enforcement`, called from the dialect's own + ``format_column_constraint`` and ``format_table_constraint``. The + kinds that do not exist here (``CHECK``, ``UNIQUE``) never reach this + probe. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_alter_constraint_enforced(self) -> bool: + """False: there is no ``ALTER CONSTRAINT`` statement to carry a marker. + + Official grammar: the page defines exactly two constraint operations + under ``ALTER TABLE`` and neither is this one:: + + ALTER TABLE [[project_name.]dataset_name.]fk_table_name + ADD [CONSTRAINT [IF NOT EXISTS] constraint_name] FOREIGN KEY (...) + REFERENCES pk_table_name(pk_column_name[,...]) NOT ENFORCED + [ADD...]; + + ALTER TABLE [[project_name.]dataset_name.]table_name + DROP CONSTRAINT [IF EXISTS] constraint_name; + + A BigQuery constraint's enforcement state is fixed at creation -- it is + unenforced and there is no statement that changes it. So while the + marker is *mandatory* on the ADD path + (:meth:`supports_constraint_enforced`), it is *unreachable* on an ALTER + path. + + That the marker is still written on ``ADD ... FOREIGN KEY`` is not a + contradiction: the grammar puts ``NOT ENFORCED`` in the ADD production + itself, which is a creation-time statement, not an ``ALTER CONSTRAINT``. + + The probe answers the *verb's* existence and takes no kind: which kinds + an ``ALTER CONSTRAINT`` would reach if it existed is not a question a + BigQuery answer can have, and the gate below is the dialect's own + ``format_alter_constraint_action``. + + Declared locally: core already answers ``False``, and this is the pin + that keeps it. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + # ------------------------------------------------------------------ + # The render path: kind narrowing and the trait's own gate. + # + # These three are the seam S3 asks for. The probes above answer the + # grammar question with no kind; the branch class is the only thing that + # can say what kind a constraint is, so the per-kind answers live in + # formatters the dialect owns -- no private *formatter* is added (the + # ``_`` helpers are called from public formatters, per the ten rules), and + # the branches whose absence is expressible (CHECK, UNIQUE) are refused by + # name here rather than rendered into SQL the parser rejects. + # ------------------------------------------------------------------ + + #: Branch class -> the SQL spelling used in a refusal message. The spelling + #: is the SQL keyword, not the Python class name, because the message is + #: read by someone who wrote SQL. + _CONSTRAINT_KIND_SQL = { + "ColumnPrimaryKeyConstraint": "PRIMARY KEY", + "ColumnUniqueConstraint": "UNIQUE", + "ColumnCheckConstraint": "CHECK", + "ColumnForeignKeyConstraint": "FOREIGN KEY", + "TablePrimaryKeyConstraint": "PRIMARY KEY", + "TableUniqueConstraint": "UNIQUE", + "TableCheckConstraint": "CHECK", + "TableForeignKeyConstraint": "FOREIGN KEY", + } + + def _bigquery_kind_sql(self, branch: object) -> str: + """The SQL spelling to name a constraint branch in a refusal.""" + return self._CONSTRAINT_KIND_SQL.get(type(branch).__name__, type(branch).__name__) + + def _require_bigquery_enforcement(self, constraint: object) -> None: + """Refuse ``ENFORCED`` on BigQuery, and ``NOT ENFORCED`` off a PK/FK. + + ``NOT ENFORCED`` is inside every BigQuery constraint production, so + the dialect can never *omit* it -- but it also has no ``ENFORCED`` + token at all, so a caller who asks for the bare marker is asking for + something the grammar does not have. And the token rides only the two + kinds that exist here; the table-level ones that do not exist are + refused by :meth:`_require_bigquery_table_kind` before this runs. + + Raises: + UnsupportedFeatureError: For ``enforced=True``, or for the marker + on a branch that cannot carry it. + """ + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + enforced = getattr(constraint, "enforced", False) + not_enforced = getattr(constraint, "not_enforced", False) + if not enforced and not not_enforced: + return + branch = getattr(constraint, "branch", None) + kind = self._bigquery_kind_sql(branch) + if enforced: + feature = f"{kind} ENFORCED" + raise UnsupportedFeatureError( + self.name, + feature, + f"{self.name} does not support {feature}. BigQuery's " + f"constraint grammar writes ``NOT ENFORCED`` into every " + f"production and has no ``ENFORCED`` spelling.", + ) + if type(branch).__name__ not in ( + "ColumnPrimaryKeyConstraint", + "ColumnForeignKeyConstraint", + "TablePrimaryKeyConstraint", + "TableForeignKeyConstraint", + ): + feature = f"{kind} NOT ENFORCED" + raise UnsupportedFeatureError( + self.name, + feature, + f"{self.name} does not support {feature}.", + ) + + def _require_bigquery_table_kind(self, constraint: object) -> None: + """Refuse the table-level kinds BigQuery has no production for. + + ``constraint_definition`` admits a bare ``[primary_key]`` and + ``[[CONSTRAINT constraint_name] foreign_key, ...]`` and nothing else, + so a table-level ``CHECK`` or ``UNIQUE`` cannot be written. Core's + ``format_table_constraint`` dispatches by branch and consults no + per-kind probe, which is why the refusal is this dialect's own -- and + why it happens before the name prefix is rendered. + + Raises: + UnsupportedFeatureError: for a table-level CHECK or UNIQUE. + """ + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + kind = self._bigquery_kind_sql(getattr(constraint, "branch", None)) + if kind in ("CHECK", "UNIQUE"): + feature = f"{kind} table constraint" + raise UnsupportedFeatureError( + self.name, + feature, + f"{self.name} does not support {feature}.", + ) + + # ------------------------------------------------------------------ + # ALTER TABLE constraint lifecycle. + # + # BigQuery's ALTER grammar has exactly two constraint statements, and they + # are not the same kind: + # + # ADD -- foreign key only, and it is the *only* thing ADD can add + # DROP -- by name, and therefore also foreign key only, because a + # foreign key is the only nameable constraint on this dialect + # + # Everything else a caller might reach for (rename, alter, validate, + # not-valid, enable/disable, drop-by-kind) has no production at all. + # ------------------------------------------------------------------ + + def supports_add_constraint(self) -> bool: + """True for the *operation* -- and it adds a FOREIGN KEY and nothing else. + + Official grammar, verbatim: the statement is headed + ``ALTER TABLE ADD FOREIGN KEY statement`` and reads:: + + ALTER TABLE [[project_name.]dataset_name.]fk_table_name + ADD [CONSTRAINT [IF NOT EXISTS] constraint_name] FOREIGN KEY (fk_column_name[, ...]) + REFERENCES pk_table_name(pk_column_name[,...]) NOT ENFORCED + [ADD...]; + + The page's sentence under the heading is *"Adds a foreign key + constraint to an existing table."* There is no general ``ADD + CONSTRAINT`` form on the page at all. + + **The honest answer to the question this switch asks is ``True``**: the + operation exists, and BigQuery is the only backend in this group for + which ``ALTER TABLE ADD `` is genuinely available. Setting + it ``False`` would make the one statement BigQuery has unreachable. + + What ``True`` must not be read as claiming is that the four kinds are + equally addable. They are not, and the per-kind truth is recorded + alongside: + + * :meth:`supports_table_add_foreign_key_constraint` -- ``True`` + * :meth:`supports_table_add_primary_key_constraint` -- ``False`` + * :meth:`supports_table_add_check_constraint` -- ``False`` + * :meth:`supports_table_add_unique_constraint` -- ``False`` + + BigQuery is the only backend of the ten measured this session where + ``ADD CONSTRAINT`` does not reach all four kinds; every other one + either adds all four or (SQLite) adds none. + + A single boolean cannot carry "the operation exists" and "this kind can + use it" at once, which is the argument for the per-kind split. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_add_constraint_if_not_exists(self) -> bool: + """True: ``[CONSTRAINT [IF NOT EXISTS] constraint_name]`` brackets it. + + Official grammar, on the ADD statement:: + + ADD [CONSTRAINT [IF NOT EXISTS] constraint_name] FOREIGN KEY (...) + + The double bracket is the page's own notation for "this whole segment, + including the keyword, is optional": a constraint may be added + unnamed, named, or named-if-absent. + + Note what this switch is *not*: ``IF NOT EXISTS`` on the ``CONSTRAINT`` + keyword is the only idempotency guard BigQuery offers on this path. + There is no ``ALTER TABLE IF NOT EXISTS`` form for the ADD itself, and + the ``[ADD ...]`` tail of the production repeats the same statement + rather than introducing a guard. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_add_foreign_key_constraint(self) -> bool: + """True: the foreign key is the one kind ``ALTER TABLE ADD`` can add. + + Official grammar: the ADD production's head is literally ``FOREIGN KEY + (fk_column_name[, ...])`` and the statement's heading is + ``ALTER TABLE ADD FOREIGN KEY statement``. + + So of the four ``constraint_definition`` kinds BigQuery knows at + ``CREATE`` time, exactly one survives to ``ALTER`` time. The other + three are :meth:`supports_table_add_primary_key_constraint`, + :meth:`supports_table_add_check_constraint` and + :meth:`supports_table_add_unique_constraint` -- all ``False``. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_add_primary_key_constraint(self) -> bool: + """False: the ADD production's only head is ``FOREIGN KEY``. + + Official grammar:: + + ADD [CONSTRAINT [IF NOT EXISTS] constraint_name] FOREIGN KEY (fk_column_name[, ...]) + REFERENCES pk_table_name(pk_column_name[,...]) NOT ENFORCED + [ADD...]; + + BigQuery *can* have a primary key -- ``CREATE TABLE`` accepts one, and + BigQuery uses it for streaming best-effort row-ordering guarantees -- + but it can only be given to a table at creation. This is a real + ``False``, not "not implemented yet": there is no other statement on the + page that adds one. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + def supports_table_add_check_constraint(self) -> bool: + """False: no ``CHECK`` to add, and no ADD form that would carry one. + + Official grammar: ``constraint_definition`` (quoted in + :meth:`supports_table_check_constraint`) has no ``CHECK`` branch, and + the ALTER page's only constraint statement heads ``FOREIGN KEY``. Both + ends are closed. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + def supports_table_add_unique_constraint(self) -> bool: + """False: no ``UNIQUE`` to add, and no ADD form that would carry one. + + Official grammar: same two closures as + :meth:`supports_table_add_check_constraint` -- + ``constraint_definition`` has no ``UNIQUE`` branch and the ALTER + statement heads ``FOREIGN KEY``. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + def supports_drop_constraint(self) -> bool: + """True for the operation -- and it can only ever name a foreign key. + + Official grammar, verbatim:: + + ALTER TABLE [[project_name.]dataset_name.]table_name + DROP CONSTRAINT [IF EXISTS] constraint_name; + + and the page's sentence above it: *"Drops a constraint from an existing + table. You can use this statement to drop foreign key constraints from + a table."* + + The second sentence is the important one and it is the backend's + narrowest fact: the statement is *described* as dropping constraints in + general and then *specified* as dropping foreign keys. Since a + foreign key is the only nameable constraint BigQuery has + (:meth:`supports_table_naming_foreign_key_constraint`), those two facts + are consistent -- the reachable set is exactly the FKs. + + Consequence worth stating: a BigQuery primary key cannot be dropped by + name, because it cannot be named. Neither can anything else. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_drop_constraint_if_exists(self) -> bool: + """True: ``DROP CONSTRAINT [IF EXISTS] constraint_name`` -- the guard is available. + + Official grammar, verbatim:: + + DROP CONSTRAINT [IF EXISTS] constraint_name; + + **Changed from core's ``DDLColumnMixin`` default of ``False``.** That + inherited ``False`` is the most consequential of the inherited answers + for a backend whose *only* constraint lifecycle verb is this one: the + default would have declined the idempotent drop and left BigQuery with + no way to drop a constraint idempotently. + + The page's own text for the guard is worded in terms of primary keys + (*"IF EXISTS: If no primary key exists in the def[ault dataset ...]"*), + which is the page's copy-paste residue -- a primary key is not droppable + on BigQuery at all. The bracket is in the grammar, so the guard is + available; the prose is not evidence about which constraints it covers. + + BigQuery therefore matches MariaDB, PostgreSQL, SQL Server and + ClickHouse, and differs from MySQL (no ``DROP CONSTRAINT IF EXISTS`` at + all) and from Firebird and SQLite. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_table_drop_foreign_key_constraint(self) -> bool: + """True: ``DROP CONSTRAINT`` reaches the foreign key and nothing else. + + Official grammar: *"You can use this statement to drop foreign key + constraints from a table."* -- the statement's own description of its + reach. + + The mirror of :meth:`supports_table_add_foreign_key_constraint`, and the + reason :meth:`supports_drop_constraint` can be ``True`` without being + misleading. + + 文档(待云验): grammar-only, not server-verified. + """ + return True + + def supports_constraint_novalidate(self) -> bool: + """False: no ``NOT VALID`` on BigQuery's ADD statement. + + Official grammar: the ADD production ends at the compulsory + ``NOT ENFORCED`` followed by ``[ADD...]``. There is no ``NOT VALID``, + and the page does not mention the clause anywhere. + + Declared locally rather than left absent. On this core no + ``ConstraintMixin`` or ``DDLColumnMixin`` declares the probe at all -- + ``BigQueryDialect`` was reaching only the ``ConstraintSupport`` + Protocol stub, whose body is not an implementation -- so the + ``UnsupportedFeatureError`` in ``_format_constraint_validation`` was + resting on that stub rather than on a BigQuery statement. Now it rests + on the grammar. + + Nothing needs an unvalidated state here, which is a stronger reason + than the absence of the token: a BigQuery constraint is unenforced from + the moment it exists (:meth:`supports_constraint_enforced`), so there is + no "add it but do not validate it yet" window to defer. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + def supports_validate_constraint(self) -> bool: + """False: there is no ``VALIDATE CONSTRAINT`` statement on the page. + + Official grammar: the page defines exactly two constraint operations + under ``ALTER TABLE`` -- ``ADD ... FOREIGN KEY`` and ``DROP CONSTRAINT`` + (both quoted at :meth:`supports_add_constraint`). Neither validates, + and no third statement exists. + + Declared locally: core already answers ``False``; this is the pin. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + def supports_table_rename_constraint(self) -> bool: + """False: no ``RENAME CONSTRAINT ... TO ...``. + + Official grammar: the two ``ALTER TABLE`` constraint statements are + quoted in full at :meth:`supports_add_constraint`; a rename is not one + of them. + + Worth contrasting, because the two "absent spellings" are absent for + different reasons elsewhere: PostgreSQL and Oracle have a real + ``RENAME CONSTRAINT``; SQL Server reaches the same effect through the + stored procedure ``sp_rename`` with its two-argument form. BigQuery has + neither the statement nor the escape hatch. + + Because a foreign key *is* nameable, a rename would be expressible if + the statement existed -- the name is the only thing missing. That is a + real gap in BigQuery's DDL, not a gap in this backend. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + def supports_table_alter_constraint(self) -> bool: + """False: no ``ALTER CONSTRAINT`` statement on the page. + + Official grammar: see :meth:`supports_table_rename_constraint` -- the + page's ``ALTER TABLE`` constraint vocabulary is ``ADD`` and ``DROP``, + nothing else. + + This is the switch the flat + :meth:`supports_alter_constraint_enforced`` answers alongside: a + BigQuery constraint's only mutable property would be its enforcement + state, and the statement that would change it does not exist. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + # ------------------------------------------------------------------ + # The ``ON DELETE`` / ``ON UPDATE`` pair that lives on ``DDLColumnMixin`` + # rather than on ``ConstraintMixin``. + # + # These two *are* consulted by core's ``format_references_clause``, which + # makes them the switches that actually stop a referential action from + # being rendered. Both default to ``True`` in core; both change to + # ``False`` here. + # ------------------------------------------------------------------ + + def supports_foreign_key_on_delete(self) -> bool: + """False -- and this is the probe that stops ``ON DELETE`` being rendered. + + Official grammar:: + + foreign_reference := + REFERENCES primary_key_table(column_name[, ...]) NOT ENFORCED + + ``core.dialect.mixins.ddl_column.DDLColumnMixin.format_references_clause`` + consults ``supports_foreign_key_on_delete()`` and raises + ``UnsupportedFeatureError(self.name, "FOREIGN KEY ON DELETE")`` when it + declines. ``DDLColumnMixin`` defaults it to ``True``, so the inherited + value rendered `` ON DELETE CASCADE`` onto a BigQuery reference clause. + + Note the difference from the flat + :meth:`supports_fk_on_delete` above, which states the same fact for the + ``ConstraintMixin`` half of the same question: **this** one is the gate + with teeth. Both are declared so the two halves cannot drift. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + def supports_foreign_key_on_update(self) -> bool: + """False -- the gate that stops ``ON UPDATE`` being rendered. + + Official grammar:: + + foreign_reference := + REFERENCES primary_key_table(column_name[, ...]) NOT ENFORCED + + Same production and same reasoning as + :meth:`supports_foreign_key_on_delete`: + ``DDLColumnMixin.format_references_clause`` raises on ``False``, and + ``DDLColumnMixin`` defaults the switch to ``True``. + + 文档(待云验): grammar-only, not server-verified. + """ + return False + + # ------------------------------------------------------------------ + # The render-path narrowing (S3): the public formatters core dispatches to. + # + # The probes above answer "does the clause exist" and take no kind. The + # kind is the branch class -- the only thing that can name it -- so the + # per-kind answer is expressed here, in the two public formatters a + # constraint clause reaches. No private formatter is added (the ``_`` + # helpers are called from these), which is the seam the guide asks for. + # ------------------------------------------------------------------ + + def format_column_constraint(self, constraint: "ColumnConstraint") -> "Tuple[str, tuple]": + """Render a column constraint, enforcing BigQuery's narrow marker rule. + + ``column_schema`` writes ``NOT ENFORCED`` into the two branches it + has, so the dialect must accept the marker -- but it has no ``ENFORCED`` + token and no third kind to put it on. Core consults no per-kind probe + on this path (the S3 production-level trait renderer asks only + ``supports_constraint_enforced``), so the narrowing is here, before the + prefix and the branch are rendered: a request the grammar cannot spell + is refused by name instead of being written into SQL the parser rejects. + + Delegation to :meth:`format_constraint_branch` / the core renderer is + what a request the grammar *can* spell takes. + + Raises: + UnsupportedFeatureError: ``enforced=True`` (no such token), or the + marker on a branch that cannot carry it. + """ + self._require_bigquery_enforcement(constraint) + return super().format_column_constraint(constraint) + + def format_table_constraint(self, expr: "TableConstraint") -> "Tuple[str, tuple]": + """Render a table constraint, refusing the kinds BigQuery has no production for. + + ``constraint_definition`` admits a bare primary key and a (possibly + named) foreign key; ``CHECK`` and ``UNIQUE`` cannot be written at this + level at all. Core dispatches by branch and consults no per-kind + probe here, so the refusal is this dialect's own -- and it happens + before the ``CONSTRAINT `` prefix, so a caller is told the kind + is unavailable rather than handed a named constraint the server would + reject. + + Raises: + UnsupportedFeatureError: A table-level ``CHECK`` or ``UNIQUE``, or + an enforcement-trait request the grammar cannot spell. + """ + self._require_bigquery_table_kind(expr) + self._require_bigquery_enforcement(expr) + return super().format_table_constraint(expr) + + +__all__ = ["BigQueryConstraintMixin", "OFFICIAL_GRAMMAR_URL"] diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/ddl.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/ddl.py index ce88334..ba3440f 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/ddl.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/ddl.py @@ -1,5 +1,6 @@ # src/rhosocial/activerecord/backend/impl/bigquery/mixins/ddl.py """BigQuery DDL column/index mixin.""" + from __future__ import annotations from typing import Any, Tuple, TYPE_CHECKING @@ -21,6 +22,57 @@ class BigQueryDDLColumnMixin: via ALTER TABLE. """ + # ------------------------------------------------------------------ + # CLAUSE ORDER ON THE COLUMN RENDER PATH -- read before reordering anything + # ------------------------------------------------------------------ + # + # ``column_schema`` fixes the order of a column definition, and it is the + # *reverse* of what most backends use in two places. Official grammar + # (transcribed 2026-10-12; no BigQuery instance is reachable, so this is + # 文档(待云验) -- grammar-only): + # + # column_schema := + # { simple_type | STRUCT | ARRAY } + # [PRIMARY KEY NOT ENFORCED | REFERENCES table_name(column_name) NOT ENFORCED] + # [ DEFAULT default_expression | embedding_generation | identity_column ] + # [NOT NULL] + # [OPTIONS(column_option_list)] + # + # Two consequences for anything that builds a ``ColumnDefinition``: + # + # 1. ``NOT NULL`` comes AFTER the ``DEFAULT`` / identity group. On MySQL, + # MariaDB, PostgreSQL, SQL Server, Oracle and Firebird the nullability + # marker leads. Here it follows, so ``col STRING NOT NULL DEFAULT 'x'`` + # is not the shape BigQuery accepts -- it wants + # ``col STRING DEFAULT 'x' NOT NULL``. + # 2. The primary key / reference group comes FIRST, before ``DEFAULT``. + # + # Why this is a comment and not a sort: core's ``format_column_definition`` + # renders ``attributes``, then ``constraints``, then + # ``generated_expression``, then ``comment``, each in the order it was + # built, and ``format_column_attributes`` explicitly does not sort. So the + # order is decided by the order the caller appended constraints, which makes + # it a property of the *call site*, not something a formatter override can + # guarantee. Nothing is sorted or reordered here on purpose -- a renderer + # that "fixed" the order for BigQuery would break the backends whose order + # is the other way round. + # + # The good news, and the reason this backend has no + # ``format_column_definition`` override at all: the generic renderer already + # lands on ``column_schema``'s order when the caller supplies the clauses in + # grammar order. Measured on this dialect's own DDL, a column declared with + # ``[REFERENCES ... not_enforced, DEFAULT, NOT NULL]`` and an + # ``OPTIONS(description=...)`` comment renders as:: + # + # `id` STRING(10) REFERENCES `p`(`id`) NOT ENFORCED DEFAULT 'x' + # NOT NULL OPTIONS(description='a note') + # + # -- group 1, group 2, group 3, group 4, exactly as ``column_schema`` spells + # them. The test that locks this is + # ``tests/.../test_bigquery_constraint_switches.py::TestClauseOrderIsRecordedOnTheRenderPath``; + # if a core change reorders the clauses, that test is where it should fail. + # ------------------------------------------------------------------ + def supports_column_comment(self) -> bool: """Whether an inline column comment is supported. 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 index 8621351..edbea97 100644 --- 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 @@ -9,17 +9,21 @@ import pytest -from rhosocial.activerecord.backend.expression.core import Column -from rhosocial.activerecord.backend.expression import ComparisonPredicate, Literal +from rhosocial.activerecord.backend.expression.core import Column, Literal +from rhosocial.activerecord.backend.expression import ComparisonPredicate from rhosocial.activerecord.backend.expression.statements import ( - ColumnConstraintType, + ColumnCheckConstraint, + ColumnDefaultConstraint, ColumnDefinition, + ColumnNotNullConstraint, + ColumnUniqueConstraint, CreateTableStatement, CreateTableOptions, IndexDefinition, StorageOptionsExpression, + TableCheckConstraint, TableConstraint, - TableConstraintType, + TableUniqueConstraint, ) from rhosocial.activerecord.backend.expression.statements.ddl_partition import ( PartitionClause, @@ -45,21 +49,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"] @@ -91,17 +95,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( @@ -129,9 +134,13 @@ def generated_expression(dialect): TABLE_TABLESPACE = "ts_data" COMPOSITE_CONSTRAINT = TableConstraint( None, - TableConstraintType.CHECK, + branch=TableCheckConstraint( + None, + ComparisonPredicate( + None, "<>", Column(None, "tenant_id"), Column(None, "order_id") + ), + ), name="ck_composite", - columns=["tenant_id", "order_id"], ) @@ -180,7 +189,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 @@ -205,7 +214,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 +308,16 @@ def _assert_declaration_model(model): ) value_constraints = model.column_constraints("value") - assert [item.constraint_type for item in value_constraints] == [ - ColumnConstraintType.DEFAULT, - ColumnConstraintType.UNIQUE, - ColumnConstraintType.CHECK, + # The constraint's kind is the branch it carries now, not a tag it stores. + 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] == [ @@ -339,15 +349,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] == [ + _column_primary_key_branch_type(), + 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 type(quantity_constraints[0].branch) is ColumnDefaultConstraint + assert quantity_constraints[0].branch.value == 7 + assert type(quantity_constraints[1].branch) is ColumnNotNullConstraint assert model.table_options() is TABLE_OPTIONS assert [item.or_replace for item in model.table_options()] == [True, False] @@ -373,6 +383,14 @@ def _assert_declaration_model(model): ) +def _column_primary_key_branch_type(): + from rhosocial.activerecord.backend.expression.statements import ( + ColumnPrimaryKeyConstraint, + ) + + return ColumnPrimaryKeyConstraint + + def _assert_batch_interfaces(model): fields = ["value", "id", "quantity"] assert list(model.columns_name()) == ["id", "value", "quantity"] @@ -432,20 +450,28 @@ 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 type(constraints[-1].branch) is _table_primary_key_branch_type() + assert constraints[-1].branch.columns == ["tenant_id", "order_id"] assert all( - item.constraint_type != TableConstraintType.PRIMARY_KEY + type(item.branch) is not _table_primary_key_branch_type() for item in constraints[:-1] ) +def _table_primary_key_branch_type(): + from rhosocial.activerecord.backend.expression.statements import ( + TablePrimaryKeyConstraint, + ) + + return TablePrimaryKeyConstraint + + @pytest.mark.parametrize("model", MODEL_CLASSES, ids=("sync", "async")) def test_ddl_source_collects_field_and_table_declarations(model): _assert_declaration_model(model) diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_capability_assertions.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_capability_assertions.py index 04e0765..fb6ccc7 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_capability_assertions.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_capability_assertions.py @@ -19,8 +19,8 @@ from rhosocial.activerecord.backend.expression.objects import Table, View from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, + ColumnGeneratedConstraint, DropTableStatement, - GeneratedColumnExpression, GeneratedColumnType, TruncateStatement, ) @@ -77,7 +77,7 @@ def test_generated_stored_column_renders(dialect): dialect, "total", IntegerType(dialect), - generated_expression=GeneratedColumnExpression( + generated_expression=ColumnGeneratedConstraint( dialect, BinaryArithmeticExpression(dialect, "+", Column(dialect, "a"), Column(dialect, "b")), storage_type=GeneratedColumnType.STORED, @@ -92,7 +92,7 @@ def test_virtual_generated_column_rejected(dialect): dialect, "total", IntegerType(dialect), - generated_expression=GeneratedColumnExpression( + generated_expression=ColumnGeneratedConstraint( dialect, Column(dialect, "a"), storage_type=GeneratedColumnType.VIRTUAL, diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py index 6059901..dc42578 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py @@ -167,12 +167,13 @@ ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ( ColumnConstraint, - ColumnConstraintType, + ColumnNotNullConstraint, ForeignKeyConstraint, IndexDefinition, ReferencesClause, + TableCheckConstraint, TableConstraint, - TableConstraintType, + TableUniqueConstraint, ) from rhosocial.activerecord.backend.expression.statements.ddl_trigger import ( TriggerEvent, @@ -342,17 +343,18 @@ def register_specials(): ) # -- constraints, which need member columns and real enum values --------- - # The generic guess passes the constraint *type* as the string "x", which - # every one of these refuses; and it passes no columns at all, which is a - # FOREIGN KEY and a REFERENCES clause without an end. + # The clause's kind is a branch node now, not a tag: the constraint cannot + # be built without saying what kind it is, and the branches whose payload + # is a column list or a referenced table need real values the heuristic + # guess has nothing to supply. 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=TableUniqueConstraint(d, ["a"]), name="c" ), ) register_special_constructor( @@ -409,7 +411,7 @@ def register_specials(): lambda d: ddl_alter.AddTableConstraint( d, TableConstraint( - d, TableConstraintType.PRIMARY_KEY, name="c", columns=["a"] + d, branch=TableUniqueConstraint(d, ["a"]), name="c" ), ), ) @@ -421,7 +423,7 @@ def register_specials(): register_special_constructor( "statements.ddl_alter.AlterConstraint", lambda d: ddl_alter.AlterConstraint( - d, "c", enforced=True, constraint_type=TableConstraintType.CHECK + d, "c", enforced=True, constraint_kind=TableCheckConstraint ), ) register_special_constructor( From 5997d41d197e239bd3ff3e419c71fbb046361ad9 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 15 Oct 2026 01:26:28 +0800 Subject: [PATCH 05/35] fix(bigquery): converge the constraint switches onto the (self) probe protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract test for the constraint capability declaration committed with the S3 migration. Nothing here changes behaviour; the code and its probes were one unit in the previous commit (per the wave guide), so this one adds the guard that makes the declaration load-bearing rather than decorative. Evidence level: 文档(待云验) -- official grammar only, not server-verified, stated in the file header and asserted per switch. There is no BigQuery instance reachable from this environment and the CI emulator (goccy/bigquery-emulator on localhost:9050) implements neither of the two ALTER TABLE constraint statements, so the emulator cannot adjudicate these switches either. What it pins: * the value table -- every switch this backend relies on, grouped the way the grammar groups them (column_schema / constraint_definition / ALTER TABLE); * declared-by-this-backend -- the first class in the dialect's MRO that has the name in __dict__ is inside this package. Eight of these answers used to be inherited core defaults that are the exact opposite of BigQuery's grammar (CHECK, UNIQUE, MATCH, DEFERRABLE, both referential actions, and the DDLColumnMixin ON DELETE / ON UPDATE pair); * probes take no argument -- (self) and nothing else, checked on the bound method so a defaulted parameter cannot slip back in behind ``= None``; * the 待云验 marker in every switch docstring, and the module's plain-English restatement of the evidence level; * the render-path narrowing -- a PK/FK takes the compulsory NOT ENFORCED, the bare ENFORCED token is refused on all four kinds and both levels, and a table-level CHECK / UNIQUE is refused by name (core gates no per-kind table switch, so the refusal is this dialect's own formatter); * the remaining gaps are recorded in the mixin's module docstring, not only in the test. The gate ordering is asserted too: the kind refusal wins over the ``CONSTRAINT `` prefix, so a named CHECK is refused for its kind rather than for its name. Verification: tests/.../feature/backend/test_bigquery_constraint_switches.py, exit 0 (166 passed); ruff on the file, clean. --- .../test_bigquery_constraint_switches.py | 843 ++++++++++++++++++ 1 file changed, 843 insertions(+) create mode 100644 tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_constraint_switches.py diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_constraint_switches.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_constraint_switches.py new file mode 100644 index 0000000..8faa3b1 --- /dev/null +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_constraint_switches.py @@ -0,0 +1,843 @@ +# tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_constraint_switches.py +"""BigQuery constraint capability switches, pinned to the official grammar. + +**Evidence level: 文档(待云验) — official grammar only, not server-verified.** +Every value asserted here was transcribed from the official DDL reference's +``column_schema`` / ``constraint_definition`` / ``ALTER TABLE`` productions on +2026-10-12. Nothing was measured: there is no BigQuery instance reachable from +this repository's environment, and the CI emulator (``goccy/bigquery-emulator`` +on ``localhost:9050``) does not implement either of the two ``ALTER TABLE`` +constraint statements, so it cannot adjudicate them either. The grammar is +quoted in the docstrings of +``rhosocial.activerecord.backend.impl.bigquery.mixins.constraint``; this module +pins the values and checks that they are load-bearing. + +Source of every quote: + https://cloud.google.com/bigquery/docs/reference/standard-sql/data-definition-language + +Five guards beyond the values themselves: + +* **declared here, not inherited.** Before this change the backend declared + **zero** constraint switches and took all of them from core, whose defaults + answer ``True`` for ``CHECK``, ``UNIQUE``, ``MATCH``, ``DEFERRABLE`` and both + referential actions — every one of which BigQuery's grammar rejects. That is + the highest-risk inheritance position in the group, so each switch must be + declared by a class in *this* package and a moved core default must not be + able to move BigQuery's contract. +* **the probes take no argument.** A probe answers whether the capability family + exists on this dialect, not which kind may use it; the per-kind answers are + the render path's (see :class:`TestTheRenderPathNarrowsByKind`). +* **each docstring says it is not live-verified.** A ``待云验`` marker is this + repository's standing convention for "a real BigQuery run must confirm this"; + asserted per switch so a later edit cannot quietly drop the caveat. +* **the one-notch finding, asserted as a whole.** BigQuery's CREATE side admits + primary keys and foreign keys only, and its ALTER side admits foreign keys + only. ``CHECK`` and ``UNIQUE`` are ``False`` at *both* levels, and only a + table-level foreign key can be named. +* **``NOT ENFORCED`` is mandatory, so the enforcement switch is ``True``.** + This reads backwards until you see the grammar: the token is inside the + production, so a dialect that refused to spell it could not express a primary + key or a foreign key at all. Asserted separately with the quoting, because it + is the value a future reader is most likely to "fix" to ``False``. +""" + +import inspect + +import pytest +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.core import Column +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + ColumnCheckConstraint, + ColumnConstraint, + ColumnDefinition, + ColumnForeignKeyConstraint, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, + ReferencesClause, + TableCheckConstraint, + TableConstraint, + TableForeignKeyConstraint, + TablePrimaryKeyConstraint, + TableUniqueConstraint, +) +from rhosocial.activerecord.backend.expression.types import VarCharType + +from rhosocial.activerecord.backend.impl.bigquery.dialect import BigQueryDialect +from rhosocial.activerecord.backend.impl.bigquery.mixins import constraint as constraint_module +from rhosocial.activerecord.backend.impl.bigquery.mixins.constraint import ( + OFFICIAL_GRAMMAR_URL, + BigQueryConstraintMixin, +) + +#: The mixin's **module** docstring, where the evidence level and the remaining +#: render-path gaps are recorded. Deliberately not ``__doc__`` on the class: +#: that resolves to the class docstring, which restates the level but is not +#: where the gaps live. +MODULE_DOC = constraint_module.__doc__ or "" + +#: Every switch this backend relies on must come from this package. A switch +#: declared in core's ``ConstraintMixin`` / ``DDLColumnMixin`` is not an answer +#: for BigQuery; it is an accident waiting for a core default to move. +BACKEND_PACKAGE = "rhosocial.activerecord.backend.impl.bigquery." + +#: The page every grammar quote in the mixin's docstrings was transcribed from. +#: Pinned here so the mixin and this module cannot drift onto different pages. +EXPECTED_GRAMMAR_URL = ( + "https://cloud.google.com/bigquery/docs/reference/standard-sql/data-definition-language" +) + +#: (switch name, grammar value) pairs. The value is hard-coded on purpose: +#: reading it back from the dialect would make the test unable to see a moved +#: default. +#: +#: Grouped the way the grammar groups them, because that grouping *is* the +#: finding: the column level (``column_schema``) carries two kinds, neither +#: nameable; the table level (``constraint_definition``) carries the same two, +#: but only the foreign key may be named; the ALTER side carries foreign keys +#: only. +GRAMMAR_SWITCHES = ( + # -- column level: column_schema ---------------------------------------- + ("supports_column_primary_key_constraint", True), + ("supports_column_foreign_key_constraint", True), + # CHECK and UNIQUE are in none of column_schema's four optional groups. + ("supports_column_check_constraint", False), + ("supports_column_unique_constraint", False), + # ``column_schema``'s first group has no CONSTRAINT keyword at all, so no + # column-level constraint on BigQuery can be named. + ("supports_column_naming_primary_key_constraint", False), + ("supports_column_naming_foreign_key_constraint", False), + # NOT ENFORCED is inside the production -> mandatory, so "can this dialect + # spell an enforcement marker" is True; the two kinds that do not exist + # cannot carry one. + ("supports_column_enforced_primary_key_constraint", True), + ("supports_column_enforced_foreign_key_constraint", True), + ("supports_column_enforced_check_constraint", False), + ("supports_column_enforced_unique_constraint", False), + # -- table level: constraint_definition --------------------------------- + ("supports_table_primary_key_constraint", True), + ("supports_table_foreign_key_constraint", True), + ("supports_table_check_constraint", False), + ("supports_table_unique_constraint", False), + # ``[primary_key]`` is bare; ``[[CONSTRAINT constraint_name] foreign_key, ...]`` + # is not. A BigQuery table primary key cannot be named. + ("supports_table_naming_primary_key_constraint", False), + ("supports_table_naming_foreign_key_constraint", True), + ("supports_table_enforced_primary_key_constraint", True), + ("supports_table_enforced_foreign_key_constraint", True), + ("supports_table_enforced_check_constraint", False), + ("supports_table_enforced_unique_constraint", False), + # -- flat kind switches core's renderers consult ------------------------ + ("supports_primary_key_constraint", True), + ("supports_foreign_key_constraint", True), + ("supports_check_constraint", False), + ("supports_unique_constraint", False), + ("supports_not_null_constraint", True), + ("supports_exclude_constraint", False), + # -- clauses the reference clause does not have ------------------------ + # ``foreign_reference ::= REFERENCES primary_key_table ( column_name[, ...] ) + # NOT ENFORCED`` -- four tokens, nothing else. + ("supports_fk_on_delete", False), + ("supports_fk_on_update", False), + ("supports_fk_match", False), + ("supports_deferrable_constraint", False), + # The ``DDLColumnMixin`` pair, which is what actually stops ``ON DELETE`` / + # ``ON UPDATE`` being rendered by ``format_references_clause``. + ("supports_foreign_key_on_delete", False), + ("supports_foreign_key_on_update", False), + # -- enforcement control ------------------------------------------------- + # NOT ENFORCED is mandatory on the CREATE and ADD paths -> True. There is no + # ALTER CONSTRAINT statement at all -> False. + ("supports_constraint_enforced", True), + ("supports_alter_constraint_enforced", False), + # -- ALTER TABLE lifecycle ---------------------------------------------- + # The operation exists (it adds a foreign key); the three other kinds cannot + # be added by any statement. + ("supports_add_constraint", True), + ("supports_add_constraint_if_not_exists", True), + ("supports_table_add_foreign_key_constraint", True), + ("supports_table_add_primary_key_constraint", False), + ("supports_table_add_check_constraint", False), + ("supports_table_add_unique_constraint", False), + # ``DROP CONSTRAINT [IF EXISTS] constraint_name`` -- and a foreign key is the + # only nameable constraint, so that is the only kind it reaches. + ("supports_drop_constraint", True), + ("supports_drop_constraint_if_exists", True), + ("supports_table_drop_foreign_key_constraint", True), + # Everything else in the lifecycle vocabulary has no production. + ("supports_constraint_novalidate", False), + ("supports_validate_constraint", False), + ("supports_table_rename_constraint", False), + ("supports_table_alter_constraint", False), +) + + +def _dialect(): + return BigQueryDialect() + + +def _declaring_class(switch_name): + """The first class in the dialect's MRO that declares ``switch_name``.""" + for cls in BigQueryDialect.__mro__: + if switch_name in cls.__dict__: + return cls + raise AssertionError(f"no class in the MRO declares {switch_name}()") + + +def _predicate(d): + return ComparisonPredicate(d, "=", Column(d, "a"), Column(d, "b")) + + +def _references(d): + return Table(d, "p") + + +class TestGrammarSwitchValues: + @pytest.mark.parametrize( + "switch_name,expected", + GRAMMAR_SWITCHES, + ids=[s for s, _ in GRAMMAR_SWITCHES], + ) + def test_matches_the_official_grammar(self, switch_name, expected): + dialect = _dialect() + assert getattr(dialect, switch_name)() is expected, ( + f"{switch_name}() no longer matches the official grammar " + f"transcription; see the docstring in mixins/constraint.py for the " + f"production that decided it." + ) + + @pytest.mark.parametrize( + "switch_name,_expected", + GRAMMAR_SWITCHES, + ids=[s for s, _ in GRAMMAR_SWITCHES], + ) + def test_declared_by_this_backend(self, switch_name, _expected): + """A moved core default must not move BigQuery's contract silently. + + This backend used to declare none of these and take every answer from + core's ``ConstraintMixin`` / ``DDLColumnMixin``, whose defaults are + ``True`` for ``supports_check_constraint``, + ``supports_unique_constraint``, ``supports_fk_match``, + ``supports_deferrable_constraint``, ``supports_fk_on_delete``, + ``supports_fk_on_update``, ``supports_foreign_key_on_delete`` and + ``supports_foreign_key_on_update`` -- eight answers that are the exact + opposite of what BigQuery's grammar says. + """ + declaring = _declaring_class(switch_name) + assert declaring.__module__.startswith(BACKEND_PACKAGE), ( + f"{switch_name} is inherited from {declaring.__module__}; declare it " + f"in this backend's mixins so the grammar answer is explicit." + ) + + @pytest.mark.parametrize( + "switch_name,_expected", + GRAMMAR_SWITCHES, + ids=[s for s, _ in GRAMMAR_SWITCHES], + ) + def test_probes_take_no_argument(self, switch_name, _expected): + """``(self)`` and nothing else -- the round's probe protocol. + + A probe answers one question ("does this clause family exist here") and + the kind is not part of it. A parameterised probe has two call shapes + depending on its own signature, which is exactly how the draft this + change replaces ended up carrying a kind the formatter had to guess at. + Checked on the bound method, so a defaulted parameter cannot slip back + in behind an ``= None``. + """ + bound = getattr(_dialect(), switch_name) + params = inspect.signature(bound).parameters + assert list(params) == [], ( + f"{switch_name} must take only (self); it declares {list(params)}. " + f"The per-kind narrowing lives on the render path, in this " + f"dialect's own formatters." + ) + + +class TestEvidenceIsMarkedGrammarOnly: + """A reader must not mistake a transcription for a measurement. + + The ``文档(待云验)`` marker is this repository's standing convention for + "a real BigQuery run must confirm this before a caller relies on it". It is + asserted per switch so a later edit cannot quietly drop the caveat, and the + module docstring is checked for the plain-English restatement so the + finding is legible to someone who does not read the Chinese marker. + """ + + @pytest.mark.parametrize( + "switch_name", [s for s, _ in GRAMMAR_SWITCHES], ids=[s for s, _ in GRAMMAR_SWITCHES] + ) + def test_each_docstring_marks_itself_not_live_verified(self, switch_name): + doc = inspect.getdoc(getattr(BigQueryConstraintMixin, switch_name)) or "" + assert "待云验" in doc, ( + f"{switch_name}'s docstring must carry the 文档(待云验) marker. Every " + f"value here is read off the official grammar and none of it was " + f"measured: no BigQuery instance is reachable from this environment " + f"and the CI emulator implements neither ALTER TABLE constraint " + f"statement." + ) + + def test_the_module_says_plainly_that_nothing_was_measured(self): + assert "文档(待云验)" in MODULE_DOC + assert "Nothing here was measured" in MODULE_DOC + assert "localhost:9050" in MODULE_DOC, ( + "The module must name the emulator and say why it cannot adjudicate " + "these switches -- otherwise a reader may assume the emulator was " + "used as evidence." + ) + assert "official-grammar-only" in MODULE_DOC + + def test_the_grammar_url_is_pinned_in_one_place(self): + assert OFFICIAL_GRAMMAR_URL == EXPECTED_GRAMMAR_URL + + +class TestConstraintCapabilityIsOneNotch: + """BigQuery has primary keys and foreign keys, and nothing else. + + ``CREATE TABLE`` admits exactly two kinds; ``ALTER TABLE`` admits exactly + one. This is the finding the whole matrix hangs on, so it is asserted on + its own rather than only through the per-switch parametrization. + """ + + def test_create_side_admits_exactly_primary_key_and_foreign_key(self): + dialect = _dialect() + advertised = { + name: getattr(dialect, name)() + for name in ( + "supports_column_primary_key_constraint", + "supports_column_foreign_key_constraint", + "supports_column_check_constraint", + "supports_column_unique_constraint", + ) + } + assert advertised == { + "supports_column_primary_key_constraint": True, + "supports_column_foreign_key_constraint": True, + "supports_column_check_constraint": False, + "supports_column_unique_constraint": False, + } + + def test_check_and_unique_are_false_at_both_levels(self): + """The one pair a reader is most likely to expect to differ. + + ClickHouse's equivalent pair is ``False`` at column level and ``True`` + at table level. BigQuery has no ``CHECK`` at either level: neither + ``column_schema``'s four optional groups nor ``constraint_definition``'s + two branches mentions it. The asymmetry between the two backends is the + engines', not a transcription slip. + """ + dialect = _dialect() + assert dialect.supports_check_constraint() is False + assert dialect.supports_table_check_constraint() is False + assert dialect.supports_unique_constraint() is False + assert dialect.supports_table_unique_constraint() is False + + def test_only_a_table_level_foreign_key_can_be_named(self): + """``[primary_key]`` is bare; ``[[CONSTRAINT n] foreign_key, ...]`` is not. + + Official grammar, verbatim:: + + constraint_definition := + [primary_key] + | [[CONSTRAINT constraint_name] foreign_key, ...] + + So a BigQuery table's primary key cannot be named -- which is also why + ``DROP CONSTRAINT`` cannot reach it. + """ + dialect = _dialect() + assert dialect.supports_table_naming_foreign_key_constraint() is True + assert dialect.supports_table_naming_primary_key_constraint() is False + assert dialect.supports_column_naming_foreign_key_constraint() is False + assert dialect.supports_column_naming_primary_key_constraint() is False + + def test_alter_table_adds_a_foreign_key_and_nothing_else(self): + """``ALTER TABLE ADD FOREIGN KEY statement`` -- that is the whole heading. + + Of the four kinds BigQuery knows at ``CREATE`` time, exactly one + survives to ``ALTER`` time. Every other backend measured this session + either adds all four kinds (MySQL, MariaDB, PostgreSQL, SQL Server, + Oracle, Snowflake, Firebird) or none (SQLite); BigQuery is the only one + in between. + """ + dialect = _dialect() + assert dialect.supports_table_add_foreign_key_constraint() is True + assert dialect.supports_table_add_primary_key_constraint() is False + assert dialect.supports_table_add_check_constraint() is False + assert dialect.supports_table_add_unique_constraint() is False + + def test_drop_reaches_the_foreign_key_and_nothing_else(self): + dialect = _dialect() + assert dialect.supports_drop_constraint() is True + assert dialect.supports_table_drop_foreign_key_constraint() is True + # The lifecycle verbs BigQuery's grammar simply does not contain. + assert dialect.supports_table_rename_constraint() is False + assert dialect.supports_table_alter_constraint() is False + assert dialect.supports_validate_constraint() is False + assert dialect.supports_constraint_novalidate() is False + + +class TestNotEnforcedIsMandatory: + """The value that reads backwards until you see the grammar. + + ``NOT ENFORCED`` is written into the production, not into a ``[NOT]`` + optional group, so a dialect that refused to spell it could not express a + primary key or a foreign key at all. ``False`` would be the wrong fix. + """ + + def test_the_marker_is_inside_every_production_that_has_one(self): + doc = inspect.getdoc(BigQueryConstraintMixin.supports_constraint_enforced) or "" + for production in ( + "[PRIMARY KEY NOT ENFORCED", + "REFERENCES table_name(column_name) NOT ENFORCED", + "primary_key := PRIMARY KEY (column_name[, ...]) NOT ENFORCED", + "REFERENCES pk_table_name(pk_column_name[,...]) NOT ENFORCED", + ): + assert production in doc, ( + f"the docstring must quote {production!r} verbatim so the True " + f"reads as 'mandatory' rather than 'optional'" + ) + + def test_the_switch_is_true_for_exactly_the_kinds_that_exist(self): + dialect = _dialect() + assert dialect.supports_column_enforced_primary_key_constraint() is True + assert dialect.supports_column_enforced_foreign_key_constraint() is True + assert dialect.supports_table_enforced_primary_key_constraint() is True + assert dialect.supports_table_enforced_foreign_key_constraint() is True + # The other two cannot carry a marker because they do not exist. + assert dialect.supports_column_enforced_check_constraint() is False + assert dialect.supports_column_enforced_unique_constraint() is False + assert dialect.supports_table_enforced_check_constraint() is False + assert dialect.supports_table_enforced_unique_constraint() is False + + def test_alter_constraint_enforcement_is_unreachable_not_refused(self): + """``NOT ENFORCED`` is compulsory on ADD and unreachable on ALTER. + + The grammar's two ``ALTER TABLE`` statements are ``ADD ... + FOREIGN KEY ... NOT ENFORCED`` (a creation-time statement that carries + the marker in its own production) and ``DROP CONSTRAINT``. There is no + ``ALTER CONSTRAINT``, so a BigQuery constraint's enforcement state + cannot be changed after the fact -- which is a different answer from + "the clause is refused", and the two switches therefore differ. + """ + dialect = _dialect() + assert dialect.supports_constraint_enforced() is True + assert dialect.supports_alter_constraint_enforced() is False + + +class TestTheRenderPathNarrowsByKind: + """The per-kind half of the enforcement answer, and where it is enforced. + + Nothing in the switch table selects a kind; a probe answers whether the + clause family exists. Which constraints may carry the clause is a property + of the branch class, so the narrowing is this dialect's own -- in the two + public formatters a constraint clause dispatches to, and nowhere else. The + request the grammar can spell renders; the request it cannot is refused by + name. + """ + + def test_primary_key_takes_the_mandatory_marker(self): + """The kind BigQuery must be able to spell -- and now can. + + On the core this change replaces, ``format_primary_key_constraint`` + raised ``ValueError`` for any kind but CHECK / FOREIGN KEY before the + probe was reached, so a BigQuery primary key could not carry its + compulsory ``NOT ENFORCED`` at all. S3 moved the trait to the + production, so the same request now renders. + """ + d = _dialect() + sql, params = d.format_column_constraint( + ColumnConstraint( + d, + branch=ColumnPrimaryKeyConstraint(d), + not_enforced=True, + ) + ) + assert sql == " PRIMARY KEY NOT ENFORCED", sql + assert params == () + + def test_foreign_key_takes_the_mandatory_marker(self): + d = _dialect() + sql, _ = d.format_column_constraint( + ColumnConstraint( + d, + branch=ColumnForeignKeyConstraint(d, (_references(d), ["id"])), + not_enforced=True, + ) + ) + assert sql == " REFERENCES `p`(`id`) NOT ENFORCED", sql + + def test_the_bare_enforced_token_is_refused_on_every_kind(self): + """BigQuery has no ``ENFORCED`` spelling, so asking for one is refused. + + Asserted on both levels and on all four kinds that exist here, because + the same fact decides all of them and a reader is most likely to + "fix" the switch to ``False`` rather than the render path. + """ + d = _dialect() + column_cases = ( + ColumnPrimaryKeyConstraint(d), + ColumnForeignKeyConstraint(d, (_references(d), ["id"])), + ) + for branch in column_cases: + with pytest.raises(UnsupportedFeatureError) as exc: + d.format_column_constraint(ColumnConstraint(d, branch=branch, enforced=True)) + assert "ENFORCED" in exc.value.feature_name + assert "NOT ENFORCED" not in exc.value.feature_name + + table_cases = ( + TablePrimaryKeyConstraint(d, ["a"]), + TableForeignKeyConstraint(d, ["a"], (_references(d), ["id"])), + ) + for branch in table_cases: + with pytest.raises(UnsupportedFeatureError) as exc: + d.format_table_constraint(TableConstraint(d, branch=branch, enforced=True)) + assert "ENFORCED" in exc.value.feature_name + assert "NOT ENFORCED" not in exc.value.feature_name + + def test_the_table_level_check_and_unique_are_refused_by_name(self): + """Core gates no per-kind table switch, so the refusal is this dialect's. + + ``constraint_definition`` has two branches and neither is CHECK or + UNIQUE. Core's ``format_table_constraint`` dispatches by branch without + consulting a probe (only EXCLUDE is gated there), so the honest + ``False`` values in the switch table would not by themselves stop + ``CREATE TABLE ... (CONSTRAINT ck CHECK (...))`` from rendering. The + dialect's own formatter is what refuses it. + """ + d = _dialect() + with pytest.raises(UnsupportedFeatureError) as exc: + d.format_table_constraint( + TableConstraint(d, branch=TableCheckConstraint(d, _predicate(d)), name="ck") + ) + assert exc.value.feature_name == "CHECK table constraint" + + with pytest.raises(UnsupportedFeatureError) as exc: + d.format_table_constraint( + TableConstraint(d, branch=TableUniqueConstraint(d, ["a"]), name="uq") + ) + assert exc.value.feature_name == "UNIQUE table constraint" + + def test_the_refusal_wins_over_the_name_prefix(self): + """A named CHECK is refused for its kind, not rendered as a constraint. + + The ordering matters: checking the kind after the prefix would make the + message depend on whether a name happened to be attached. + """ + d = _dialect() + with pytest.raises(UnsupportedFeatureError) as exc: + d.format_table_constraint( + TableConstraint( + d, + branch=TableCheckConstraint(d, _predicate(d)), + name="named_check", + ) + ) + assert exc.value.feature_name == "CHECK table constraint" + + def test_the_marker_is_refused_on_a_check_branch_that_cannot_exist(self): + """``NOT ENFORCED`` on a column CHECK: the kind is gone before the marker. + + The column CHECK is refused by :meth:`supports_check_constraint` inside + core's branch formatter, so the marker never gets a chance -- which is + the right outcome and is asserted so the two refusals do not get + accidentally reordered. + """ + d = _dialect() + with pytest.raises(UnsupportedFeatureError): + d.format_column_constraint( + ColumnConstraint( + d, + branch=ColumnCheckConstraint(d, _predicate(d)), + not_enforced=True, + ) + ) + + def test_a_subclass_that_widens_the_probe_does_not_gain_a_marker(self): + """The narrowing is the formatter's, not the probe's. + + ``supports_constraint_enforced`` is ``True`` here already; the reason a + wrong marker is refused is the render path. A subclass that flips the + probe cannot therefore make ``enforced=True`` render -- only the + dialect's own formatter can decide that, and it refuses. + """ + + class _NoEnforcement(BigQueryDialect): + def supports_constraint_enforced(self) -> bool: + return False + + d = _NoEnforcement() + assert d.supports_constraint_enforced() is False + # The refusal is the dialect's own formatter, which runs first. + with pytest.raises(UnsupportedFeatureError) as exc: + d.format_column_constraint( + ColumnConstraint( + d, + branch=ColumnPrimaryKeyConstraint(d), + enforced=True, + ) + ) + assert "PRIMARY KEY ENFORCED" == exc.value.feature_name + + +class TestRefusalsTheFalseValuesProduce: + """What the corrections actually stop, reached the way a caller reaches it. + + Each of these was rendering BigQuery-rejected SQL before the declarations + landed: ``CHECK (...)`` and ``UNIQUE`` in a column definition, + ``MATCH FULL`` / ``ON DELETE`` / ``ON UPDATE`` / ``DEFERRABLE`` on a + ``REFERENCES`` clause. + """ + + def test_column_check_is_refused_by_name(self): + d = _dialect() + branch = ColumnCheckConstraint(d, _predicate(d)) + with pytest.raises(UnsupportedFeatureError, match="CHECK column constraint"): + d.format_column_check_constraint(branch) + + def test_column_check_is_refused_on_the_full_column_path(self): + """The same refusal through the dispatcher a ``ColumnDefinition`` takes.""" + d = _dialect() + constraint = ColumnConstraint(d, branch=ColumnCheckConstraint(d, _predicate(d))) + with pytest.raises(UnsupportedFeatureError, match="CHECK column constraint"): + d.format_column_constraint(constraint) + + def test_column_unique_is_refused_by_name(self): + d = _dialect() + with pytest.raises(UnsupportedFeatureError, match="UNIQUE column constraint"): + d.format_column_constraint( + ColumnConstraint(d, branch=ColumnUniqueConstraint(d)) + ) + + def test_fk_match_is_refused_on_the_references_clause(self): + d = _dialect() + with pytest.raises(UnsupportedFeatureError, match="FOREIGN KEY MATCH"): + d.format_references_clause( + ReferencesClause(d, _references(d), ["id"], match_type="FULL") + ) + + def test_fk_on_delete_is_refused_on_the_references_clause(self): + d = _dialect() + with pytest.raises(UnsupportedFeatureError, match="FOREIGN KEY ON DELETE"): + d.format_references_clause( + ReferencesClause(d, _references(d), ["id"], on_delete="CASCADE") + ) + + def test_fk_on_update_is_refused_on_the_references_clause(self): + d = _dialect() + with pytest.raises(UnsupportedFeatureError, match="FOREIGN KEY ON UPDATE"): + d.format_references_clause( + ReferencesClause(d, _references(d), ["id"], on_update="CASCADE") + ) + + def test_deferrable_is_refused_on_the_references_clause(self): + d = _dialect() + with pytest.raises(UnsupportedFeatureError, match="DEFERRABLE"): + d.format_references_clause( + ReferencesClause(d, _references(d), ["id"], deferrable=True) + ) + + def test_exclude_is_refused_by_name_on_the_table_path(self): + """The one kind whose absence core's ``format_table_constraint`` gates on.""" + from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + TableExcludeConstraint, + ) + + d = _dialect() + with pytest.raises(UnsupportedFeatureError): + d.format_table_constraint( + TableConstraint(d, branch=TableExcludeConstraint(d, ["a"]), name="ex") + ) + + +class TestKnownRenderPathGapsAreRecordedNotPinned: + """The values are right; a few generic renderers still do not act on them. + + These are **not** assertions of desired behaviour -- they are here so the + gaps stay visible and named. Nothing below asserts that today's output is + correct; each says what is still missing, so a future change to any of these + renderers has a place to go. + """ + + def test_not_enforced_is_still_only_rendered_when_a_caller_asks(self): + """The remaining gap, on the foreign-key path: no auto-emission. + + ``format_constraint_traits`` renders the marker only when + ``constraint.not_enforced`` is set, so a caller who does not set it gets + ``REFERENCES `p`(`id`)`` -- a shape BigQuery's grammar does not have. + The *wrong* spelling is refused by this dialect (see + :class:`TestTheRenderPathNarrowsByKind`); insisting on the right one is + a core printing decision, out of this change's scope. + """ + d = _dialect() + silent = d.format_column_constraint( + ColumnConstraint( + d, + branch=ColumnForeignKeyConstraint(d, (_references(d), ["id"])), + ) + ) + explicit = d.format_column_constraint( + ColumnConstraint( + d, + branch=ColumnForeignKeyConstraint(d, (_references(d), ["id"])), + not_enforced=True, + ) + ) + assert "NOT ENFORCED" not in silent[0] + assert explicit[0].endswith("NOT ENFORCED") + + def test_the_primary_key_reaches_its_marker_but_not_by_default(self): + """The sharp half of the same gap, now reachable on the primary-key path. + + S3 moved the trait to the production, so ``not_enforced=True`` on a + BigQuery primary key renders (it used to be a ``ValueError``). What + remains is that the marker is still opt-in: the dialect does not add it + for a caller who omits it. + """ + d = _dialect() + sql, _ = d.format_column_constraint( + ColumnConstraint( + d, + branch=ColumnPrimaryKeyConstraint(d), + not_enforced=True, + ) + ) + assert sql.endswith("NOT ENFORCED"), sql + bare, _ = d.format_column_constraint( + ColumnConstraint(d, branch=ColumnPrimaryKeyConstraint(d)) + ) + assert "NOT ENFORCED" not in bare + + def test_the_gaps_are_named_where_they_are_recorded(self): + """Each of the gaps above must also be written down next to the switches. + + A gap that lives only in a test file is a comment; a gap recorded in the + mixin's module docstring is a contract statement. Both are required. + """ + for gap in ( + "Still gaps a switch cannot close", + "``NOT ENFORCED`` is mandatory, and it is only emitted on request", + "The explicit ``NULL`` column modifier is unguarded", + "``ADD CONSTRAINT`` renders the wrong shape for a foreign key", + ): + assert gap in MODULE_DOC, f"the module docstring must record: {gap!r}" + + +class TestClauseOrderIsRecordedOnTheRenderPath: + """``NOT NULL`` comes *after* ``DEFAULT`` on BigQuery -- the opposite of most. + + Official ``column_schema``:: + + [PRIMARY KEY NOT ENFORCED | REFERENCES ... NOT ENFORCED] + [ DEFAULT default_expression | embedding_generation | identity_column ] + [NOT NULL] + [OPTIONS(column_option_list)] + + Core renders a column definition as ``attributes`` then ``constraints`` in + build order and does not sort, so the order is a property of the call site + rather than of a formatter. The ordering therefore belongs in a **code + comment on the render path** and not only in a docstring -- and it is + recorded there, because a constraint mixin's docstring is not what someone + reordering ``format_column_definition`` will read. + """ + + def test_the_render_path_carries_the_ordering_comment(self): + from rhosocial.activerecord.backend.impl.bigquery.mixins import ddl as ddl_module + + source = inspect.getsource(ddl_module) + assert "CLAUSE ORDER ON THE COLUMN RENDER PATH" in source, ( + "mixins/ddl.py is this backend's column render path; the fixed " + "column_schema ordering must be a comment there, where someone " + "changing the render order will read it." + ) + for fragment in ( + "[ DEFAULT default_expression | embedding_generation | identity_column ]", + "[NOT NULL]", + "[OPTIONS(column_option_list)]", + ): + assert fragment in source, f"the ordering comment must quote {fragment!r}" + + def test_the_ordering_comment_says_the_opposite_of_the_other_backends(self): + from rhosocial.activerecord.backend.impl.bigquery.mixins import ddl as ddl_module + + source = inspect.getsource(ddl_module) + assert "comes AFTER the ``DEFAULT``" in source + assert "does not sort" in source, ( + "the comment must say that nothing sorts the clauses, or a reader " + "will assume the order is guaranteed by the formatter" + ) + + def test_the_constraint_mixin_names_the_order_too(self): + """The fact has to be reachable from the switch a reader came for.""" + doc = inspect.getdoc(BigQueryConstraintMixin.supports_not_null_constraint) or "" + assert "comes AFTER the ``DEFAULT``" in doc + + def test_the_generic_renderer_already_lands_on_the_grammar_order(self): + """Why BigQuery needs no ``format_column_definition`` override. + + Core renders ``attributes``, then ``constraints`` in build order, then + the comment clause -- which for a caller that supplies the clauses in + ``column_schema`` order is ``REFERENCES`` group, then ``DEFAULT``, then + ``NOT NULL``, then ``OPTIONS``. Locked here because it is the only + reason this backend has no column-definition override, and a silent + reorder in core would quietly produce SQL BigQuery rejects. + + Asserted as an exact string, so a reorder fails loudly rather than + shifting a clause past another unnoticed. + """ + d = _dialect() + column = ColumnDefinition( + d, + "id", + VarCharType(d, 10), + constraints=[ + ColumnConstraint( + d, + branch=ColumnForeignKeyConstraint(d, (_references(d), ["id"])), + not_enforced=True, + ), + ColumnConstraint(d, branch=_default_branch(d, "x")), + ColumnConstraint(d, branch=ColumnNotNullConstraint(d)), + ], + comment=_comment(d, "a note"), + ) + assert d.format_column_definition(column)[0] == ( + "`id` STRING(10) REFERENCES `p`(`id`) NOT ENFORCED DEFAULT 'x' " + "NOT NULL OPTIONS(description='a note')" + ) + + def test_reordering_the_clauses_reorders_the_sql(self): + """The order is the call site's, so the comment's claim is checkable. + + Core sorts nothing, so this is the mirror of the test above: feed the + clauses in the *wrong* order and the SQL comes out in that wrong order. + That is why the ordering lives in a comment at the call site rather than + in a formatter override -- no override could have prevented it. + """ + d = _dialect() + column = ColumnDefinition( + d, + "id", + VarCharType(d, 10), + constraints=[ + ColumnConstraint(d, branch=ColumnNotNullConstraint(d)), + ColumnConstraint(d, branch=_default_branch(d, "x")), + ], + ) + assert d.format_column_definition(column)[0] == ("`id` STRING(10) NOT NULL DEFAULT 'x'") + + +def _default_branch(d, value): + from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + ColumnDefaultConstraint, + ) + + return ColumnDefaultConstraint(d, value) + + +def _comment(d, text): + from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + ColumnCommentClause, + ) + + return ColumnCommentClause(d, text) From de7e96a889b6b22b2671bb839e2fd65742dde9cd Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 15 Oct 2026 01:32:04 +0800 Subject: [PATCH 06/35] refactor(bigquery): split the wildcard by grammar position Core split the single asterisk into two by where it sits in the grammar (spec 4.10): ``AllColumnsWildcard`` for the select list and RETURNING, ``RowCountWildcard`` for ``COUNT``'s argument. The two answer different questions -- ``COUNT(*)`` counts rows, ``t.*`` expands columns -- and the old ``WildcardExpression`` / ``Wildcard`` node no longer exists. Backend surface: * ``mixins/dql.py`` carried a ``format_wildcard`` override that re-spelled ``table.*`` by reading ``expr.table`` / ``expr.table_need_quote`` -- a shape the split removed. The override is **deleted rather than renamed**: the qualifier now rides a ``NamedRelationRef`` on the node, and core's ``format_all_columns_wildcard`` renders the name the query itself uses (``rel.*`` or the alias), which is the same output the override produced. There is nothing BigQuery-specific left to say, so a renamed override would have been a copy of the default. (Verified: bare ``*``, ``rel.*`` and ``alias.*`` all render through core.) No ``COUNT``-position call site exists here, and BigQuery's ``* EXCEPT`` / wildcard-table spellings are backend-specific by the guide and are not nodes core provides, so no ``format_row_count_wildcard`` override is added. Docs: ``schema_namespace.md`` (en/zh) described the deleted ``WildcardExpression`` and its ``format_wildcard`` discard of ``schema_name``. The passage is rewritten to the two nodes and the relation-carried qualifier, and the one place a wildcard genuinely cannot carry a dataset is recorded as the backend-specific wildcard table (``dataset.*``), which stays backend-side. Verification: * every backend test except the roundtrip matrix, exit 0 (the matrix's wildcard rows are the next commit's business, together with the other new core classes). * bare ``AllColumnsWildcard``, ``AllColumnsWildcard(relation=...)`` and ``FunctionCall(COUNT, RowCountWildcard)`` render ``*`` / ``o.*`` / ``COUNT(*)``. * ruff on the changed files: clean. --- .../schema_namespace.md | 40 +++++++++---------- .../schema_namespace.md | 37 ++++++++--------- .../backend/impl/bigquery/mixins/dql.py | 9 +---- 3 files changed, 38 insertions(+), 48 deletions(-) diff --git a/docs/en_US/bigquery_specific_features/schema_namespace.md b/docs/en_US/bigquery_specific_features/schema_namespace.md index 3a35b34..498ec09 100644 --- a/docs/en_US/bigquery_specific_features/schema_namespace.md +++ b/docs/en_US/bigquery_specific_features/schema_namespace.md @@ -179,34 +179,33 @@ documented table-name forms are `mytable`, `dataset.mytable` and [from-clause]: https://cloud.google.com/bigquery/docs/reference/standard-sql/query-syntax#from_clause `BigQueryDialect` enforces this by overriding `format_column` rather than -letting the core renderer qualify the column, and `format_wildcard` does the -same for `table.*`: +letting the core renderer qualify the column; the wildcard itself is core's +default spelling, and `format_all_columns_wildcard` does the same for +`table.*` (the qualifier rides the relation object, so what is rendered is the +name the query itself uses): ```python Column(d, "id", table="orders").to_sql()[0] # `orders`.`id` -WildcardExpression(d, table="orders").to_sql()[0] +AllColumnsWildcard(d, relation=NamedRelationRef(d, Table(d, "orders"))).to_sql()[0] # `orders`.* -WildcardExpression(d).to_sql()[0] +AllColumnsWildcard(d).to_sql()[0] # * ``` -`format_wildcard` is the one formatter that discards a `schema_name` without -rendering it and without validating it, an empty string included: +The asterisk is split by grammar position (spec 4.10): the select list and +RETURNING take `AllColumnsWildcard`, and `COUNT`'s argument takes +`RowCountWildcard`. The two are not interchangeable. An +`AllColumnsWildcard`'s relation is a `NamedRelationRef`, which carries both the +name and the qualifier, so the alias a query uses is the alias rendered -- +BigQuery need not discard or validate a dataset here. -```python -WildcardExpression(d, table="orders", schema_name="app").to_sql()[0] -# `orders`.* - -WildcardExpression(d, table="orders", schema_name="").to_sql()[0] -# `orders`.* -``` - -Nothing is lost from the output, since a wildcard carries no dataset to begin -with. The asymmetry is worth noting next to `format_column`, which does refuse an -empty dataset. +One BigQuery-specific asterisk spelling does **not** enter core: the wildcard +table (`dataset.*`). It is a dataset expansion in a Table position, not a +column expansion and not a row count, so it keeps its backend-side form rather +than being pressed onto either core wildcard node. Two further consequences of the override are worth stating precisely, because they differ from the core renderer. Both follow from one declared capability, @@ -765,11 +764,12 @@ count: **This library currently carries one of them.** Stated plainly: > **The table, view, column and index expressions in this backend accept a -> `schema_name` and nothing above it. `TableExpression`, `Column` and -> `WildcardExpression` have no project field, +> `schema_name` and nothing above it. `TableExpression`, `Column` and the +> wildcard nodes have no project field, > and no dialect hook adds one. A dataset is always rendered as exactly one > quoted segment followed by one dot, so a dotted value stays inside that -> segment.** (`Column` and `WildcardExpression` discard the value; see +> segment.** (A wildcard's qualifier rides its `NamedRelationRef`, and `Column` +> checks then discards the value; see > [Columns never carry the dataset](#columns-never-carry-the-dataset). On an index > statement `schema_name` covers the index name only — the table reference has its > own dataset.) diff --git a/docs/zh_CN/bigquery_specific_features/schema_namespace.md b/docs/zh_CN/bigquery_specific_features/schema_namespace.md index 23f79ad..3547ac4 100644 --- a/docs/zh_CN/bigquery_specific_features/schema_namespace.md +++ b/docs/zh_CN/bigquery_specific_features/schema_namespace.md @@ -164,33 +164,30 @@ dataset 限定 `FROM` 里的范围,到此为止。列引用是「表名或别 [from-clause]: https://cloud.google.com/bigquery/docs/reference/standard-sql/query-syntax#from_clause -`BigQueryDialect` 的做法是覆盖 `format_column`,不让核心渲染器去限定列; -`format_wildcard` 对 `table.*` 做的是同一件事: +`BigQueryDialect` 的做法是覆盖 `format_column`,不让核心渲染器去限定列;通配符 +本身就是核心的默认拼写,`format_all_columns_wildcard` 对 `table.*` 做的是同一件事 +(`rel.*` 的限定骑在 relation 对象上,渲染出的就是查询所用的名字): ```python Column(d, "id", table="orders").to_sql()[0] # `orders`.`id` -WildcardExpression(d, table="orders").to_sql()[0] +AllColumnsWildcard(d, relation=NamedRelationRef(d, Table(d, "orders"))).to_sql()[0] # `orders`.* -WildcardExpression(d).to_sql()[0] +AllColumnsWildcard(d).to_sql()[0] # * ``` -`format_wildcard` 是唯一一个既不渲染 `schema_name` 也不校验它的格式化方法,空串 -也一样放过: +通配符按文法位置分成两个节点(规范 4.10):选择列表与 RETURNING 位是 +`AllColumnsWildcard`,`COUNT` 的参数位是 `RowCountWildcard`,二者不可互换。 +`AllColumnsWildcard` 的 relation 是一个 `NamedRelationRef`,它的名字与限定符都从 +relation 对象上取,因此 query 里用的别名就是渲染出来的别名——BigQuery 不需要在这里 +丢弃或校验 dataset。 -```python -WildcardExpression(d, table="orders", schema_name="app").to_sql()[0] -# `orders`.* - -WildcardExpression(d, table="orders", schema_name="").to_sql()[0] -# `orders`.* -``` - -输出并没有因此少掉什么:通配符本来就不带 dataset。值得记下的是它与 -`format_column` 的不对称——后者会拒绝空串。 +BigQuery 还有一处后端专属的星号写法**不进入核心**:通配符表(`dataset.*`)。它是 +Table 位置上的一个数据集展开,不是列展开,也不是行计数,语义与上面两个节点都不同, +因此保持后端侧形态,不由核心的任一通配符节点承担。 覆盖带来的另外两点后果也需要讲清楚,因为它们与核心渲染器不同: @@ -710,12 +707,12 @@ BigQuery 的全限定表名由三段组成,参考文档把段数写明了: **本库目前只带其中一级。** 说清楚: > **本后端的表、视图、列、索引表达式只接受 `schema_name`,不接受它上面的任何一级。 -> `TableExpression`、`Column`、`WildcardExpression` 都没有 project 字段, +> `TableExpression`、`Column` 与两个通配符节点都没有 project 字段, > 也没有可以补上的方言钩子。 > dataset 一律渲染成恰好一个带引号的段加一个点号,因此写进去的点号会留在这一段 -> 内部。**(`Column` 与 `WildcardExpression` 会把这个值丢弃,见 -> [列引用永远不带 dataset](#列引用永远不带-dataset);索引语句上的 `schema_name` -> 只管索引名,表引用自带自己的 dataset。) +> 内部。**(通配符的限定符骑在它的 `NamedRelationRef` 上;`Column` 会校验后丢弃这个 +> 值,见 [列引用永远不带 dataset](#列引用永远不带-dataset);索引语句上的 +> `schema_name` 只管索引名,表引用自带自己的 dataset。) ```python TableExpression(d, "orders", schema_name="myproj.app").to_sql()[0] diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/dql.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/dql.py index d026864..f99246d 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/dql.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/dql.py @@ -8,7 +8,7 @@ from rhosocial.activerecord.backend.expression.query_sources import SetOperationStatement if TYPE_CHECKING: - from rhosocial.activerecord.backend.expression.core import Column, WildcardExpression + from rhosocial.activerecord.backend.expression.core import Column class BigQueryDQLMixin: @@ -155,13 +155,6 @@ def format_column(self, expr: Column) -> Tuple[str, tuple]: col_sql = f"{col_sql} AS {self.format_identifier(expr.alias, expr.alias_need_quote)}" return col_sql, () - def format_wildcard(self, expr: WildcardExpression) -> Tuple[str, tuple]: - """Wildcard references in BigQuery use the (2-part) table name only.""" - if expr.table: - return f"{self.format_identifier(expr.table, expr.table_need_quote)}.*", () - return "*", () - - def format_datetime_diff_expression(self, expr) -> Tuple[str, tuple]: """Format ``DATE_DIFF(end, start, part)``. From b3015a2b1db49e141e6cbbacb14a705556d646ca Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 15 Oct 2026 01:45:43 +0800 Subject: [PATCH 07/35] test(bigquery): reconcile the roundtrip matrix with the new core nodes The matrix is the exhaustiveness check the round's CI runs first, and three core waves left it short: S3's two constraint branch bases, S1-c's ``PassingClause``, and the LIKE/ILIKE constructors core's own dummy2 matrix already carries. It is now all green, run in full rather than sampled. Rows added: * ``ColumnClause`` / ``ColumnConstraintBranch`` / ``TableConstraintBranch`` -- the bases S3 introduced. Each names no kind and so declares no ``format_method``; classified as ``LEGITIMATE_NOT_IMPLEMENTED`` with the ``_NO_DISPATCH`` message, which is what the base's own ``NotImplementedError`` says, not a capability the dialect lacks. * ``xml.PassingClause`` -- S1-c's clause node. BigQuery has no SQL/XML, so it belongs in ``LEGITIMATE_NON_RENDERS`` and its ``format_passing_clause`` in ``UNMODELLED_FORMATTERS`` under the existing SQL/XML group, which is what the omission reports. * ``predicates.LikePredicate`` / ``predicates.ILikePredicate`` -- registered constructors, copied from core's dummy2 matrix so the two agree. The generic guess fills ``op`` (LIKE's operator string) and the operands with bare strings, so the formatter calls ``.to_sql()`` on a ``str`` and raises ``AttributeError``. The constructors are shape-for-shape what dummy2 uses. Two dispatch-failure fragments were also the old formatter spellings, fixed in the rename wave: ``format_xmlexists_predicate`` (was ``format_xmlexists_expression``) and ``format_ilike_predicate`` (was ``format_ilike_expression``), in both the per-class pins and the ``UNMODELLED_FORMATTERS`` rows. Verification: tests/.../feature/backend/test_expression_roundtrip_all.py, full run, exit 0 (the four UUID/CustomType skips are the pinned UNCONSTRUCTIBLE entries). Coverage report: 313 classes -- 189 rendered, 54 unsupported, 51 no-formatter, 15 not-implemented, 4 unconstructible. The whole feature/backend tree, exit 0. --- .../backend/test_expression_roundtrip_all.py | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py index dc42578..b2292a8 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py @@ -131,7 +131,11 @@ Trigger, View, ) -from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.predicates import ( + ComparisonPredicate, + ILikePredicate, + LikePredicate, +) from rhosocial.activerecord.backend.expression.query_parts import JoinClause from rhosocial.activerecord.backend.expression.serialization import ( ExpressionRegistry, @@ -591,6 +595,19 @@ def register_specials(): ), ) + # -- predicates whose operands are value nodes -------------------------- + # ``column`` and ``pattern`` are annotated loosely, so the generic guess + # leaves a bare string where the formatter calls ``.to_sql()``. Both shapes + # are core's own, copied from the core dummy2 matrix so the two agree. + register_special_constructor( + "predicates.LikePredicate", + lambda d: LikePredicate(d, "LIKE", Column(d, "a"), Literal(d, "%x%")), + ) + register_special_constructor( + "predicates.ILikePredicate", + lambda d: ILikePredicate(d, Column(d, "a"), "%x%"), + ) + # -- expressions that must not be empty --------------------------------- register_special_constructor( "advanced_functions.CaseFunction", @@ -850,6 +867,12 @@ def path_pattern(d): "rhosocial.activerecord.backend.expression.xml.XMLTableExpression": ( UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_xmltable_expression'", ), + # SQL/XML PASSING is a clause of the three XML nodes, and the dialect has + # no SQL/XML at all -- so the clause node core split out in S1-c is a gap by + # the same omission as the twelve XML expressions above. + "rhosocial.activerecord.backend.expression.xml.PassingClause": ( + UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_passing_clause'", + ), # ---- SQL/PGQ property graphs: no CREATE PROPERTY GRAPH ------------------ "rhosocial.activerecord.backend.expression.graph.CreatePropertyGraphStatement": ( UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_create_property_graph_statement'", @@ -1025,6 +1048,18 @@ def path_pattern(d): _NO_DISPATCH ), "rhosocial.activerecord.backend.expression.objects.type_.TypeObject": _NO_DISPATCH, + # The roots of the constraint and column-clause trees. Each concrete member + # names its own formatter; the branch bases name no kind, so there is + # nothing to dispatch to. Added by core's S3 two-level constraint nodes. + "rhosocial.activerecord.backend.expression.statements.ddl_table.ColumnClause": ( + _NO_DISPATCH + ), + "rhosocial.activerecord.backend.expression.statements.ddl_table.ColumnConstraintBranch": ( + _NO_DISPATCH + ), + "rhosocial.activerecord.backend.expression.statements.ddl_table.TableConstraintBranch": ( + _NO_DISPATCH + ), # An ALTER TABLE action, an INSERT row source, and the two transaction # roots: each concrete member names its own formatter. "rhosocial.activerecord.backend.expression.statements.ddl_alter.AlterTableAction": ( @@ -1113,6 +1148,7 @@ def _dispatched_formatter(exc): "format_xmlroot_expression", "format_xmlserialize_expression", "format_xmltable_expression", + "format_passing_clause", ), "SQL/PGQ property graphs (BigQuery has no CREATE PROPERTY GRAPH, no " "GRAPH_TABLE and no Cypher)": ( From 6011a992cada9a8a62d1066a0a7965267af49a78 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 15 Oct 2026 01:52:04 +0800 Subject: [PATCH 08/35] ci: point testsuite at the matching branch The constraint-gates work this branch carries is not on any release branch, so neither dependency can come from one. CORE_PKG already points at fix/column-constraint-gates; TESTSUITE_PKG still pinned release/v1.0.0.dev17, which does not carry the W2/W3/S3 adaptation the shared feature/ddl contract now requires. It moves to the same branch, matching the core's own CI (python-activerecord@fix/column-constraint-gates), so the two refs move together. The env comment above is updated to say so plainly, rather than continuing to describe both refs as release branches. Verified locally against the pinned refs' working trees in the central environment: testsuite feature/ddl shared contract, exit 0. --- .github/workflows/test.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f16023f..01ee97d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,10 +19,13 @@ permissions: env: # rhosocial-activerecord and rhosocial-activerecord-testsuite dev releases are - # not published to PyPI; install them from the release branches. + # not published to PyPI; install them from git. # The schema-object refactor this branch depends on was developed on the core # library's expression-schema-object-refactor branch and merged to - # release/v1.0.0.dev30 (PR #125), which is the ref CORE_PKG pins. Two of the + # release/v1.0.0.dev30 (PR #125). This branch now pins CORE_PKG at its own + # development branch -- the constraint-gates work it carries is not on any + # release branch yet -- and pins TESTSUITE_PKG at the matching branch for the + # same reason, so the two move together. Two of the # four names a previous revision of this comment listed -- QualifiedNameMixin # and CatalogSupport -- are ones that work *deleted*, along with the # single-`supports_schema_qualification` object model this backend used to @@ -42,7 +45,7 @@ env: # of that layer, so the imports this backend now makes do not resolve against # it. CORE_PKG: "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@fix/column-constraint-gates" - TESTSUITE_PKG: "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + TESTSUITE_PKG: "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates" jobs: test: From 20eae6707e29d091694036017fe08b2cf89dbff5 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 17 Oct 2026 02:05:26 +0800 Subject: [PATCH 09/35] refactor(expression): follow the core renames and the split registry Carry the W3c class renames, the R1 signature alignment and the R2a arithmetic/concatenation renaming through the backend sources and tests. The roundtrip matrix moves off the retired class-based registry onto the serialization class index, and registers the two core classes whose op is a closed word set. --- .../impl/bigquery/backend/async_backend.py | 4 +- .../backend/impl/bigquery/backend/backend.py | 4 +- .../backend/impl/bigquery/mixins/dql.py | 100 ++++++--------- .../backend/impl/bigquery/mixins/trim.py | 4 +- .../backend/ddl/test_type_domain_negative.py | 8 +- .../test_bigquery_capability_assertions.py | 4 +- .../test_bigquery_clause_pair_guard.py | 120 ++++++++++-------- .../backend/test_expression_roundtrip_all.py | 67 ++++++---- 8 files changed, 163 insertions(+), 148 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/backend/async_backend.py b/src/rhosocial/activerecord/backend/impl/bigquery/backend/async_backend.py index 257c49e..9d51ea6 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/backend/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/backend/async_backend.py @@ -225,7 +225,7 @@ async def bulk_update(self, options) -> QueryResult: from rhosocial.activerecord.backend.expression import ( Column, Literal, UpdateStatement, ComparisonPredicate, InPredicate, ) - from rhosocial.activerecord.backend.expression.advanced_functions import CaseFunction + from rhosocial.activerecord.backend.expression.advanced_functions import CaseExpression from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType @@ -238,7 +238,7 @@ async def bulk_update(self, options) -> QueryResult: condition = ComparisonPredicate(self.dialect, "=", pk_col, Literal(self.dialect, pk_val)) result_expr = Literal(self.dialect, field_val) cases.append((condition, result_expr)) - assignments[field_name] = CaseFunction( + assignments[field_name] = CaseExpression( self.dialect, cases=cases, else_result=Column(self.dialect, field_name), ) pk_literals = Literal(self.dialect, options.pk_values) diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/backend/backend.py b/src/rhosocial/activerecord/backend/impl/bigquery/backend/backend.py index 89ee512..04a55c2 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/backend/backend.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/backend/backend.py @@ -621,7 +621,7 @@ def bulk_update(self, options) -> QueryResult: from rhosocial.activerecord.backend.expression import ( Column, Literal, UpdateStatement, ComparisonPredicate, InPredicate, ) - from rhosocial.activerecord.backend.expression.advanced_functions import CaseFunction + from rhosocial.activerecord.backend.expression.advanced_functions import CaseExpression from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType @@ -634,7 +634,7 @@ def bulk_update(self, options) -> QueryResult: condition = ComparisonPredicate(self.dialect, "=", pk_col, Literal(self.dialect, pk_val)) result_expr = Literal(self.dialect, field_val) cases.append((condition, result_expr)) - assignments[field_name] = CaseFunction( + assignments[field_name] = CaseExpression( self.dialect, cases=cases, else_result=Column(self.dialect, field_name), ) diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/dql.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/dql.py index f99246d..e7678b7 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/dql.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/dql.py @@ -4,17 +4,26 @@ from typing import Tuple, TYPE_CHECKING -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError -from rhosocial.activerecord.backend.expression.query_sources import SetOperationStatement +from rhosocial.activerecord.backend.expression.query_sources import SetOperation if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.core import Column +#: The node's operation words mapped to the spelling GoogleSQL needs. Every +#: entry carries the explicit ALL/DISTINCT qualifier the grammar requires -- +#: see the method docstring for why none of them can drop it. +_OP_SPELLINGS = { + "union": "UNION DISTINCT", + "union_all": "UNION ALL", + "intersect": "INTERSECT DISTINCT", + "except": "EXCEPT DISTINCT", +} + class BigQueryDQLMixin: """BigQuery DQL formatting overrides.""" - def format_set_operation_statement(self, expr: SetOperationStatement) -> Tuple[str, tuple]: + def format_set_operation(self, expr: SetOperation) -> Tuple[str, tuple]: """Format a set operation with GoogleSQL's required qualifier. BigQuery's grammar has no bare set operator:: @@ -22,73 +31,42 @@ def format_set_operation_statement(self, expr: SetOperationStatement) -> Tuple[s set_operator: UNION { ALL | DISTINCT } | INTERSECT DISTINCT | EXCEPT DISTINCT - so the pair is consumed three ways, not two: - - * ``all_`` -> `` ALL``, and only for ``UNION``: the grammar has no - ``INTERSECT ALL`` / ``EXCEPT ALL``, so those requests are refused by - name rather than emitted; - * ``distinct`` -> `` DISTINCT``; - * neither -> `` DISTINCT`` as well. BigQuery cannot spell the absence - of the qualifier, and ``DISTINCT`` is the standard's default for a - bare ``UNION`` (the Redshift migration guide rewrites a bare - ``UNION`` as ``UNION DISTINCT``); the audit records this dialect - property as "BigQuery: falsy forces DISTINCT, so 'unspecified' is - unreachable there". Emitting the bare operator would be invalid - SQL, and refusing would break every ORM ``union()`` call that leaves - the qualifier unset, so the dialect spells its required default. - - The two requests are distinguishable at the API (one sets a - parameter); they cannot be distinguished in SQL on this dialect - because there is no second spelling for "no qualifier". + The node names the operation with a **word** (``union`` / + ``union_all`` / ``intersect`` / ``except``) and this method maps each + word to the spelling this backend writes. Every spelling carries an + explicit ``ALL`` or ``DISTINCT``, because the grammar has no bare + form: a bare SQL-standard ``UNION`` means ``UNION DISTINCT`` (the + Redshift-to-BigQuery migration guide rewrites it that way), and + emitting the bare operator would be invalid SQL. - Raises: - UnsupportedFeatureError: ``all_`` requested for an operation - whose grammar is DISTINCT-only. + That the qualifier is mandatory is now *structural* rather than a + refusal this formatter has to raise: the word set has no member that + asks for an unspelled operator, so "neither set nor a qualifier is + requested" cannot be written at all. ``union`` and ``union_all`` are + told apart by the modifier alone. + + The grammar has no ``INTERSECT ALL`` / ``EXCEPT ALL``. That too is now + structural: there is no ``intersect_all`` / ``except_all`` word, so the + request cannot be spelled and there is nothing left to refuse by name. + + Args: + expr: The node exposing ``op`` (a word form), ``left`` and + ``right``. + + Returns: + Tuple of (SQL string, parameters tuple), carrying the operands' + parameters in order. """ def _render(node) -> Tuple[str, list]: sql, params = node.to_sql() - if isinstance(node, SetOperationStatement): + if isinstance(node, SetOperation): sql = f"({sql})" return sql, list(params) left_sql, left_params = _render(expr.left) right_sql, right_params = _render(expr.right) - operation = expr.operation - if expr.all_: - if operation.strip().upper() != "UNION": - raise UnsupportedFeatureError( - self.name, - f"{operation} ALL", - suggestion=( - "BigQuery's set-operator grammar is " - "UNION { ALL | DISTINCT } | INTERSECT DISTINCT | " - "EXCEPT DISTINCT." - ), - ) - qualifier = " ALL" - elif expr.distinct: - qualifier = " DISTINCT" - else: - # See the docstring: the dialect's grammar requires a qualifier, - # and DISTINCT is the default spelling of the unqualified request. - qualifier = " DISTINCT" - base_sql = f"{left_sql} {operation}{qualifier} {right_sql}" - all_params = left_params + right_params - sql_parts = [base_sql] - if expr.alias: - sql_parts.append(f"AS {self.format_identifier(expr.alias)}") - if expr.for_update_clause: - raise UnsupportedFeatureError( - self.name, - "FOR UPDATE in set operations", - "BigQuery does not support FOR UPDATE in a set operation.", - ) - for clause in (expr.order_by_clause, expr.limit_offset_clause): - if clause: - clause_sql, clause_params = clause.to_sql() - sql_parts.append(clause_sql) - all_params.extend(clause_params) - return " ".join(sql_parts), tuple(all_params) + base_sql = f"{left_sql} {_OP_SPELLINGS[expr.op]} {right_sql}" + return base_sql, tuple(left_params + right_params) def supports_fetch_with_ties(self) -> bool: """BigQuery does not support FETCH ... WITH TIES.""" diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/trim.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/trim.py index 489f4e6..3e49b31 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/trim.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/trim.py @@ -23,7 +23,7 @@ from typing import Tuple, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover - from ...expression.advanced_functions import TrimFunction + from ...expression.advanced_functions import TrimExpression class BigQueryTrimMixin: @@ -31,7 +31,7 @@ class BigQueryTrimMixin: _TRIM_FUNCTIONS = {"BOTH": "TRIM", "LEADING": "LTRIM", "TRAILING": "RTRIM"} - def format_trim_expression(self, expr: "TrimFunction") -> Tuple[str, Tuple]: + def format_trim_expression(self, expr: "TrimExpression") -> Tuple[str, Tuple]: """Format a TRIM node in BigQuery's form. Args: 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 index abb4a67..88831f4 100644 --- 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 @@ -20,7 +20,7 @@ DropTypeSupport, ) from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError -from rhosocial.activerecord.backend.expression import BaseExpression, Literal +from rhosocial.activerecord.backend.expression import BaseExpression, DdlLiteral from rhosocial.activerecord.backend.expression.objects import Domain, Schema, Type from rhosocial.activerecord.backend.expression.statements import ( AlterDomainStatement, @@ -147,11 +147,7 @@ def _type_nodes(dialect: BigQueryDialect) -> Tuple[BaseExpression, ...]: def _domain_nodes(dialect: BigQueryDialect) -> Tuple[BaseExpression, ...]: - condition = DomainValueExpression(dialect) > Literal( - dialect, - 0, - inline_literals=True, - ) + condition = DomainValueExpression(dialect) > DdlLiteral(dialect, 0) check = DomainCheckConstraint(dialect, condition, name="nonnegative") action = DropDomainDefaultAction(dialect) return ( diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_capability_assertions.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_capability_assertions.py index fb6ccc7..6e08ff2 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_capability_assertions.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_capability_assertions.py @@ -10,7 +10,7 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression import ( - BinaryArithmeticExpression, + ArithmeticOperation, Column, CreateTableStatement, CreateViewStatement, @@ -79,7 +79,7 @@ def test_generated_stored_column_renders(dialect): IntegerType(dialect), generated_expression=ColumnGeneratedConstraint( dialect, - BinaryArithmeticExpression(dialect, "+", Column(dialect, "a"), Column(dialect, "b")), + ArithmeticOperation(dialect, "add", Column(dialect, "a"), Column(dialect, "b")), storage_type=GeneratedColumnType.STORED, ), ) diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_clause_pair_guard.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_clause_pair_guard.py index 65fd905..7e9d9d3 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_clause_pair_guard.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_clause_pair_guard.py @@ -3,7 +3,7 @@ The expressiveness round split every two-spelling clause into two independent parameters (``cycle`` / ``no_cycle``, ``cascade`` / ``restrict``, -``with_data`` / ``no_data``, ``all_`` / ``distinct``, ...); "unspecified" is +``with_data`` / ``no_data``, ...); "unspecified" is the state where none of a pair's parameters is set, and setting both raises ``ValueError`` at construction. For every pair a formatter here reads, this file asserts what each of the four states produces: @@ -19,9 +19,14 @@ dropped); the refusal is part of the pair's state table, so "cannot express" is an asserted outcome rather than a silent substitution. Where a dialect's grammar makes a spelling mandatory, the "neither" state maps to the grammar's -required default and that mapping is asserted explicitly -- see -:class:`TestSetOperationQualifier`, the one pair in this file whose grammar -has no bare form. +required default and that mapping is asserted explicitly. + +One pair is **out of scope** because it is no longer a pair: the set +operation's ``all_`` / ``distinct`` flags. The node now names its operation +with a word drawn from a closed set the node validates at construction, so +there is no "unspecified" state to spell and nothing to refuse. +:class:`TestSetOperationSpelling` guards that word-to-spelling mapping +instead. **Not server-verified.** No BigQuery instance exists in this repository and CI has none; every assertion here is a render assertion. The mapping @@ -42,7 +47,7 @@ Table, ) from rhosocial.activerecord.backend.expression.query_sources import ( - SetOperationStatement, + SetOperation, ) from rhosocial.activerecord.backend.expression.statements.ddl_schema import ( DropSchemaStatement, @@ -73,8 +78,8 @@ def _query(d, table="t"): return QueryStatement(d, select=[Column(d, "a")], from_=Table(d, table)) -class TestSetOperationQualifier: - """``SetOperationStatement.all_`` / ``distinct``. +class TestSetOperationSpelling: + """The four operation words and the spelling each one gets here. BigQuery's grammar has no bare set operator:: @@ -82,69 +87,84 @@ class TestSetOperationQualifier: UNION { ALL | DISTINCT } | INTERSECT DISTINCT | EXCEPT DISTINCT (GoogleSQL DDL/query reference; the Redshift-to-BigQuery migration guide - rewrites a bare ``UNION`` as ``UNION DISTINCT``, and the audit records - "BigQuery: falsy forces DISTINCT, so 'unspecified' is unreachable there"). - - The dialect therefore spells the unspecified state with its grammar's - required default, ``DISTINCT``; it does not emit the invalid bare form and - it does not refuse the ORM's default ``union()`` path. The explicit - ``distinct=True`` state renders the same SQL: on this dialect the two - requests cannot be distinguished in SQL, because there is no second - spelling for "no qualifier". This is the one pair in this file where the - four states are not pairwise distinguishable in SQL; the two requests stay - distinguishable at the API (one sets a parameter), and the audit entry is - the recorded reason. + rewrites a bare ``UNION`` as ``UNION DISTINCT``.) + + The node names the operation with a word and the dialect maps it. Because + the grammar has no bare form, every spelling carries an explicit ``ALL`` or + ``DISTINCT``, and ``union`` / ``union_all`` are told apart by the modifier. + + The former ``all_`` / ``distinct`` flag pair is gone, and with it the one + intentional SQL collision this file recorded ("falsy forces DISTINCT, so + 'unspecified' is unreachable there"). That collision cannot occur now: the + word set has no duplicate member, and an unknown word is refused at + construction. The ``INTERSECT ALL`` / ``EXCEPT ALL`` refusals are likewise + structural -- there is no word for them -- so there is nothing left to + assert as a named refusal. """ - def _expr(self, dialect, operation="UNION", **kwargs): - return SetOperationStatement( + def _expr(self, dialect, op="union"): + return SetOperation( dialect, + op=op, left=_query(dialect, "t1"), right=_query(dialect, "t2"), - operation=operation, - **kwargs, ) - def test_neither_spells_the_grammar_required_default(self, dialect): + def test_union_spells_distinct(self, dialect): sql = self._expr(dialect).to_sql()[0] assert sql == ( "SELECT `a` FROM `t1` UNION DISTINCT SELECT `a` FROM `t2`" ), sql - def test_all_spells_all(self, dialect): - sql = self._expr(dialect, all_=True).to_sql()[0] + def test_union_all_spells_all(self, dialect): + sql = self._expr(dialect, "union_all").to_sql()[0] assert sql == "SELECT `a` FROM `t1` UNION ALL SELECT `a` FROM `t2`", sql - def test_distinct_spells_distinct(self, dialect): - sql = self._expr(dialect, distinct=True).to_sql()[0] - assert sql == ( - "SELECT `a` FROM `t1` UNION DISTINCT SELECT `a` FROM `t2`" - ), sql + def test_the_two_union_words_are_distinguishable(self, dialect): + assert self._expr(dialect).to_sql()[0] != self._expr(dialect, "union_all").to_sql()[0] - def test_both_parameters_are_api_misuse(self, dialect): - with pytest.raises(ValueError, match="all_ and distinct are mutually exclusive"): - self._expr(dialect, all_=True, distinct=True) - - def test_intersect_all_is_refused_by_name(self, dialect): - """BigQuery has ``INTERSECT DISTINCT`` only; ALL is not in the grammar.""" - with pytest.raises(UnsupportedFeatureError) as exc: - self._expr(dialect, operation="INTERSECT", all_=True).to_sql() - assert exc.value.feature_name == "INTERSECT ALL" - - def test_except_all_is_refused_by_name(self, dialect): - """BigQuery has ``EXCEPT DISTINCT`` only; ALL is not in the grammar.""" - with pytest.raises(UnsupportedFeatureError) as exc: - self._expr(dialect, operation="EXCEPT", all_=True).to_sql() - assert exc.value.feature_name == "EXCEPT ALL" - - def test_distinct_on_intersect_and_except_is_expressible(self, dialect): - assert self._expr(dialect, operation="INTERSECT", distinct=True).to_sql()[0] == ( + def test_intersect_and_except_spell_distinct(self, dialect): + assert self._expr(dialect, "intersect").to_sql()[0] == ( "SELECT `a` FROM `t1` INTERSECT DISTINCT SELECT `a` FROM `t2`" ) - assert self._expr(dialect, operation="EXCEPT", distinct=True).to_sql()[0] == ( + assert self._expr(dialect, "except").to_sql()[0] == ( "SELECT `a` FROM `t1` EXCEPT DISTINCT SELECT `a` FROM `t2`" ) + def test_intersect_all_and_except_all_cannot_be_spelled(self, dialect): + """The grammar has no ``INTERSECT ALL`` / ``EXCEPT ALL``, and now + neither has the node: there is no word to ask for them.""" + for word in ("intersect_all", "except_all", "minus", "UNION ALL"): + with pytest.raises(ValueError, match="op must be one of"): + self._expr(dialect, word) + + def test_the_word_is_case_insensitive_and_normalized(self, dialect): + expr = self._expr(dialect, "UNION_ALL") + assert expr.op == "union_all" + assert "UNION ALL" in expr.to_sql()[0] + + def test_every_operation_word_is_covered_and_distinct(self, dialect): + """Each word renders its own SQL, and no bare operator is ever emitted.""" + rendered = {} + for op in ("union", "union_all", "intersect", "except"): + sql = self._expr(dialect, op).to_sql()[0] + rendered[op] = sql + operator = sql.split(" FROM `t1` ")[1].split(" SELECT")[0] + assert "ALL" in operator or "DISTINCT" in operator, (op, sql) + assert len(set(rendered.values())) == 4, rendered + + def test_nested_set_operations_stay_parenthesized(self, dialect): + inner = self._expr(dialect, "union_all") + nested = SetOperation( + dialect, + op="union", + left=inner, + right=_query(dialect, "t3"), + ) + sql = nested.to_sql()[0] + assert sql.startswith("("), sql + assert ") UNION DISTINCT " in sql, sql + class TestIdentityClausePairs: """``IdentityClause`` cycle / order / cache pairs. diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py index b2292a8..cb7871c 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py @@ -81,7 +81,7 @@ Classes are found by walking the two packages with ``collect_expression_classes``, never by reading -``ExpressionRegistry._registry``. The registry is process-global: it grows as +``registered_expression_classes()``. The registry is process-global: it grows as any sibling test module imports another backend, so a matrix built from it holds whatever the import order happened to produce -- core measured three different counts for the same tree that way. Walking the packages is @@ -105,17 +105,19 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression import graph as graph_mod from rhosocial.activerecord.backend.expression.advanced_functions import ( - CaseFunction, + CaseExpression, WindowClause, WindowDefinition, WindowSpecification, ) from rhosocial.activerecord.backend.expression.bases import BaseExpression from rhosocial.activerecord.backend.expression.core import Column, Literal +from rhosocial.activerecord.backend.expression.operators import ArithmeticOperation +from rhosocial.activerecord.backend.expression.query_sources import SetOperation from rhosocial.activerecord.backend.expression.datetime import ( TemporalOptionsExpression, ) -from rhosocial.activerecord.backend.expression.operators import RawSQLExpression +from rhosocial.activerecord.backend.expression.operators import RawSQL from rhosocial.activerecord.backend.expression.objects import ( Database, Domain, @@ -138,10 +140,11 @@ ) from rhosocial.activerecord.backend.expression.query_parts import JoinClause from rhosocial.activerecord.backend.expression.serialization import ( - ExpressionRegistry, + auto_register_builtins, deserialize, deserialize_json, deserialize_xml, + registered_expression_classes, serialize, serialize_json, serialize_xml, @@ -295,6 +298,24 @@ def _integer_column(dialect, name="col"): def register_specials(): """Replace every introspective guess that would cost a real assertion.""" + # -- nodes whose ``op`` is a closed word set, not a free string ---------- + # Both refuse an ``op`` outside their set at construction, so the + # introspective guess (which supplies a placeholder string) cannot build + # them at all. Naming one real word is what lets the matrix exercise them. + register_special_constructor( + "operators.ArithmeticOperation", + lambda d: ArithmeticOperation(d, "add", Column(d, "a"), Literal(d, 1)), + ) + register_special_constructor( + "query_sources.SetOperation", + lambda d: SetOperation( + d, + op="union", + left=_one_column_query(d), + right=_one_column_query(d), + ), + ) + # -- the FROM side ------------------------------------------------------ register_special_constructor( "sources.relation.NamedRelationRef", @@ -610,8 +631,8 @@ def register_specials(): # -- expressions that must not be empty --------------------------------- register_special_constructor( - "advanced_functions.CaseFunction", - lambda d: CaseFunction( + "advanced_functions.CaseExpression", + lambda d: CaseExpression( d, cases=[(_column_predicate(d), Literal(d, 1))], else_result=Literal(d, 0), @@ -741,7 +762,7 @@ def path_pattern(d): register_special_constructor( "xml.XMLTableExpression", lambda d: XMLTableExpression( - d, RawSQLExpression(d, "SELECT 1 AS a"), [XMLTableColumn("a", "int")] + d, RawSQL(d, "SELECT 1 AS a"), [XMLTableColumn("a", "int")] ), ) @@ -951,11 +972,11 @@ def path_pattern(d): "rhosocial.activerecord.backend.expression.statements.ddl_partition.PartitionClause": ( UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_partition_clause'", ), - "rhosocial.activerecord.backend.expression.pivot.PivotExpression": ( - UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_pivot_expression'", + "rhosocial.activerecord.backend.expression.pivot.Pivot": ( + UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_pivot'", ), - "rhosocial.activerecord.backend.expression.pivot.UnpivotExpression": ( - UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_unpivot_expression'", + "rhosocial.activerecord.backend.expression.pivot.Unpivot": ( + UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_unpivot'", ), "rhosocial.activerecord.backend.expression.predicates.ILikePredicate": ( UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_ilike_predicate'", @@ -1191,8 +1212,8 @@ def _dispatched_formatter(exc): "the core PIVOT / UNPIVOT expressions (this backend composes no formatter " "for either; aggregation over rotated rows is spelled by conditional " "aggregation)": ( - "format_pivot_expression", - "format_unpivot_expression", + "format_pivot", + "format_unpivot", ), "ILIKE (BigQuery has no ILIKE; case-insensitive matching goes through " "LOWER() comparisons)": ("format_ilike_predicate",), @@ -1322,7 +1343,7 @@ def expr_case(request, dialect): + "rescue it, so there is nothing for the matrix to assert on." ) cls = REGISTERED[fqn] - ExpressionRegistry._auto_register_builtins() + auto_register_builtins() # The four classes this backend defines are not core's built-ins, so the # registry cannot find them by name when it decodes a nested one. register_all({fqn: cls}) @@ -1355,7 +1376,7 @@ def test_nothing_is_skipped(self, dialect): :data:`UNCONSTRUCTIBLE` both ways, so a class that stops building fails with its own name and a named class that starts building fails too. """ - ExpressionRegistry._auto_register_builtins() + auto_register_builtins() actual = tuple( sorted( fqn @@ -1394,7 +1415,7 @@ def test_pinned_non_renders_still_raise_what_they_claim(self, dialect): Without this an entry could sit in the table for a class that now renders perfectly well, and the matrix would be asserting nothing about it. """ - ExpressionRegistry._auto_register_builtins() + auto_register_builtins() for fqn, (expected_type, fragment) in LEGITIMATE_NON_RENDERS.items(): instance, source = make_instance(REGISTERED[fqn], dialect) assert instance is not None, ( @@ -1410,7 +1431,7 @@ def test_pinned_non_renders_still_raise_what_they_claim(self, dialect): def test_pinned_not_implementeds_still_raise_what_they_claim(self, dialect): """Each pinned entry still raises, and says the reason the table names.""" - ExpressionRegistry._auto_register_builtins() + auto_register_builtins() for fqn, fragment in LEGITIMATE_NOT_IMPLEMENTED.items(): instance, source = make_instance(REGISTERED[fqn], dialect) assert instance is not None, ( @@ -1448,7 +1469,7 @@ def test_unmodelled_formatter_list_is_exact(self, dialect): what would have said so loudly if a fifth node of that family had arrived without a BigQuery formatter. """ - ExpressionRegistry._auto_register_builtins() + auto_register_builtins() register_all(REGISTERED) observed = set() for fqn in sorted(REGISTERED): @@ -1487,7 +1508,7 @@ class that appeared after import is caught. The package walk is used rather than the registry because the registry also holds whatever backends other test modules happened to import. """ - ExpressionRegistry._auto_register_builtins() + auto_register_builtins() expected = set(_collect_matrix_classes()) assert expected == set(REGISTERED), ( "the set of classes the two packages define changed after " @@ -1516,7 +1537,7 @@ def test_core_classes_are_covered_not_just_this_backends_own(self): surface is narrow enough that most of the core tree is untested without this. """ - ExpressionRegistry._auto_register_builtins() + auto_register_builtins() core = set(_collect_package(CORE_EXPR_PKG)) bigquery = set(_collect_package(BIGQUERY_EXPR_PKG)) assert core, "the core package walk found nothing" @@ -1530,9 +1551,9 @@ def test_every_covered_class_is_registered_for_deserialization(self): renders but the registry cannot resolve would round-trip into the wrong thing or nothing at all. """ - ExpressionRegistry._auto_register_builtins() + auto_register_builtins() register_all(REGISTERED) - unresolved = sorted(set(REGISTERED) - set(ExpressionRegistry._registry)) + unresolved = sorted(set(REGISTERED) - set(registered_expression_classes())) assert not unresolved, ( f"the matrix covers classes the registry cannot resolve: {unresolved}" ) @@ -1547,7 +1568,7 @@ def test_coverage_report(self, dialect): after checking that it is named in :data:`UNCONSTRUCTIBLE`, so the bucket is never a silent dumping ground. """ - ExpressionRegistry._auto_register_builtins() + auto_register_builtins() register_all(REGISTERED) counts = {} for fqn, cls in REGISTERED.items(): From 039e20dc0a9da3889ef090b1c3c6477e09851e8c Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 17 Oct 2026 17:29:07 +0800 Subject: [PATCH 10/35] refactor(bigquery): read declared fields instead of probing with getattr Wave P of the 2026-10-17 value/literal/reflection guide. 15 probing sites cleared, rg '(getattr|hasattr)\(\s*(expr|node|clause|item)\b' src now has zero matches. Migration surface (old -> new): - mixins/materialized_view.py, 11 sites (the guide's whole 83-255 span): * or_replace / if_not_exists / partition_by / cluster_by / options -- declared only on BigQueryCreateMaterializedViewExpression, so the create formatter now dispatches once by isinstance on that class and then reads each field directly; a generic CreateMaterializedViewStatement declares none of them, so its grammar simply has no such clause. * column_aliases / tablespace / storage_options / with_data / no_data / query -- declared on the core CreateMaterializedViewStatement, now read directly in _reject_unsupported_clauses and _materialized_view_query_sql (both annotated with that class, matching the postgres fcc9939 form-A rationale). - mixins/constraint.py, 4 sites beyond the guide's gate (the probing variable is named `constraint`, which the gate pattern does not cover, but the fields are expression-contract declarations all the same): * enforced / not_enforced / branch on _require_bigquery_enforcement, now read directly off the ColumnConstraint/TableConstraint production nodes that declare them; parameter annotated with the union. * branch on _require_bigquery_table_kind, read directly; parameter annotated TableConstraint (its only caller). Overridden formatters: none renamed; the two constraint refusals and the create/drop/alter/replica materialized-view formatters keep their names. Behaviour change (tightening): a non-constraint object reaching _require_bigquery_* now fails loudly with AttributeError instead of being silently treated as a constraint without traits; a non-statement reaching the materialized-view helpers likewise. No rendered SQL changed -- the default branches that fired were all the declared defaults. Evidence: PYTHONPATH=src;tests pytest feature/backend/dialect/test_bigquery_materialized_view.py (52 passed), feature/backend/test_bigquery_constraint_switches.py (229 passed), feature/backend/test_bigquery_ddl_improvements.py + test_bigquery_clause_pair_guard.py (46 passed), all exit 0; ruff check on both files clean. Core import self-check OK before each run. --- .../impl/bigquery/mixins/constraint.py | 16 +++--- .../impl/bigquery/mixins/materialized_view.py | 54 +++++++++++-------- 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/constraint.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/constraint.py index 74f658a..19dd9da 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/constraint.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/constraint.py @@ -158,7 +158,7 @@ two groups are kept apart deliberately, and every docstring says which it is. """ -from typing import TYPE_CHECKING, Tuple +from typing import TYPE_CHECKING, Tuple, Union if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.expression.statements.ddl_table import ( @@ -958,7 +958,9 @@ def _bigquery_kind_sql(self, branch: object) -> str: """The SQL spelling to name a constraint branch in a refusal.""" return self._CONSTRAINT_KIND_SQL.get(type(branch).__name__, type(branch).__name__) - def _require_bigquery_enforcement(self, constraint: object) -> None: + def _require_bigquery_enforcement( + self, constraint: "Union[ColumnConstraint, TableConstraint]" + ) -> None: """Refuse ``ENFORCED`` on BigQuery, and ``NOT ENFORCED`` off a PK/FK. ``NOT ENFORCED`` is inside every BigQuery constraint production, so @@ -974,11 +976,11 @@ def _require_bigquery_enforcement(self, constraint: object) -> None: """ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - enforced = getattr(constraint, "enforced", False) - not_enforced = getattr(constraint, "not_enforced", False) + enforced = constraint.enforced + not_enforced = constraint.not_enforced if not enforced and not not_enforced: return - branch = getattr(constraint, "branch", None) + branch = constraint.branch kind = self._bigquery_kind_sql(branch) if enforced: feature = f"{kind} ENFORCED" @@ -1002,7 +1004,7 @@ def _require_bigquery_enforcement(self, constraint: object) -> None: f"{self.name} does not support {feature}.", ) - def _require_bigquery_table_kind(self, constraint: object) -> None: + def _require_bigquery_table_kind(self, constraint: "TableConstraint") -> None: """Refuse the table-level kinds BigQuery has no production for. ``constraint_definition`` admits a bare ``[primary_key]`` and @@ -1017,7 +1019,7 @@ def _require_bigquery_table_kind(self, constraint: object) -> None: """ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - kind = self._bigquery_kind_sql(getattr(constraint, "branch", None)) + kind = self._bigquery_kind_sql(constraint.branch) if kind in ("CHECK", "UNIQUE"): feature = f"{kind} table constraint" raise UnsupportedFeatureError( diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/materialized_view.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/materialized_view.py index 838c0ef..59c1530 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/materialized_view.py @@ -12,6 +12,7 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.objects import MaterializedView +from ..expression.materialized_view import BigQueryCreateMaterializedViewExpression from ..materialized_view_options import resolve_materialized_view_option if TYPE_CHECKING: # pragma: no cover @@ -79,26 +80,31 @@ def format_create_materialized_view_statement( self._require_materialized_view(expr, "CreateMaterializedViewStatement") self._reject_unsupported_clauses(expr, "CREATE MATERIALIZED VIEW") + # OR REPLACE, IF NOT EXISTS, PARTITION BY, CLUSTER BY and OPTIONS are + # BigQueryCreateMaterializedViewExpression's own fields. The generic + # statement declares none of them -- its grammar has no such clause to + # emit -- so they are read only where they are declared, and the + # isinstance dispatch on the declared class is what makes each read a + # direct one. + is_bigquery_create = isinstance(expr, BigQueryCreateMaterializedViewExpression) + parts = ["CREATE"] - if getattr(expr, "or_replace", False): + if is_bigquery_create and expr.or_replace: parts.append("OR REPLACE") parts.append("MATERIALIZED VIEW") - if getattr(expr, "if_not_exists", False): + if is_bigquery_create and expr.if_not_exists: parts.append("IF NOT EXISTS") parts.append(self._materialized_view_name_sql(expr)) - 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})") + if is_bigquery_create: + if expr.partition_by: + parts.append(f"PARTITION BY {expr.partition_by}") + if expr.cluster_by: + cols = ", ".join(str(column) for column in expr.cluster_by) + parts.append(f"CLUSTER BY {cols}") + options = self._format_materialized_view_options(expr.options) + if options: + parts.append(f"OPTIONS({options})") query_sql, query_params = self._materialized_view_query_sql(expr) parts.append(f"AS {query_sql}") @@ -221,38 +227,42 @@ def _materialized_view_name_sql(self, expr: Any) -> str: name_sql, _params = expr.view.to_sql() return name_sql - def _reject_unsupported_clauses(self, expr: Any, feature: str) -> None: + def _reject_unsupported_clauses( + self, expr: "CreateMaterializedViewStatement", feature: str + ) -> None: """Reject generic-expression clauses BigQuery cannot express.""" - if getattr(expr, "column_aliases", None): + if expr.column_aliases: raise UnsupportedFeatureError( self.name, f"{feature} COLUMN ALIASES", "BigQuery materialized views take their column names from the query.", ) - if getattr(expr, "tablespace", None): + if expr.tablespace: raise UnsupportedFeatureError(self.name, f"{feature} TABLESPACE") - if getattr(expr, "storage_options", None): + if expr.storage_options: raise UnsupportedFeatureError( self.name, f"{feature} STORAGE PARAMETERS", "BigQuery configures materialized views through OPTIONS(...).", ) - if getattr(expr, "with_data", False): + if expr.with_data: raise UnsupportedFeatureError( self.name, f"{feature} WITH DATA", "BigQuery populates the view at creation time and has no WITH DATA clause.", ) - if getattr(expr, "no_data", False): + if expr.no_data: 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]: + def _materialized_view_query_sql( + self, expr: "CreateMaterializedViewStatement" + ) -> Tuple[str, tuple]: """Return the defining query SQL, accepting an expression or raw SQL.""" - query = getattr(expr, "query", None) + query = expr.query if query is None: raise ValueError("CREATE MATERIALIZED VIEW requires a defining query") if isinstance(query, str): From 391469f76557f3b6d767234164e8e73cd16bac17 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 18 Oct 2026 07:53:40 +0800 Subject: [PATCH 11/35] feat(bigquery): adopt the typed value nodes with BigQuery literal spellings Wave V of the value-literal reflection guide: wire the core TypedValueMixin and this backend's spellings onto the dialect (guide 4.1). No BigQuery instance is reachable from this repository, so every form is a rendering assertion transcribed from the official reference pages (lexical structure and syntax / data types, read 2026-10-17, not server-verified). Wiring: BigQueryTypedValueMixin ahead of core TypedValueMixin, both ahead of SQLDialectBase; mixins/__init__ exports it. The in-flight draft had the class bases without the import in dialect.py -- fixed (collection NameError). Overrides, all DDL-position only (data positions keep placeholder + host value): binary = b'\\x01\\x02' bytes literal, every byte a \\xhh escape (valid for any bytes); UUID = bare quoted text (no UUID type, GENERATE_UUID() returns STRING); INTERVAL = the unquoted single-part form INTERVAL n UNIT, INT64 step mapped over the timedelta's days/seconds/microseconds and spelled in the largest dividing unit down to MICROSECOND (YEAR/QUARTER/MONTH are calendar units a timedelta cannot state; WEEK is exactly seven DAYs); Decimal = NUMERIC '...' / BIGNUMERIC '...' keyword form (bare numeric text is a FLOAT64 literal) with NUMERIC = 29 integer digits at scale 9 and BIGNUMERIC = the documented -5.789604...9968E+38..5.789604...9967E+38 bounds (the 39th integer digit is the partial one, the negative bound one unit past the positive); date/time = keyword-prefixed typed literals, naive datetime spelled DATETIME '...' (TIMESTAMP would silently pin UTC), aware datetime TIMESTAMP '...' with its offset; an aware time is refused rather than silently zone-stripped. Dialect-wide escape policy: _escape_sql_string overridden to GoogleSQL's backslash escaping (the \\xhh/\\' table; quote doubling appears nowhere in it and any sequence not in it is an error), so every inline string this dialect renders -- value literals, OPTIONS entries, comments -- is backslash-escaped. dql.py's DATE_DIFF part site now routes through self._escape_sql_string instead of pinning SQLDialectBase's quote doubling. Behavior changes (DDL-position): inline strings containing quote or backslash render backslash-escaped instead of quote-doubled (dialect-wide); date/time/datetime/timestamp inline literals carry the type keyword; Decimal inline literals are the NUMERIC/BIGNUMERIC keyword form; aware times, Decimals past scale 38 or past the documented magnitude bounds, and an INTERVAL step outside signed INT64 now fail explicitly with ValueError. Drive-by pre-existing repair, found by ruff F821 on a changed file: format_column's refusal in dql.py has raised NameError since 1cf7c46 because UnsupportedFeatureError had no import in scope. The module-level import added here (the same pattern ddl.py and transaction.py already use) fixes it; the two red tests it caused (test_column_schema_validation.py::TestBareColumnWithSchema, test_expression_fields_match_formatters.py bare-column case) go green. Tests (exit codes): new test_bigquery_typed_values.py 56 passed, exit 0; feature/backend -k 'typed or literal' 59 passed, exit 0; feature/backend/dialect/ whole dir 253 passed, exit 0; ruff on all changed files clean. The roundtrip matrix is not collected in the -k run: core serialization has moved auto_register_builtins (now default_registry) since the guide was written and the matrix still imports the old name; the wave-4 reconciliation repairs it against the then-current core HEAD. --- .../backend/impl/bigquery/dialect.py | 32 +- .../backend/impl/bigquery/mixins/__init__.py | 2 + .../backend/impl/bigquery/mixins/dql.py | 8 +- .../impl/bigquery/mixins/typed_value.py | 264 +++++++++++++ .../dialect/test_bigquery_typed_values.py | 369 ++++++++++++++++++ 5 files changed, 668 insertions(+), 7 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/bigquery/mixins/typed_value.py create mode 100644 tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_typed_values.py diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/dialect.py b/src/rhosocial/activerecord/backend/impl/bigquery/dialect.py index 417dfce..f694183 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/dialect.py @@ -52,6 +52,11 @@ # override is needed. ``TRIM`` differs and is overridden by # BigQueryTrimMixin (below) for the reason documented there. LpadMixin, RepeatMixin, RpadMixin, + # The typed value nodes' tier-1 rendering: the core mixin supplies the + # data-position binding and the NULL/boolean/numeric/string defaults; + # BigQueryTypedValueMixin (ahead of it, from .mixins) spells the kinds + # this grammar writes differently. + TypedValueMixin, ) from .protocols import ( BigQueryArraySupport, @@ -92,8 +97,10 @@ # TRIM is the one of the four string nodes BigQuery spells differently: # the direction lives in the function name, so BigQueryTrimMixin must # outrank core's TrimMixin in the MRO. LPAD, RPAD and REPEAT are the - # shared spellings verbatim here. + # shared spellings verbatim here, and BigQueryTypedValueMixin follows + # the same pattern ahead of core's TypedValueMixin (imported above). BigQueryTrimMixin, + BigQueryTypedValueMixin, ) @@ -129,6 +136,12 @@ class BigQueryDialect( # REPEAT are the shared spellings verbatim here, so the generic mixins # answer for those three unchanged. BigQueryTrimMixin, + # Value literals: BigQuery's spellings ahead of the core TypedValueMixin, + # whose tier-1 defaults the dialect keeps for the kinds this grammar + # agrees with. Both precede SQLDialectBase, so every format_*_value the + # typed nodes dispatch to exists on the dialect. + BigQueryTypedValueMixin, + TypedValueMixin, # The backend's one naming-side mixin: both levels rendered, and # validate_catalog_name narrowed, because a BigQuery path is # project.dataset.object and a project with no dataset is not a name. @@ -200,6 +213,23 @@ def get_parameter_placeholder(self, position: int = 0) -> str: """BigQuery Standard SQL uses ``?`` positional bind markers.""" return "?" + @staticmethod + def _escape_sql_string(value: str) -> str: + """GoogleSQL's string escaping: backslashes, not quote doubling. + + The base helper doubles single quotes, which is the SQL-standard + convention. GoogleSQL documents the backslash escape ``\\'`` for a + single quote -- and, because backslashes introduce escape sequences + there at all, a literal backslash must itself become ``\\\\`` -- + while quote doubling appears nowhere in its escape table (lexical + structure and syntax: "Escape sequences for string and bytes + literals"; not server-verified, read 2026-10-17). The backslash is + escaped first, then the quote, so neither can eat the other's + escape. This is the dialect-wide policy: every inline string this + dialect renders goes through it. + """ + return value.replace("\\", "\\\\").replace("'", "\\'") + def get_type_mappings(self) -> Dict[str, Any]: return { "INTEGER": "INT64", diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/__init__.py index 127e608..ea19800 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/__init__.py @@ -14,6 +14,7 @@ from .materialized_view import BigQueryMaterializedViewMixin from .transaction import BigQueryTransactionMixin from .trim import BigQueryTrimMixin +from .typed_value import BigQueryTypedValueMixin class BigQueryStructMixin: @@ -50,6 +51,7 @@ def supports_geography(self) -> bool: 'BigQueryMaterializedViewMixin', 'BigQueryTransactionMixin', 'BigQueryTrimMixin', + 'BigQueryTypedValueMixin', 'BigQueryStructMixin', 'BigQueryArrayMixin', 'BigQueryJSONMixin', diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/dql.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/dql.py index e7678b7..b678855 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/dql.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/dql.py @@ -4,6 +4,7 @@ from typing import Tuple, TYPE_CHECKING +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.query_sources import SetOperation if TYPE_CHECKING: @@ -153,11 +154,6 @@ def format_datetime_diff_expression(self, expr) -> Tuple[str, tuple]: Raises: UnsupportedFeatureError: For a part BigQuery has no spelling for. """ - from rhosocial.activerecord.backend.dialect.exceptions import ( - UnsupportedFeatureError, - ) - from rhosocial.activerecord.backend.dialect.base import SQLDialectBase - # The units the framework's IntervalUnit defines. BigQuery's DATE_DIFF # has more parts -- WEEKDAY, ISOWEEK, QUARTER -- but a caller cannot # name them here, so listing them would be a promise nobody can keep. @@ -183,7 +179,7 @@ def format_datetime_diff_expression(self, expr) -> Tuple[str, tuple]: else: sql = ( f"DATE_DIFF({end_sql}, {start_sql}, " - f"{SQLDialectBase._escape_sql_string(part)})" + f"{self._escape_sql_string(part)})" ) return self.apply_alias(sql, end_params + start_params, expr) diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/typed_value.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/typed_value.py new file mode 100644 index 0000000..62357dc --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/typed_value.py @@ -0,0 +1,264 @@ +# src/rhosocial/activerecord/backend/impl/bigquery/mixins/typed_value.py +"""BigQuery's spellings for the typed value nodes (``expression.values``). + +Evidence level: 文档(not server-verified) +------------------------------------------- +**No spelling below was measured.** No BigQuery instance is reachable from +this repository's test environment and the CI emulator does not adjudicate +literal grammar, so every form is transcribed from the official reference +pages, read 2026-10-17: + +* lexical structure and syntax -- bytes literals (``b``/``B`` prefix), the + escape-sequence table (``\\xhh``: exactly 2 hex digits, one byte), ``DATE``/ + ``TIME``/``DATETIME``/``TIMESTAMP`` literals (keyword + quoted canonical + format), ``NUMERIC``/``BIGNUMERIC`` literals (keyword + quoted value) and + interval literals (``INTERVAL step_size step_unit``, unquoted): + https://cloud.google.com/bigquery/docs/reference/standard-sql/lexical +* data types -- NUMERIC (precision 38, scale 9), BIGNUMERIC (approximately + 76.8 digits, scale 38), TIME ("a time of day, independent of a specific + date and time zone"), the interval-supported datetime parts: + https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types + +What is overridden, and why +--------------------------- +The kinds BigQuery agrees with the base on -- NULL, booleans (``TRUE`` / +``FALSE``), ``INT64``/``FLOAT64`` numeric text, and strings -- keep the +base's rendering (strings inline correctly because the dialect overrides +``_escape_sql_string`` for GoogleSQL's backslash escaping; see +:mod:`...impl.bigquery.dialect`). Everything else this grammar spells its +own way: + +* **binary** -- ``b'\\x01\\x02'``: the bytes-literal prefix with every byte + as a ``\\xhh`` hex escape, the one form that is valid whatever the bytes + contain. +* **UUID** -- the bare quoted text: BigQuery has no UUID type, and + ``GENERATE_UUID()`` returns a ``STRING``, so the canonical form is the + whole of what a ``STRING`` column holds. +* **INTERVAL** -- the unquoted numeric form ``INTERVAL 5 DAY``, mapped over + the ``timedelta``'s days / seconds / microseconds. +* **Decimal** -- ``NUMERIC '...'`` / ``BIGNUMERIC '...'``: the keyword form + is the only exact-decimal literal; bare ``3.14`` would be a ``FLOAT64`` + literal and the precision the value exists to carry would be lost. +* **date / time / datetime / timestamp** -- the keyword-prefixed typed + literals (``DATE '...'``, ``TIME '...'``): BigQuery's canonical literals + carry the type keyword, and a bare quoted string is a ``STRING`` that + only *coerces* where the position happens to expect the type. For a + ``datetime`` the naive/aware distinction picks the type: naive is + zoneless ``DATETIME``, aware is ``TIMESTAMP`` with the offset spelled. +""" + +import decimal +from typing import TYPE_CHECKING, Tuple + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.expression.values import TypedValue + +#: BIGNUMERIC's documented range, transcribed from the data-types reference +#: (not server-verified, read 2026-10-17). The integer part at the bound is +#: 39 digits -- the partial "77th digit" of the ~76.8-digit precision -- so +#: the magnitude is compared against the bounds themselves, not against a +#: digit count. The two bounds differ by one unit because the type is a +#: scaled fixed point: the negative side holds one step more than the +#: positive one, exactly as the documented Min/Max lines spell them. +_BIGNUMERIC_MIN = decimal.Decimal( + "-5.7896044618658097711785492504343953926634992332820282019728792003956564819968E+38" +) +_BIGNUMERIC_MAX = decimal.Decimal( + "5.7896044618658097711785492504343953926634992332820282019728792003956564819967E+38" +) + + +class BigQueryTypedValueMixin: + """BigQuery's tier-2 value literals and its typed tier-1 spellings. + + Stands alone rather than inheriting the core :class:`TypedValueMixin`, + matching SQLite's mixin: the dialect composes this class ahead of the + core one, and the overridden methods win by that position. The kinds + not written here are the ones BigQuery spells the base's way. + """ + + def format_binary_value(self, expr: "TypedValue") -> Tuple[str, tuple]: + """A bytes literal: ``b'\\x01\\x02'``, every byte as a hex escape. + + The ``b`` prefix is the documented bytes-literal form, and + ``\\xhh`` -- exactly 2 hex digits, decoding to one byte -- is the + documented escape that is valid for any byte value, so every byte + is spelled that way rather than only the unprintable ones. + """ + if not type(expr).inline_params: + return self._bind_value(expr) + return "b'" + "".join(f"\\x{b:02x}" for b in bytes(expr.value)) + "'", () + + def format_uuid_value(self, expr: "TypedValue") -> Tuple[str, tuple]: + """A UUID as quoted text -- BigQuery's UUIDs live in ``STRING``. + + There is no UUID type and ``GENERATE_UUID()`` returns ``STRING``, + so the canonical text is the whole value. + """ + if not type(expr).inline_params: + return self._bind_value(expr) + return self.format_literal(str(expr.value)), () + + def format_interval_value(self, expr: "TypedValue") -> Tuple[str, tuple]: + """An interval literal: the unquoted ``INTERVAL n UNIT`` form. + + GoogleSQL's single-datetime-part literal is ``INTERVAL step_size + step_unit`` with an ``INT64`` step, unquoted (the quoted + ``'datetime_parts_string'`` range form is the other literal and is + not derived here). The step is mapped over the ``timedelta``'s own + days / seconds / microseconds as one exact integer count of + microseconds, then spelled in the largest unit that divides it -- + ``INTERVAL 1 DAY``, ``INTERVAL 90 MINUTE``, ``INTERVAL 25 HOUR``, + ``INTERVAL 86402 SECOND`` for a day-plus-two-seconds span -- and in + ``MICROSECOND`` when no larger unit divides it exactly, an odd + microsecond count for instance. ``YEAR`` / ``QUARTER`` / ``MONTH`` + are calendar units a ``timedelta`` cannot state (the core node's + docstring says so), and ``WEEK`` is seven ``DAY``s exactly, so the + mapping keeps to the units that carry the value's own magnitude. + + Raises: + ValueError: The step in the chosen unit does not fit a signed + ``INT64``. The exact minimum is refused along with anything + beyond it, because whether a parser negates before or after + the range check is not documented; every step whose literal + parses unambiguously is spelled. + """ + if not type(expr).inline_params: + return self._bind_value(expr) + value = expr.value + total_micros = (value.days * 86_400 + value.seconds) * 1_000_000 + value.microseconds + # The largest unit that divides the span exactly; MICROSECOND, the + # one unit no remainder can escape, is the default. + unit, step = "MICROSECOND", total_micros + for candidate, micros_per in ( + ("DAY", 86_400_000_000), + ("HOUR", 3_600_000_000), + ("MINUTE", 60_000_000), + ("SECOND", 1_000_000), + ("MILLISECOND", 1_000), + ): + if total_micros % micros_per == 0: + unit, step = candidate, total_micros // micros_per + break + if not -(2**63) < step <= 2**63 - 1: + raise ValueError( + f"INTERVAL {step} {unit} does not fit a signed INT64 step " + "size; the interval literal grammar takes an INT64, so this " + "span cannot be spelled as one part" + ) + return f"INTERVAL {step} {unit}", () + + def format_decimal_value(self, expr: "TypedValue") -> Tuple[str, tuple]: + """An exact-decimal literal: ``NUMERIC '...'`` or ``BIGNUMERIC '...'``. + + The keyword followed by a quoted value is the documented literal, + and the only exact one -- bare numeric text is a ``FLOAT64`` + literal, which is the approximation the value exists to avoid. + The keyword is chosen by which type's documented range holds the + value (data types, read 2026-10-17, not server-verified): + + * ``NUMERIC`` -- precision 38, scale 9, documented range + ``+/-9.9999999999999999999999999999999999999E+28``. That bound + is exactly 29 integer digits with scale 9, which is the form + checked here. + * ``BIGNUMERIC`` -- scale 38, documented range + ``-5.789604...9968E+38`` to ``5.789604...9967E+38``. The + integer part at the bound is 39 digits -- the partial digit of + the "approximately 76.8 digits" precision -- so the magnitude + is compared against the documented bounds themselves + (:data:`_BIGNUMERIC_MIN` / :data:`_BIGNUMERIC_MAX`), which are + one unit apart: the negative side holds one step more than the + positive one. + + A value beyond both is refused here rather than written into SQL + the server must reject. + + Raises: + ValueError: The value is non-finite, its scale is past + ``BIGNUMERIC``'s 38, or its magnitude is outside both + types' documented range. + """ + if not type(expr).inline_params: + return self._bind_value(expr) + value = expr.value + if not value.is_finite(): + raise ValueError( + "non-finite Decimal cannot be inlined into SQL (the base " + "refuses it for every dialect; BigQuery spells it no way " + "either)" + ) + _, digits, exponent = value.as_tuple() + scale = -exponent if exponent < 0 else 0 + integer_digits = ( + len(digits) + exponent if exponent >= 0 else max(len(digits) - scale, 0) + ) + if scale <= 9 and integer_digits <= 29: + keyword = "NUMERIC" + elif scale <= 38 and _BIGNUMERIC_MIN <= value <= _BIGNUMERIC_MAX: + keyword = "BIGNUMERIC" + else: + raise ValueError( + f"{value} is outside the documented range of both decimal " + f"types: scale {scale} is past BIGNUMERIC's 38, or the " + "magnitude is past NUMERIC's 29 integer digits at scale 9 " + "and past BIGNUMERIC's documented bounds; no decimal " + "literal can hold it" + ) + text = str(value) + if "E" in text: + # The exponent marker is spelling, not value: the documented + # literal examples write it in lower case. + text = text.replace("E", "e") + return f"{keyword} {self.format_literal(text)}", () + + def format_date_value(self, expr: "TypedValue") -> Tuple[str, tuple]: + """A date literal: ``DATE '2014-09-27'``, keyword and quoted text. + + The date literal is the ``DATE`` keyword followed by a string + literal in the canonical date format -- the quoted ISO text the + base renders, prefixed with the keyword this grammar requires. + """ + if not type(expr).inline_params: + return self._bind_value(expr) + return f"DATE {self.format_literal(expr.value)}", () + + def format_time_value(self, expr: "TypedValue") -> Tuple[str, tuple]: + """A time literal: ``TIME '12:30:00.45'``, keyword and quoted text. + + The ``TIME`` keyword precedes the canonical time format, which is + a time of day and nothing else: the type is documented as + "independent of a specific date and time zone". + + Raises: + ValueError: An aware ``time`` carries an offset that the + spelling would have to drop silently, which this dialect + does not do -- the offset is the caller's to remove. + """ + if not type(expr).inline_params: + return self._bind_value(expr) + if expr.value.tzinfo is not None: + raise ValueError( + f"{expr.value.isoformat()} is an aware time, but BigQuery's " + "TIME is a time of day independent of a specific date and " + "time zone; spelling it would have to drop the offset " + "silently, which the dialect does not do" + ) + return f"TIME {self.format_literal(expr.value)}", () + + def format_timestamp_value(self, expr: "TypedValue") -> Tuple[str, tuple]: + """A date-time literal: ``DATETIME '...'`` or ``TIMESTAMP '...'``. + + BigQuery's grammar has a zoneless date-time type and an absolute + one, and the naive/aware distinction names them exactly: a naive + ``datetime`` is wall time with no zone, spelled ``DATETIME '...'`` + -- the zoneless literal, with no offset invented -- and an aware + one is an absolute point, spelled ``TIMESTAMP '...'`` with the + offset the value carries. This mapping neither adds a zone to a + naive value nor drops the one an aware value holds, which is the + same line the core node draws between the two. + """ + if not type(expr).inline_params: + return self._bind_value(expr) + if expr.value.tzinfo is None: + return f"DATETIME {self.format_literal(expr.value)}", () + return f"TIMESTAMP {self.format_literal(expr.value)}", () diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_typed_values.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_typed_values.py new file mode 100644 index 0000000..3305c81 --- /dev/null +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_typed_values.py @@ -0,0 +1,369 @@ +# tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_typed_values.py +"""BigQuery's spellings for the typed value nodes (rendering only, no server). + +Every assertion is a rendering assertion against the documented GoogleSQL +literal grammar (lexical structure and syntax / data types, read 2026-10-17 +-- **not server-verified**: no BigQuery instance is reachable from this +repository). Each kind asserts both positions the class-level +``inline_params`` strategy splits rendering into: + +* the **data position** (the shipped classes: ``inline_params = False``) + binds the host value behind ``?`` and converts nothing; +* the **DDL syntax position** (``inline_params = True``, declared by the + test-local ``Ddl*`` classes the way the core lifts ``Literal`` to + ``DdlLiteral``) spells the inline literal exactly. + +The explicit refusals -- an INTERVAL step that cannot fit the ``INT64`` the +grammar takes, a ``Decimal`` outside both decimal types' documented range, +a non-finite ``Decimal``, an aware ``time`` -- are asserted with their +messages. +""" + +import datetime +import decimal +import uuid + +import pytest + +from rhosocial.activerecord.backend.expression.values import ( + BinaryValue, + BooleanValue, + DateValue, + DecimalValue, + FloatValue, + IntegerValue, + IntervalValue, + NullValue, + StringValue, + TimeValue, + TimestampValue, + UUIDValue, +) +from rhosocial.activerecord.backend.impl.bigquery.dialect import BigQueryDialect + + +@pytest.fixture +def dialect(): + return BigQueryDialect() + + +def _ddl(cls): + """*cls* with the DDL-position strategy, declared as a class of its own. + + Whether a value inlines is the **class's** decision (spec 4.4), never a + per-instance flag -- so the test says it the only way the contract + allows, a subclass declaring ``inline_params = True``, which is the + same move the core makes when it lifts ``Literal`` to ``DdlLiteral`` + for a DDL condition it owns. + """ + return type(f"Ddl{cls.__name__}", (cls,), {"inline_params": True}) + + +class TestWiring: + """The dialect composes this backend's spellings ahead of the base's.""" + + def test_backend_mixin_wins_the_overridden_kinds(self, dialect): + for name in ( + "format_binary_value", + "format_uuid_value", + "format_interval_value", + "format_decimal_value", + "format_date_value", + "format_time_value", + "format_timestamp_value", + ): + assert getattr(dialect, name).__qualname__.startswith( + "BigQueryTypedValueMixin." + ) + + def test_base_mixin_answers_the_untouched_kinds(self, dialect): + for name in ( + "format_null_value", + "format_boolean_value", + "format_integer_value", + "format_float_value", + "format_string_value", + ): + assert getattr(dialect, name).__qualname__.startswith( + "TypedValueMixin." + ) + + +class TestBinaryValue: + def test_data_position_binds_the_bytes(self, dialect): + assert BinaryValue(dialect, b"\x01\x02").to_sql() == ("?", (b"\x01\x02",)) + + def test_ddl_position_spells_the_bytes_literal(self, dialect): + assert _ddl(BinaryValue)(dialect, b"\x01\x02").to_sql() == ( + "b'\\x01\\x02'", + (), + ) + + def test_every_byte_becomes_one_hex_escape(self, dialect): + assert _ddl(BinaryValue)(dialect, b"\x00\x0a\xff\x7f").to_sql() == ( + "b'\\x00\\x0a\\xff\\x7f'", + (), + ) + + def test_empty_bytes_are_the_empty_literal(self, dialect): + assert _ddl(BinaryValue)(dialect, b"").to_sql() == ("b''", ()) + + +class TestUUIDValue: + def test_data_position_binds_the_uuid(self, dialect): + value = uuid.UUID("123e4567-e89b-12d3-a456-426614174000") + assert UUIDValue(dialect, value).to_sql() == ("?", (value,)) + + def test_ddl_position_is_the_bare_quoted_text(self, dialect): + assert _ddl(UUIDValue)( + dialect, uuid.UUID("123e4567-e89b-12d3-a456-426614174000") + ).to_sql() == ( + "'123e4567-e89b-12d3-a456-426614174000'", + (), + ) + + +class TestIntervalValue: + def test_data_position_binds_the_timedelta(self, dialect): + assert IntervalValue(dialect, datetime.timedelta(days=1)).to_sql() == ( + "?", + (datetime.timedelta(days=1),), + ) + + @pytest.mark.parametrize( + "value, expected", + [ + # The single-datetime-part literal: INTERVAL step_size step_unit, + # unquoted, step an INT64 (lexical structure and syntax). + (datetime.timedelta(days=1), "INTERVAL 1 DAY"), + (datetime.timedelta(days=-5), "INTERVAL -5 DAY"), + (datetime.timedelta(weeks=6), "INTERVAL 42 DAY"), + (datetime.timedelta(hours=25), "INTERVAL 25 HOUR"), + (datetime.timedelta(minutes=90), "INTERVAL 90 MINUTE"), + (datetime.timedelta(seconds=1), "INTERVAL 1 SECOND"), + (datetime.timedelta(milliseconds=500), "INTERVAL 500 MILLISECOND"), + (datetime.timedelta(microseconds=3), "INTERVAL 3 MICROSECOND"), + # A day plus two seconds is a whole number of seconds: SECOND is + # the largest unit that divides the span exactly, so it is the + # step (the documented examples spell whole-second spans that + # way too: INTERVAL 90000 SECOND). + (datetime.timedelta(days=1, seconds=2), "INTERVAL 86402 SECOND"), + # An odd microsecond count: no unit above MICROSECOND divides + # it, so it is spelled exactly, in microseconds. + ( + datetime.timedelta(days=-1, seconds=-2, microseconds=-3), + "INTERVAL -86402000003 MICROSECOND", + ), + # timedelta(seconds=-90) is -1.5 minutes: the step must be the + # one unit that holds it exactly, not a rounded-up neighbour. + (datetime.timedelta(seconds=-90), "INTERVAL -90 SECOND"), + (datetime.timedelta(0), "INTERVAL 0 DAY"), + ], + ) + def test_ddl_position_spells_the_single_part_form(self, dialect, value, expected): + assert _ddl(IntervalValue)(dialect, value).to_sql() == (expected, ()) + + def test_the_int64_step_boundary_is_spelled(self, dialect): + value = datetime.timedelta(microseconds=2**63 - 1) + assert _ddl(IntervalValue)(dialect, value).to_sql() == ( + "INTERVAL 9223372036854775807 MICROSECOND", + (), + ) + + def test_a_step_beyond_int64_is_refused(self, dialect): + with pytest.raises(ValueError, match="INT64"): + _ddl(IntervalValue)(dialect, datetime.timedelta(microseconds=2**63)).to_sql() + + +class TestDecimalValue: + def test_data_position_binds_the_decimal(self, dialect): + assert DecimalValue(dialect, decimal.Decimal("3.14")).to_sql() == ( + "?", + (decimal.Decimal("3.14"),), + ) + + @pytest.mark.parametrize( + "value, expected", + [ + # NUMERIC literal: the keyword followed by the quoted value + # (lexical structure and syntax, NUMERIC literals). + (decimal.Decimal("0"), "NUMERIC '0'"), + (decimal.Decimal("-3.14"), "NUMERIC '-3.14'"), + # 29 integer digits is NUMERIC's whole integer budget (38 - 9); + # an int-constructed Decimal carries exponent 0, so str() spells + # it as the plain digits. + (decimal.Decimal(10**28), "NUMERIC '10000000000000000000000000000'"), + # 1.23456e05 is exactly 123456: the coefficient folds into plain + # digits at this exponent, and that is the literal rendered. + (decimal.Decimal("1.23456e05"), "NUMERIC '123456'"), + # A Decimal constructed from an E-form string keeps the exponent, + # so str() gives '9E+28' -- and the marker is lowered to match the + # documented literal examples ('1.23456e05', '-9.876e-3'). + (decimal.Decimal("9E+28"), "NUMERIC '9e+28'"), + # Scale 10 is past NUMERIC's 9; BIGNUMERIC is the literal that + # holds it. + (decimal.Decimal("123.4567890123"), "BIGNUMERIC '123.4567890123'"), + # 30 integer digits is past NUMERIC's 29 but inside + # BIGNUMERIC's documented range. + (decimal.Decimal(10**29), "BIGNUMERIC '100000000000000000000000000000'"), + # 1e-38 is BIGNUMERIC's documented minimum magnitude -- scale + # 38, the last scale the type takes. + (decimal.Decimal("1e-38"), "BIGNUMERIC '1e-38'"), + ], + ) + def test_ddl_position_spells_the_keyword_literal(self, dialect, value, expected): + assert _ddl(DecimalValue)(dialect, value).to_sql() == (expected, ()) + + def test_the_partial_39th_integer_digit_is_inside_the_range(self, dialect): + # 1e38 has a 39-digit integer part -- the partial digit of the + # "approximately 76.8 digits" precision -- and is inside + # BIGNUMERIC's documented range, so it is spelled, not refused. + assert _ddl(DecimalValue)(dialect, decimal.Decimal("1e38")).to_sql() == ( + "BIGNUMERIC '1e+38'", + (), + ) + + def test_the_documented_bignumeric_bounds_are_spelled(self, dialect): + # The documented Min and Max, transcribed from the data-types + # reference: the two bounds are one unit apart, because the + # negative side of a scaled fixed point holds one step more than + # the positive one. Both store exponent -38 (E+38 over 77 + # significant digits), so str() gives the plain form -- 39 integer + # digits, point, 38 fractional digits -- which the quoted-value + # grammar takes as it stands. + assert _ddl(DecimalValue)(dialect, decimal.Decimal( + "5.7896044618658097711785492504343953926634992332820282019728792003956564819967E+38" + )).to_sql() == ( + "BIGNUMERIC '578960446186580977117854925043439539266.34992332820282019728792003956564819967'", + (), + ) + assert _ddl(DecimalValue)(dialect, decimal.Decimal( + "-5.7896044618658097711785492504343953926634992332820282019728792003956564819968E+38" + )).to_sql() == ( + "BIGNUMERIC '-578960446186580977117854925043439539266.34992332820282019728792003956564819968'", + (), + ) + + def test_past_bignumerics_documented_range_is_refused(self, dialect): + with pytest.raises(ValueError, match="BIGNUMERIC"): + _ddl(DecimalValue)(dialect, decimal.Decimal("6e38")).to_sql() + + def test_a_scale_past_bignumerics_is_refused(self, dialect): + with pytest.raises(ValueError, match="BIGNUMERIC"): + _ddl(DecimalValue)(dialect, decimal.Decimal("1e-39")).to_sql() + + def test_non_finite_is_refused(self, dialect): + with pytest.raises(ValueError, match="non-finite"): + _ddl(DecimalValue)(dialect, decimal.Decimal("NaN")).to_sql() + + +class TestDateValue: + def test_data_position_binds_the_date(self, dialect): + assert DateValue(dialect, datetime.date(2014, 9, 27)).to_sql() == ( + "?", + (datetime.date(2014, 9, 27),), + ) + + def test_ddl_position_spells_the_typed_literal(self, dialect): + assert _ddl(DateValue)(dialect, datetime.date(2014, 9, 27)).to_sql() == ( + "DATE '2014-09-27'", + (), + ) + + +class TestTimeValue: + def test_data_position_binds_the_time(self, dialect): + assert TimeValue(dialect, datetime.time(12, 30)).to_sql() == ( + "?", + (datetime.time(12, 30),), + ) + + def test_ddl_position_spells_the_typed_literal(self, dialect): + value = datetime.time(12, 30, 0, 450000) + assert _ddl(TimeValue)(dialect, value).to_sql() == ( + "TIME '12:30:00.450000'", + (), + ) + + def test_an_aware_time_is_refused_not_silently_zone_stripped(self, dialect): + value = datetime.time( + 12, 30, tzinfo=datetime.timezone(datetime.timedelta(hours=5)) + ) + with pytest.raises(ValueError, match="aware time"): + _ddl(TimeValue)(dialect, value).to_sql() + + +class TestTimestampValue: + def test_data_position_binds_the_datetime(self, dialect): + assert TimestampValue(dialect, datetime.datetime(2014, 9, 27, 12, 30)).to_sql() == ( + "?", + (datetime.datetime(2014, 9, 27, 12, 30),), + ) + + def test_a_naive_datetime_is_the_zoneless_datetime_literal(self, dialect): + value = datetime.datetime(2014, 9, 27, 12, 30, 0, 450000) + assert _ddl(TimestampValue)(dialect, value).to_sql() == ( + "DATETIME '2014-09-27 12:30:00.450000'", + (), + ) + + def test_an_aware_datetime_is_the_timestamp_literal_with_its_offset(self, dialect): + value = datetime.datetime( + 2014, 9, 27, 12, 30, tzinfo=datetime.timezone(datetime.timedelta(hours=5)) + ) + assert _ddl(TimestampValue)(dialect, value).to_sql() == ( + "TIMESTAMP '2014-09-27 12:30:00+05:00'", + (), + ) + + +class TestTheKindsBigQueryAgreesWith: + """Tier 1 audited against the grammar: the base's spellings apply. + + ``NULL`` is the keyword, ``BOOL`` takes ``TRUE``/``FALSE``, ``INT64`` + and ``FLOAT64`` literals are numeric text, and a ``STRING`` literal is + quoted -- all of them the core mixin's own answers, kept by position. + """ + + def test_null_binds_and_spells_the_keyword(self, dialect): + assert NullValue(dialect).to_sql() == ("?", (None,)) + assert _ddl(NullValue)(dialect).to_sql() == ("NULL", ()) + + @pytest.mark.parametrize("value, expected", [(True, "TRUE"), (False, "FALSE")]) + def test_boolean_spells_true_false(self, dialect, value, expected): + assert _ddl(BooleanValue)(dialect, value).to_sql() == (expected, ()) + + def test_integer_spells_numeric_text(self, dialect): + assert _ddl(IntegerValue)(dialect, 123).to_sql() == ("123", ()) + + def test_float_spells_numeric_text(self, dialect): + assert _ddl(FloatValue)(dialect, 1.5).to_sql() == ("1.5", ()) + + def test_string_spells_quoted(self, dialect): + assert _ddl(StringValue)(dialect, "abc").to_sql() == ("'abc'", ()) + + def test_string_escapes_the_quote_with_a_backslash(self, dialect): + assert _ddl(StringValue)(dialect, "it's").to_sql() == ("'it\\'s'", ()) + + def test_string_escapes_the_backslash_itself(self, dialect): + assert _ddl(StringValue)(dialect, "a\\b").to_sql() == ("'a\\\\b'", ()) + + +class TestTheDialectWideEscapePolicy: + """``_escape_sql_string`` is the one place GoogleSQL's escaping lives. + + The escape table documents ``\\'`` for a single quote and makes + backslashes escape-sequence introducers, so a literal backslash is + doubled first; quote doubling is not in the table at all. Every inline + string the dialect renders -- value nodes, OPTIONS entries, comments -- + goes through this policy. + """ + + def test_a_single_quote(self, dialect): + assert dialect.format_literal("it's") == "'it\\'s'" + + def test_a_backslash_before_the_quote(self, dialect): + assert dialect.format_literal("a\\b'c") == "'a\\\\b\\'c'" + + def test_the_public_helper_agrees(self, dialect): + assert dialect.escape_sql_string("it's") == "it\\'s" From bd22d12d4d6982e1877901cc297e66f7929964ef Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 18 Oct 2026 12:43:36 +0800 Subject: [PATCH 12/35] test(bigquery): reconcile the roundtrip matrix with the value and function waves The classed-function wave (M1) audited first, and found nothing to change in this backend, so it rides here as a no-op: BigQuery's LENGTH counts characters, which is what the core's format_char_length_function spells by default, so no char_length override belongs here; this backend overrides no format_char_length_function/format_count_function, defines no format_function_call and no INSERT VALUES source of its own (rg over src: zero stale classed-entry overrides, zero FunctionCall call sites, zero format_insert_statement/format_binary_arithmetic_expression overrides), so there was no code to migrate. The matrix itself needed three repairs against the current core HEAD (e57c6702). Core's serialization moved auto_register_builtins into the lazily built default_registry and dropped registered_expression_classes, which broke this file at import: the deserialization-coverage test now resolves each class's kind through default_registry().resolve_node and the ten eager warm-up calls are gone (register_all warms the registry itself). The testsuite dropped its round-trip harness in e05edb0 with the direction that round-trip coverage moves to the owning repository, so the harness is vendored here as expression_roundtrip_harness.py (from the same testsuite@4ef2551 source mysql vendored, adapted to the split registry the way mysql's copy is, plus one deliberate drift fix: zip() gains strict=False so B905 stops firing while the length assert above stays the check that reports). The third repair is the reconciliation itself: - Constructors: the seven value classes whose host type an int cannot state (BinaryValue/BooleanValue/DateValue/StringValue/TimeValue/TimestampValue/UUIDValue) get real host values; the six new scalar string nodes (Chr/Left/Right/Space/Substring/Overlay) get real operands their SQLValue forward refs defeat the guesser on; ExtractFieldExpression and _ExtractBase get a real field and source. Two stale factories follow the current signatures (ArrayType's element_type is keyword-only now; ValidateConstraint names its constraint positionally) and the dead graph.EdgeTable registration goes -- core renamed that element to EdgeTableDefinition, which the heuristic constructor builds. - Verdict tables: UNCONSTRUCTIBLE loses CustomType (its raw parameter is required now, so it builds and refuses through the generic-type probe like the other unsupported types) and keeps the UUID trio, which still refuse while constructing. LEGITIMATE_NON_RENDERS moves graph.EdgeTable to graph.EdgeTableDefinition with the format_edge_table_definition fragment and pins declared._Annotation as (AttributeError, has no attribute 'alias'): the shared base of the declared-result annotations dispatches to the one formatter those annotations share, which reads the alias only a concrete annotation carries. UNMODELLED_FORMATTERS renames format_edge_table to format_edge_table_definition and adds one group for the empty 'format_' the TypedValue category base dispatches to -- not a feature BigQuery lacks, but the row keeps the unmodelled list exact in both directions. ExtractFieldExpression and _ExtractBase render through the shared format_extract_expression, so they take no table rows. Coverage after reconciliation, 390 classes (core + this backend): rendered 265, no-formatter 52, unsupported 54, not-implemented 15, unmodelled 1, unconstructible 3. The surfaces of values.py are abstract and are not collected, as the wave guide requires. Tests (exit codes): the full matrix file 396 passed / 3 designed skips (the UNCONSTRUCTIBLE UUID trio, each named with its reason), exit 0; TestMatrixIntegrity alone 9 passed, exit 0; ruff on both files clean. Also fixed in passing: BaseExpression was imported but unused in this file (dead already at HEAD; F401). --- .../backend/expression_roundtrip_harness.py | 1138 +++++++++++++++++ .../backend/test_expression_roundtrip_all.py | 200 ++- 2 files changed, 1297 insertions(+), 41 deletions(-) create mode 100644 tests/rhosocial/activerecord_bigquery_test/feature/backend/expression_roundtrip_harness.py diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/expression_roundtrip_harness.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/expression_roundtrip_harness.py new file mode 100644 index 0000000..1befcc5 --- /dev/null +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/expression_roundtrip_harness.py @@ -0,0 +1,1138 @@ +# tests/rhosocial/activerecord_bigquery_test/feature/backend/expression_roundtrip_harness.py +""" +Expression serialization round-trip / contract test helpers (BigQuery-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, Type + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +# Annotation fragments that mean "this parameter names a relation object". +# Word boundaries matter: ``TableConstraint`` and ``AddTableConstraint`` are +# constraint actions, not relations, and a substring test would hand them a +# Table. ``SchemaObject`` is the base every catalogue object shares, so an +# annotation naming it wants an object too. +_MENTIONS_RELATION = re.compile( + r"\b(Table|View|MaterializedView|Index|Sequence|Type|Domain|Function|Trigger" + r"|Schema|Database|RelationObject|SchemaObject|NodeTable|EdgeTable)\b" +) + +# An INSERT's data source. It is an expression now, so it needs a real one -- +# the generic placeholder cannot invent the rows a VALUES list is made of. +_MENTIONS_INSERT_SOURCE = re.compile(r"\bInsertDataSource\b") + + +def _empty_container_for(annotation: Any, dialect: Any): + """Return an empty container for a parameterised alias, else None. + + A parameterised alias like ``List[str]`` or ``Dict[str, BaseExpression]`` + says what the *elements* are, never the container itself, so there is no + catalogue object to hand back -- only an empty container. Deciding this + before the relation-name test is what keeps ``typing.List[str]`` from being + read as a ``Table``: ``typing.get_origin`` on it gives ``list``, but the + relation test only ever saw the alias' own name, and ``List[str]`` matches + ``Sequence``/``Table``-family fragments well enough to be mistaken for one. + + Returns ``None`` when ``annotation`` is not a parameterised container, which + is the caller's signal to keep reading. + """ + origin = typing.get_origin(annotation) + if origin is None: + return None + if origin in (list, tuple, set, frozenset): + return [] + if origin is dict: + return {} + # ``Sequence[str]`` and friends resolve to the collections.abc ABC rather + # than to ``list``, so match on the abstract base instead of the concrete + # one the annotation happens to name. + if isinstance(origin, type) and issubclass(origin, (collections.abc.Sequence, collections.abc.Set)): + return [] + if isinstance(origin, type) and issubclass(origin, collections.abc.Mapping): + return {} + return None + + +def _mentions_expression_class(annotation: Any, raw: Any) -> bool: + """Whether the annotation names an expression node, whatever it is called. + + This used to be a substring test for ``"Expression"``, which is a coupling + to the class *names*: renaming ``QueryExpression`` to ``QueryStatement`` + (规范 4.9: a statement is a statement) silently stopped matching, the + parameter fell through to the ``"x"`` fallback, and the failure surfaced + later as ``'str' object has no attribute 'to_sql'`` from inside a + formatter. The structural question -- does this annotation name a + ``BaseExpression`` subclass? -- does not care what the class is called. + """ + from rhosocial.activerecord.backend.expression.bases import BaseExpression + + candidates = [raw] + if typing.get_origin(annotation) is not None: + candidates.extend(typing.get_args(annotation)) + for candidate in candidates: + if isinstance(candidate, type) and issubclass(candidate, BaseExpression): + return True + return False + + +def _is_predicate_class(annotation: Any, raw: Any) -> bool: + """Whether the annotation names a predicate node, whatever it is called. + + Same structural question as :func:`_mentions_expression_class` answers for + expressions, asked of the predicate family: a rename inside the family must + not change what this heuristic builds either. + """ + from rhosocial.activerecord.backend.expression.bases import SQLPredicate + + for candidate in (raw, annotation): + if isinstance(candidate, type) and issubclass(candidate, SQLPredicate): + return True + return False + + +def _placeholder_for(param: inspect.Parameter, dialect: Any = None): + """Return a heuristic construction value for a required parameter.""" + annotation = param.annotation + if annotation is not inspect.Parameter.empty: + origin = typing.get_origin(annotation) + raw = annotation if origin is None else origin + if isinstance(annotation, str): + text = annotation + else: + text = getattr(raw, "__name__", "") + container = _empty_container_for(annotation, dialect) + if container is not None: + return container + if raw in (list, tuple, set): + return [] + if raw is dict: + return {} + if _MENTIONS_RELATION.search(text): + return _relation(dialect, text) + if _MENTIONS_INSERT_SOURCE.search(text): + return _values_source(dialect) + if raw is str or "str" in text: + return "x" + # A typed value expression wraps the call it renders, so the parameter + # wants a FunctionCall and not a bare name. This must be tested before + # the str branch above: "FunctionCall" contains no "str", but the + # fallback below returns "x" for it, which the wrapper then calls + # to_sql() on. + if "FunctionCall" in text: + return _function_call(dialect) + if "DataType" in text or "Type" in text.split(".")[-1:]: + from rhosocial.activerecord.backend.expression.types import IntegerType + + return IntegerType(dialect) + if raw is int: + return 1 + if raw is float: + return 1.0 + if raw is bool: + return True + # A predicate slot wants a predicate, not a value. SQLPredicate is a + # BaseExpression subclass like the values are, so the structural + # expression branch below hands it a Literal -- and the constructors + # that type a parameter as a predicate refuse a Literal on their + # isinstance check (core's DomainCheckConstraint.condition is the one + # in the matrix today). Asked structurally when the annotation + # resolves; the name test beside it covers forward references. + if (not isinstance(annotation, str) and _is_predicate_class(annotation, raw)) or ( + "Predicate" in text + ): + return _comparison(dialect) + # Asked structurally when the annotation resolves: what a class is + # called says nothing about what it is, and a rename must not change + # what this heuristic builds. The substring tests beside it remain for + # forward references, which introspection cannot resolve -- and they + # must list every name the library might grow, which is exactly why + # the structural test is the one that leads. + if ( + "Expression" in text + or "Subquery" in text + or "Statement" in text + or (not isinstance(annotation, str) and _mentions_expression_class(annotation, raw)) + ): + return _literal(dialect) + + name = param.name + if name in ("value",): + return 1 + if name in ("values", "columns", "args", "params", "predicates", "expressions"): + return [] + if name in ("left", "right") or "predicate" in name or "condition" in name: + return _comparison(dialect) + if "expression" in name or name in ("expr", "operand", "subquery", "query"): + return _literal(dialect) + return "x" + + +def _literal(dialect): + from rhosocial.activerecord.backend.expression.core import Literal + + return Literal(dialect, 1) + + +def _function_call(dialect): + """A niladic function call, for a parameter that wraps one. + + 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): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate + + return ComparisonPredicate(dialect, "=", Column(dialect, "a"), _literal(dialect)) + + +def _try_construct(cls, dialect): + """Construct an instance of cls with heuristic arguments; None on failure.""" + sig = inspect.signature(cls.__init__) + # DataType-family declares dialect as an optional trailing keyword. + has_dialect = "dialect" in sig.parameters + first_param = next((p for p in sig.parameters if p != "self"), None) + dialect_as_kw = first_param != "dialect" + args = [] + kwargs = {} + skipped_defaulted_positional = False + for pname, param in sig.parameters.items(): + if pname in ("self", "dialect"): + continue + if param.kind == inspect.Parameter.VAR_KEYWORD: + continue + if param.kind == inspect.Parameter.VAR_POSITIONAL: + if not skipped_defaulted_positional: + args.append(_literal(dialect)) + continue + if param.default is not inspect.Parameter.empty: + if param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: + skipped_defaulted_positional = True + continue + placeholder = _placeholder_for(param, dialect) + # placeholder None → default case; real value provided otherwise + if param.kind == inspect.Parameter.KEYWORD_ONLY: + kwargs[pname] = placeholder + else: + args.append(placeholder) + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + if not has_dialect: + return cls(*args, **kwargs) + if dialect_as_kw: + return cls(*args, dialect=dialect, **kwargs) + return cls(dialect, *args, **kwargs) + except Exception: + return None + + +def special_constructors(): + """Explicit constructors for classes whose ``__init__`` rejects heuristic + placeholder values. Keyed by a suffix that matches the class's full + module.ClassName. Registered by name only — this is a static registry and + backends may register their own entries for backend-specific expressions. + + **A key that matches no class is dead code, and nothing says so.** + :func:`make_instance` dispatches on ``full_name.endswith(key)``, so + renaming a class turns its entry into a literal that matches nothing: the + class then falls through to :func:`_try_construct`, the source is reported + as ``"heuristic"`` instead of ``"special"``, and if the heuristic also + fails the class is silently reported as unconstructible rather than as + mis-registered. This is how ``"advanced_functions.JSONExpression"`` came to + match nothing at all once core split that one class into + ``JSONDocumentValue`` and ``JSONTextValue``. + + The failure is one-directional, which is what makes it dangerous: renaming + the key once breaks the match silently, and a *single* key covering both + new names is not expressible, because ``endswith`` compares a suffix. A + class that was split needs one entry per resulting class. + + :func:`register_special_constructor` writes to the module-level + ``_SPECIAL_REGISTRY``, which is what :func:`make_instance` reads. Note that + this function rebuilds its dict on every call, so a registration made + elsewhere is **not** visible to a caller that invokes + ``special_constructors()`` directly — core's expression-contract test does + exactly that, and therefore sees only the literals below. + """ + return { + "statements.ddl_table.ColumnDefinition": _column_definition, + "statements.ddl_table.ColumnConstraint": _column_constraint, + "statements.ddl_table.ForeignKeyConstraint": _foreign_key_constraint, + "statements.ddl_table.TableConstraint": _table_constraint, + "statements.ddl_table.ColumnForeignKeyConstraint": _column_fk_constraint, + "statements.ddl_table.TableForeignKeyConstraint": _table_fk_constraint, + "statements.ddl_table.TablePrimaryKeyConstraint": _table_primary_key, + "statements.ddl_table.TableUniqueConstraint": _table_unique, + "statements.dml.ValuesSource": _values_source, + "statements.ddl_sequence.CreateSequenceStatement": _create_sequence_expr, + "statements.ddl_sequence.DropSequenceStatement": _drop_sequence_expr, + "statements.ddl_sequence.AlterSequenceStatement": _alter_sequence_expr, + "statements.ddl_type.CreateTypeStatement": _create_type_expr, + "statements.ddl_type.AlterTypeStatement": _alter_type_expr, + "statements.ddl_type.DropTypeStatement": _drop_type_expr, + "statements.ddl_domain.CreateDomainStatement": _create_domain_expr, + "statements.ddl_domain.AlterDomainStatement": _alter_domain_expr, + "statements.ddl_domain.DropDomainStatement": _drop_domain_expr, + "statements.ddl_function.CreateFunctionStatement": _create_function_expr, + "statements.ddl_function.DropFunctionStatement": _drop_function_expr, + "statements.ddl_schema.CreateSchemaStatement": _create_schema_expr, + "statements.ddl_schema.DropSchemaStatement": _drop_schema_expr, + "statements.ddl_database.CreateDatabaseStatement": _create_database_expr, + "statements.ddl_database.DropDatabaseStatement": _drop_database_expr, + # core splits what was one JSONExpression into a document and a text + # class, so one key cannot cover both: endswith matches a suffix. + "advanced_functions.JSONDocumentValue": _json_document_expr, + "advanced_functions.JSONTextValue": _json_text_expr, + "query_parts.JoinClause": _join_expr, + "statements.ddl_partition.PartitionClause": _partition_clause, + "statements.dml.OnConflictClause": _on_conflict, + "statements.dml.UpdateStatement": _update_expr, + "statements.dml.InsertStatement": _insert_expr, + "statements.dml.DeleteStatement": _delete_expr, + "statements.ddl_alter.AlterTableStatement": _alter_table_expr, + "types.array.ArrayType": _array_type, + "statements.ddl_view.CreateViewStatement": _create_view_expr, + "graph.GraphVertex": _graph_vertex, + "graph.GraphEdge": _graph_edge, + "graph.VertexTable": _node_table, + "graph.EdgeTable": _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 EdgeTable + from rhosocial.activerecord.backend.expression.objects import ( + EdgeTable as EdgeTableObject, + ) + + return EdgeTable(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 ( + AddColumn, + AlterTableStatement, + DropColumn, + ) + + column = _column_definition(dialect) + return AlterTableStatement( + dialect, + table=_table(dialect, "t"), + actions=[DropColumn(dialect, "a"), AddColumn(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 register_enums_and_dataclasses(package_path: str) -> None: + """Register a package's enums and dataclasses into the same whitelist. + + An artifact encodes an enum member as ``[short class name, member]`` and + the reader resolves that name through the registry's enum table -- no + ``importlib`` by name (untrusted input, §4.11.4), so a backend enum that + was never registered refuses to read back. Core populates its own enums + and dataclasses; a backend package's are exactly as privileged as a + third-party plugin's, which is to say they are registered, not discovered. + Same ownership filters as core's own populator: the class must be defined + in the module being walked. + """ + import dataclasses + import enum as _enum + import importlib + import pkgutil + + from rhosocial.activerecord.backend.expression.registry import RegistryError + from rhosocial.activerecord.backend.expression.serialization import default_registry + + registry = default_registry() + pkg = importlib.import_module(package_path) + if hasattr(pkg, "__path__"): + modnames = [ + m for _, m, _ in pkgutil.walk_packages( + path=pkg.__path__, prefix=package_path + ".", onerror=lambda x: None + ) + ] + else: + modnames = [package_path] + for modname in modnames: + try: + mod = importlib.import_module(modname) + except Exception: + continue + for name in dir(mod): + obj = getattr(mod, name, None) + if not isinstance(obj, type) or obj.__module__ != modname: + continue + if issubclass(obj, _enum.Enum): + try: + registry.register_enum(obj) + except RegistryError: + pass + elif dataclasses.is_dataclass(obj): + try: + registry.register_dataclass(obj) + except RegistryError: + pass + + +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" + # strict=False because the length assert above is the intended check + # and carries the better message; a strict zip would pre-empt it with + # a bare ValueError. + for i, (x, y) in enumerate(zip(a, b, strict=False)): + 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 + + +__all__ = [ + "assert_params_equal", + "collect_expression_classes", + "make_instance", + "register_all", + "register_enums_and_dataclasses", + "register_special_constructor", + "roundtrip_expression", + "sql_consistent", +] \ No newline at end of file diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py index cb7871c..8e3f067 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py @@ -20,9 +20,10 @@ Why ``to_sql()`` is classified rather than caught ================================================= -The shared testsuite helper this matrix replaces wraps the first render in -``try/except Exception: return`` (``testsuite/utils/expression.py``, in -``sql_consistent``). That makes every render failure a green tick: the three +The shared-harness helper this matrix does not use for classification wraps +the first render in ``try/except Exception: return`` (``sql_consistent``, in +the locally vendored ``expression_roundtrip_harness.py``). That makes every +render failure a green tick: the three SQL comparisons after it never run, so a formatter reading a field that no longer exists is indistinguishable from a formatter for a feature this dialect does not support. Core measured what that costs on its own matrix -- 74 errors @@ -80,8 +81,8 @@ ============================= Classes are found by walking the two packages with -``collect_expression_classes``, never by reading -``registered_expression_classes()``. The registry is process-global: it grows as +``collect_expression_classes``, never by reading the default registry's kind +whitelist. The registry is process-global: it grows as any sibling test module imports another backend, so a matrix built from it holds whatever the import order happened to produce -- core measured three different counts for the same tree that way. Walking the packages is @@ -98,7 +99,9 @@ rescue, and it is pinned in both directions so neither list grows silently. """ +import datetime import inspect +import uuid import pytest @@ -110,14 +113,32 @@ WindowDefinition, WindowSpecification, ) -from rhosocial.activerecord.backend.expression.bases import BaseExpression from rhosocial.activerecord.backend.expression.core import Column, Literal from rhosocial.activerecord.backend.expression.operators import ArithmeticOperation +from rhosocial.activerecord.backend.expression.operators import RawSQL from rhosocial.activerecord.backend.expression.query_sources import SetOperation 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.values import ( + BinaryValue, + BooleanValue, + DateValue, + StringValue, + TimeValue, + TimestampValue, + UUIDValue, ) -from rhosocial.activerecord.backend.expression.operators import RawSQL from rhosocial.activerecord.backend.expression.objects import ( Database, Domain, @@ -140,11 +161,10 @@ ) from rhosocial.activerecord.backend.expression.query_parts import JoinClause from rhosocial.activerecord.backend.expression.serialization import ( - auto_register_builtins, + default_registry, deserialize, deserialize_json, deserialize_xml, - registered_expression_classes, serialize, serialize_json, serialize_xml, @@ -209,7 +229,7 @@ from rhosocial.activerecord.backend.impl.bigquery.expression import ( BigQueryAlterMaterializedViewSetOptionsExpression, ) -from rhosocial.activerecord.testsuite.utils.expression import ( +from rhosocial.activerecord_bigquery_test.feature.backend.expression_roundtrip_harness import ( collect_expression_classes, make_instance, register_all, @@ -453,7 +473,10 @@ def register_specials(): ) register_special_constructor( "statements.ddl_alter.ValidateConstraint", - lambda d: ddl_alter.ValidateConstraint(d, name="c"), + # The action names its constraint positionally now; the keyword-only + # ``name`` must repeat it exactly, so naming it twice says less than + # naming it once. + lambda d: ddl_alter.ValidateConstraint(d, "c"), ) # -- schemas, sequences, databases, domains, types --------------------- @@ -663,7 +686,11 @@ def register_specials(): # -- types that need a member type or a member value -------------------- register_special_constructor( - "types.array.ArrayType", lambda d: ArrayType(d, VarCharType(d, 10)) + # ``element_type`` is keyword-only behind an optional ``dialect`` now, + # so a positional member type is not expressible; the keyword is the + # only spelling left. + "types.array.ArrayType", + lambda d: ArrayType(d, element_type=VarCharType(d, 10)), ) # ``values`` is keyword-only behind a defaulted positional, so the guess # skips it and the type declares no members. @@ -671,6 +698,80 @@ def register_specials(): "types.enum_.EnumType", lambda d: EnumType(d, values=["a", "b"]) ) + # -- the typed value nodes ---------------------------------------------- + # The base's ``value`` parameter is annotated ``Any``, so the filler's + # name-based guess hands every value class an int. The classes whose host + # type an int states exactly -- the numbers and the interval, through the + # coercions the nodes declare -- accept it; the seven below coerce + # nothing, because an int states no honest string, date, time, bytes or + # UUID, so each gets the real host value it exists to carry. + register_special_constructor( + "values.BinaryValue", lambda d: BinaryValue(d, b"\x01\x02") + ) + register_special_constructor( + "values.BooleanValue", lambda d: BooleanValue(d, True) + ) + register_special_constructor( + "values.DateValue", lambda d: DateValue(d, datetime.date(2026, 10, 18)) + ) + register_special_constructor( + "values.StringValue", lambda d: StringValue(d, "x") + ) + register_special_constructor( + "values.TimeValue", lambda d: TimeValue(d, datetime.time(12, 30)) + ) + register_special_constructor( + "values.TimestampValue", + lambda d: TimestampValue(d, datetime.datetime(2026, 10, 18, 12, 30)), + ) + register_special_constructor( + "values.UUIDValue", + lambda d: UUIDValue( + d, uuid.UUID("123e4567-e89b-12d3-a456-426614174000") + ), + ) + + # -- scalar string nodes whose operands are forward-ref strings --------- + # The operands are annotated as ``'SQLValue'`` forward references, which + # the filler cannot resolve structurally, and its name-based fallback hands + # a bare ``"x"`` that the nodes' typed-operand coercers refuse. Real + # operands construct exactly what each node exists to spell. + register_special_constructor( + "functions.string.ChrFunction", lambda d: ChrFunction(d, 65) + ) + register_special_constructor( + "functions.string.LeftFunction", lambda d: LeftFunction(d, "abc", 2) + ) + register_special_constructor( + "functions.string.RightFunction", lambda d: RightFunction(d, "abc", 2) + ) + register_special_constructor( + "functions.string.SpaceFunction", lambda d: SpaceFunction(d, 3) + ) + register_special_constructor( + "functions.string.SubstringExpression", + lambda d: SubstringExpression(d, "abc", 2), + ) + register_special_constructor( + "functions.string.OverlayExpression", + lambda d: OverlayExpression(d, "abc", "xy", 2), + ) + + # -- the EXTRACT split --------------------------------------------------- + # The extract refactor gave the field a closed word set beside a source, + # and the filler's guess for a ``str | DateTimeField`` union is a bare + # ``"x"`` the class refuses. A real field and a column source are what + # the node spells; both classes render through the shared + # ``format_extract_expression``. + register_special_constructor( + "datetime.ExtractFieldExpression", + lambda d: ExtractFieldExpression(d, "YEAR", Column(d, "ts")), + ) + register_special_constructor( + "datetime._ExtractBase", + lambda d: _ExtractBase(d, "YEAR", Column(d, "ts")), + ) + # -- property graphs ---------------------------------------------------- def node_table(d): return NodeTable(d, "people") @@ -691,10 +792,9 @@ def path_pattern(d): "graph.VertexTable", lambda d: graph_mod.VertexTable(d, node_table(d), key_columns=["id"]), ) - register_special_constructor( - "graph.EdgeTable", - lambda d: graph_mod.EdgeTable(d, edge_table(d), ["src"], ["dst"]), - ) + # graph.EdgeTable is gone: core renamed the element to EdgeTableDefinition, + # which the heuristic constructor builds on its own (a guessed source + # element and no edge columns), so no registered factory stands in for it. register_special_constructor( "graph.QuantifiedPath", lambda d: graph_mod.QuantifiedPath( @@ -804,15 +904,13 @@ def path_pattern(d): "rhosocial.activerecord.backend.expression.uuid.UUIDConstantExpression", "rhosocial.activerecord.backend.expression.uuid.UUIDGenerationExpression", "rhosocial.activerecord.backend.expression.uuid.UUIDCastFunction", - # CUSTOM type. `raw` names the SQL type string and defaults to the empty - # string, so the filler skips it and the class validates that empty name - # and raises InvalidTypeNameError while constructing. A registered - # constructor could hand it a real name, but the only honest one here is - # the spelling BigQuery refuses -- and substituting NUMERIC would let the - # matrix assert a rendering this dialect does not perform. BigQuery has no - # custom type, so it is named here rather than pinned as a non-render it - # can never reach: such a pin asserts a render nothing can ask for. - "rhosocial.activerecord.backend.expression.types.custom.CustomType", + # CustomType sat here while its ``raw`` parameter was defaulted to the + # empty string -- a defaulted parameter the filler skips, and a name the + # class refuses as empty. The parameter is required now, so the filler + # supplies a real name and the class builds; the entry is gone rather than + # pinned as a non-render it can never reach: this dialect spells no custom + # type, and a constructed instance refuses through the generic-type probe + # the way the other unsupported types do. ) #: The dispatch failure -- ``format_method`` named a method this dialect does not @@ -928,8 +1026,10 @@ def path_pattern(d): "rhosocial.activerecord.backend.expression.graph.VertexTable": ( UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_vertex_table'", ), - "rhosocial.activerecord.backend.expression.graph.EdgeTable": ( - UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_edge_table'", + # EdgeTable was renamed EdgeTableDefinition in the graph split; the gap is + # the same one, spelled by the new name. + "rhosocial.activerecord.backend.expression.graph.EdgeTableDefinition": ( + UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_edge_table_definition'", ), "rhosocial.activerecord.backend.expression.graph.TablePropertiesClause": ( UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_table_properties_clause'", @@ -992,6 +1092,16 @@ def path_pattern(d): "rhosocial.activerecord.backend.expression.sources.base.TableSource": ( UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_table_source'", ), + # The shared state base of the declared-result annotations. It dispatches + # to the one formatter those annotations share, which reads the ``alias`` + # only a concrete annotation carries, so the bare base -- constructible, + # with the guessed source it stores -- fails on the state it does not + # have rather than on anything this dialect lacks. AttributeError for the + # same reason the DataType root pins TypeError: the class is incomplete + # rather than the backend being unable. + "rhosocial.activerecord.backend.expression.declared._Annotation": ( + AttributeError, "has no attribute 'alias'", + ), # The root of the type tree. Every concrete type declares its own generic # `name`, which is what `format_data_type` dispatches on; the root declares # none, so there is nothing to dispatch. TypeError, not @@ -1184,7 +1294,7 @@ def _dispatched_formatter(exc): "format_match_clause", "format_graph_columns_clause", "format_vertex_table", - "format_edge_table", + "format_edge_table_definition", "format_table_properties_clause", ), "the core SQL/PSM routine statements (BigQuery's UDFs render through their " @@ -1222,6 +1332,15 @@ def _dispatched_formatter(exc): "renders elsewhere)": ("format_temporal_options",), "the TableSource root (every concrete row source overrides its formatter; " "the root carries nothing but an alias and is never rendered)": ("format_table_source",), + # Not a feature BigQuery lacks: the typed-value category base declares an + # empty kind_name, so the dispatch names the empty 'format_' for the bare + # base. Every concrete value class declares its own kind and renders; the + # base constructs (the filler hands it an int) and has nothing to dispatch + # to. The row exists because the unmodelled list is exact in both + # directions: the observed refusal is real, and its name is what separates + # it from any future gap a class might report. + "the typed-value category base (TypedValue declares no kind of its own; " + "each concrete value class overrides it)": ("format_",), } @@ -1343,7 +1462,6 @@ def expr_case(request, dialect): + "rescue it, so there is nothing for the matrix to assert on." ) cls = REGISTERED[fqn] - auto_register_builtins() # The four classes this backend defines are not core's built-ins, so the # registry cannot find them by name when it decodes a nested one. register_all({fqn: cls}) @@ -1376,7 +1494,6 @@ def test_nothing_is_skipped(self, dialect): :data:`UNCONSTRUCTIBLE` both ways, so a class that stops building fails with its own name and a named class that starts building fails too. """ - auto_register_builtins() actual = tuple( sorted( fqn @@ -1415,7 +1532,6 @@ def test_pinned_non_renders_still_raise_what_they_claim(self, dialect): Without this an entry could sit in the table for a class that now renders perfectly well, and the matrix would be asserting nothing about it. """ - auto_register_builtins() for fqn, (expected_type, fragment) in LEGITIMATE_NON_RENDERS.items(): instance, source = make_instance(REGISTERED[fqn], dialect) assert instance is not None, ( @@ -1431,7 +1547,6 @@ def test_pinned_non_renders_still_raise_what_they_claim(self, dialect): def test_pinned_not_implementeds_still_raise_what_they_claim(self, dialect): """Each pinned entry still raises, and says the reason the table names.""" - auto_register_builtins() for fqn, fragment in LEGITIMATE_NOT_IMPLEMENTED.items(): instance, source = make_instance(REGISTERED[fqn], dialect) assert instance is not None, ( @@ -1469,7 +1584,6 @@ def test_unmodelled_formatter_list_is_exact(self, dialect): what would have said so loudly if a fifth node of that family had arrived without a BigQuery formatter. """ - auto_register_builtins() register_all(REGISTERED) observed = set() for fqn in sorted(REGISTERED): @@ -1508,7 +1622,6 @@ class that appeared after import is caught. The package walk is used rather than the registry because the registry also holds whatever backends other test modules happened to import. """ - auto_register_builtins() expected = set(_collect_matrix_classes()) assert expected == set(REGISTERED), ( "the set of classes the two packages define changed after " @@ -1537,7 +1650,6 @@ def test_core_classes_are_covered_not_just_this_backends_own(self): surface is narrow enough that most of the core tree is untested without this. """ - auto_register_builtins() core = set(_collect_package(CORE_EXPR_PKG)) bigquery = set(_collect_package(BIGQUERY_EXPR_PKG)) assert core, "the core package walk found nothing" @@ -1547,13 +1659,20 @@ def test_core_classes_are_covered_not_just_this_backends_own(self): def test_every_covered_class_is_registered_for_deserialization(self): """A class in the matrix can be found again when deserializing. - Deserialization looks the class up by name, so a class the matrix - renders but the registry cannot resolve would round-trip into the wrong - thing or nothing at all. + Deserialization resolves a node's kind to its class through the + framework's default registry, so a class the matrix renders but the + registry cannot resolve would round-trip into the wrong thing or + nothing at all. Core and the built-in backends populate that registry + on first use; this backend's own classes are what ``register_all`` + adds, which is why the registration happens here before the check. """ - auto_register_builtins() register_all(REGISTERED) - unresolved = sorted(set(REGISTERED) - set(registered_expression_classes())) + registry = default_registry() + unresolved = sorted( + fqn + for fqn, cls in REGISTERED.items() + if registry.resolve_node(cls.kind()) is not cls + ) assert not unresolved, ( f"the matrix covers classes the registry cannot resolve: {unresolved}" ) @@ -1568,7 +1687,6 @@ def test_coverage_report(self, dialect): after checking that it is named in :data:`UNCONSTRUCTIBLE`, so the bucket is never a silent dumping ground. """ - auto_register_builtins() register_all(REGISTERED) counts = {} for fqn, cls in REGISTERED.items(): From 9b86d329476692f7a182f1610f893a8d0e0dbbbc Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 21 Oct 2026 02:17:51 +0800 Subject: [PATCH 13/35] refactor(bigquery): follow the literal architecture the core settled Core replaced TypedValueMixin and the twelve *Value classes with one Literal node whose value kind is a LiteralKind member, and moved every tier-2 spelling into format_literal. This backend's import of TypedValueMixin no longer resolved, so the dialect would not load. mixins/typed_value.py becomes mixins/literal.py, holding BigQueryLiteralValueMixin with a single format_literal override composed ahead of SQLDialectBase. The spellings it used to dispatch to per-kind formatters keep their exact text, now reached by host type: bytes -> b'\\xhh', UUID -> bare quoted text, timedelta -> INTERVAL n UNIT, Decimal -> NUMERIC / BIGNUMERIC, date -> DATE '...', time -> TIME '...', datetime -> DATETIME / TIMESTAMP by whether a zone is held. Every other host type falls through to the base, which is where NULL / booleans / numbers / strings and the literal-construction protocol live. test_bigquery_typed_values.py becomes test_bigquery_literal_values.py on the same assertions, re-expressed as the two positions one node answers (bind by default, inline=True for DDL) instead of the retired class-level inline_params strategy. The documented, not server-verified evidence recorded in the mixin docstring is carried over unchanged. Verified: dialect imports; test_bigquery_literal_values.py 61 passed; ruff clean. --- .../backend/impl/bigquery/dialect.py | 23 +- .../backend/impl/bigquery/mixins/__init__.py | 4 +- .../mixins/{typed_value.py => literal.py} | 288 +++++++++--------- ...ues.py => test_bigquery_literal_values.py} | 277 ++++++++--------- 4 files changed, 274 insertions(+), 318 deletions(-) rename src/rhosocial/activerecord/backend/impl/bigquery/mixins/{typed_value.py => literal.py} (55%) rename tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/{test_bigquery_typed_values.py => test_bigquery_literal_values.py} (56%) diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/dialect.py b/src/rhosocial/activerecord/backend/impl/bigquery/dialect.py index f694183..780b8e2 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/dialect.py @@ -52,11 +52,6 @@ # override is needed. ``TRIM`` differs and is overridden by # BigQueryTrimMixin (below) for the reason documented there. LpadMixin, RepeatMixin, RpadMixin, - # The typed value nodes' tier-1 rendering: the core mixin supplies the - # data-position binding and the NULL/boolean/numeric/string defaults; - # BigQueryTypedValueMixin (ahead of it, from .mixins) spells the kinds - # this grammar writes differently. - TypedValueMixin, ) from .protocols import ( BigQueryArraySupport, @@ -97,10 +92,10 @@ # TRIM is the one of the four string nodes BigQuery spells differently: # the direction lives in the function name, so BigQueryTrimMixin must # outrank core's TrimMixin in the MRO. LPAD, RPAD and REPEAT are the - # shared spellings verbatim here, and BigQueryTypedValueMixin follows - # the same pattern ahead of core's TypedValueMixin (imported above). + # shared spellings verbatim here, and BigQueryLiteralValueMixin follows + # the same pattern (see its position below). BigQueryTrimMixin, - BigQueryTypedValueMixin, + BigQueryLiteralValueMixin, ) @@ -136,12 +131,12 @@ class BigQueryDialect( # REPEAT are the shared spellings verbatim here, so the generic mixins # answer for those three unchanged. BigQueryTrimMixin, - # Value literals: BigQuery's spellings ahead of the core TypedValueMixin, - # whose tier-1 defaults the dialect keeps for the kinds this grammar - # agrees with. Both precede SQLDialectBase, so every format_*_value the - # typed nodes dispatch to exists on the dialect. - BigQueryTypedValueMixin, - TypedValueMixin, + # Value literals: BigQuery's spellings ahead of SQLDialectBase, whose + # portable forms this dialect keeps for the kinds this grammar agrees + # with. A mixin that overrides format_literal and is placed after the + # class that defines it never wins the lookup, so this position is + # structural rather than stylistic. + BigQueryLiteralValueMixin, # The backend's one naming-side mixin: both levels rendered, and # validate_catalog_name narrowed, because a BigQuery path is # project.dataset.object and a project with no dataset is not a name. diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/__init__.py index ea19800..841c3e6 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/__init__.py @@ -14,7 +14,7 @@ from .materialized_view import BigQueryMaterializedViewMixin from .transaction import BigQueryTransactionMixin from .trim import BigQueryTrimMixin -from .typed_value import BigQueryTypedValueMixin +from .literal import BigQueryLiteralValueMixin class BigQueryStructMixin: @@ -51,7 +51,7 @@ def supports_geography(self) -> bool: 'BigQueryMaterializedViewMixin', 'BigQueryTransactionMixin', 'BigQueryTrimMixin', - 'BigQueryTypedValueMixin', + 'BigQueryLiteralValueMixin', 'BigQueryStructMixin', 'BigQueryArrayMixin', 'BigQueryJSONMixin', diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/typed_value.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/literal.py similarity index 55% rename from src/rhosocial/activerecord/backend/impl/bigquery/mixins/typed_value.py rename to src/rhosocial/activerecord/backend/impl/bigquery/mixins/literal.py index 62357dc..762d4fa 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/typed_value.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/literal.py @@ -1,8 +1,13 @@ -# src/rhosocial/activerecord/backend/impl/bigquery/mixins/typed_value.py -"""BigQuery's spellings for the typed value nodes (``expression.values``). +# src/rhosocial/activerecord/backend/impl/bigquery/mixins/literal.py +"""BigQuery's inline literal spellings. + +``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 BigQuery's second tier. Evidence level: 文档(not server-verified) -------------------------------------------- +------------------------------------------ **No spelling below was measured.** No BigQuery instance is reachable from this repository's test environment and the CI emulator does not adjudicate literal grammar, so every form is transcribed from the official reference @@ -45,13 +50,18 @@ only *coerces* where the position happens to expect the type. For a ``datetime`` the naive/aware distinction picks the type: naive is zoneless ``DATETIME``, aware is ``TIMESTAMP`` with the offset spelled. + +Every other host type falls through to the base implementation, which is +where the portable forms and the literal-construction protocol live. A type +neither tier names -- ``timedelta`` is *not* one of them here, it has its own +branch above -- reaches the base's explicit ``TypeError``, and that refusal +is the answer rather than a gap. """ +import datetime import decimal -from typing import TYPE_CHECKING, Tuple - -if TYPE_CHECKING: # pragma: no cover - from rhosocial.activerecord.backend.expression.values import TypedValue +import uuid +from typing import Any #: BIGNUMERIC's documented range, transcribed from the data-types reference #: (not server-verified, read 2026-10-17). The integer part at the bound is @@ -68,119 +78,80 @@ ) -class BigQueryTypedValueMixin: - """BigQuery's tier-2 value literals and its typed tier-1 spellings. +class BigQueryLiteralValueMixin: + """BigQuery's branches of :meth:`format_literal`. - Stands alone rather than inheriting the core :class:`TypedValueMixin`, - matching SQLite's mixin: the dialect composes this class ahead of the - core one, and the overridden methods win by that position. The kinds - not written here are the ones BigQuery spells the base's way. + Stands alone rather than inheriting the base, and the dialect composes it + **ahead of** ``SQLDialectBase`` -- the one base that defines + ``format_literal`` -- because a mixin placed after the class it overrides + never wins the lookup. Everything this class does not claim falls through + to that base, which is where the portable forms and the + literal-construction protocol live. """ - def format_binary_value(self, expr: "TypedValue") -> Tuple[str, tuple]: - """A bytes literal: ``b'\\x01\\x02'``, every byte as a hex escape. + def format_literal(self, value: Any) -> str: + """Render *value* as inline SQL text, BigQuery's spelling where it has one. - The ``b`` prefix is the documented bytes-literal form, and - ``\\xhh`` -- exactly 2 hex digits, decoding to one byte -- is the - documented escape that is valid for any byte value, so every byte - is spelled that way rather than only the unprintable ones. - """ - if not type(expr).inline_params: - return self._bind_value(expr) - return "b'" + "".join(f"\\x{b:02x}" for b in bytes(expr.value)) + "'", () - - def format_uuid_value(self, expr: "TypedValue") -> Tuple[str, tuple]: - """A UUID as quoted text -- BigQuery's UUIDs live in ``STRING``. + Args: + value: The host value to write into the statement. - There is no UUID type and ``GENERATE_UUID()`` returns ``STRING``, - so the canonical text is the whole value. - """ - if not type(expr).inline_params: - return self._bind_value(expr) - return self.format_literal(str(expr.value)), () - - def format_interval_value(self, expr: "TypedValue") -> Tuple[str, tuple]: - """An interval literal: the unquoted ``INTERVAL n UNIT`` form. - - GoogleSQL's single-datetime-part literal is ``INTERVAL step_size - step_unit`` with an ``INT64`` step, unquoted (the quoted - ``'datetime_parts_string'`` range form is the other literal and is - not derived here). The step is mapped over the ``timedelta``'s own - days / seconds / microseconds as one exact integer count of - microseconds, then spelled in the largest unit that divides it -- - ``INTERVAL 1 DAY``, ``INTERVAL 90 MINUTE``, ``INTERVAL 25 HOUR``, - ``INTERVAL 86402 SECOND`` for a day-plus-two-seconds span -- and in - ``MICROSECOND`` when no larger unit divides it exactly, an odd - microsecond count for instance. ``YEAR`` / ``QUARTER`` / ``MONTH`` - are calendar units a ``timedelta`` cannot state (the core node's - docstring says so), and ``WEEK`` is seven ``DAY``s exactly, so the - mapping keeps to the units that carry the value's own magnitude. + Returns: + The escaped literal text, without a bind parameter. Raises: - ValueError: The step in the chosen unit does not fit a signed - ``INT64``. The exact minimum is refused along with anything - beyond it, because whether a parser negates before or after - the range check is not documented; every step whose literal - parses unambiguously is spelled. + ValueError: A ``Decimal`` is non-finite, its scale is past + ``BIGNUMERIC``'s 38, or its magnitude is outside both decimal + types' documented range; an aware ``time`` would have to drop + its offset; an interval step does not fit a signed ``INT64``. + TypeError: The base implementation refuses a type BigQuery has no + literal form for, and this is the same refusal. """ - if not type(expr).inline_params: - return self._bind_value(expr) - value = expr.value - total_micros = (value.days * 86_400 + value.seconds) * 1_000_000 + value.microseconds - # The largest unit that divides the span exactly; MICROSECOND, the - # one unit no remainder can escape, is the default. - unit, step = "MICROSECOND", total_micros - for candidate, micros_per in ( - ("DAY", 86_400_000_000), - ("HOUR", 3_600_000_000), - ("MINUTE", 60_000_000), - ("SECOND", 1_000_000), - ("MILLISECOND", 1_000), - ): - if total_micros % micros_per == 0: - unit, step = candidate, total_micros // micros_per - break - if not -(2**63) < step <= 2**63 - 1: - raise ValueError( - f"INTERVAL {step} {unit} does not fit a signed INT64 step " - "size; the interval literal grammar takes an INT64, so this " - "span cannot be spelled as one part" - ) - return f"INTERVAL {step} {unit}", () + # ``datetime`` is a ``date`` subclass, so the narrower type has to be + # tested first: the order of these branches is host-type order, the way + # ``LiteralKind``'s member order is. + if isinstance(value, (bytes, bytearray)): + return "b'" + "".join(f"\\x{b:02x}" for b in bytes(value)) + "'" + if isinstance(value, uuid.UUID): + return super().format_literal(str(value)) + if isinstance(value, datetime.datetime): + return self._datetime_literal(value) + if isinstance(value, datetime.date): + return f"DATE {super().format_literal(value)}" + if isinstance(value, datetime.time): + return self._time_literal(value) + if isinstance(value, datetime.timedelta): + return self._interval_literal(value) + if isinstance(value, decimal.Decimal): + return self._decimal_literal(value) + return super().format_literal(value) - def format_decimal_value(self, expr: "TypedValue") -> Tuple[str, tuple]: - """An exact-decimal literal: ``NUMERIC '...'`` or ``BIGNUMERIC '...'``. + # -- the typed literals --------------------------------------------------- - The keyword followed by a quoted value is the documented literal, - and the only exact one -- bare numeric text is a ``FLOAT64`` - literal, which is the approximation the value exists to avoid. - The keyword is chosen by which type's documented range holds the - value (data types, read 2026-10-17, not server-verified): + def _decimal_literal(self, value: decimal.Decimal) -> str: + """``NUMERIC '...'`` or ``BIGNUMERIC '...'``, by which range holds it. + + The keyword followed by a quoted value is the documented literal, and + the only exact one -- bare numeric text is a ``FLOAT64`` literal, + which is the approximation the value exists to avoid. The keyword is + chosen by which type's documented range holds the value (data types, + read 2026-10-17, not server-verified): * ``NUMERIC`` -- precision 38, scale 9, documented range - ``+/-9.9999999999999999999999999999999999999E+28``. That bound - is exactly 29 integer digits with scale 9, which is the form - checked here. + ``+/-9.9999999999999999999999999999999999999E+28``. That bound is + exactly 29 integer digits with scale 9, which is the form checked + here. * ``BIGNUMERIC`` -- scale 38, documented range - ``-5.789604...9968E+38`` to ``5.789604...9967E+38``. The - integer part at the bound is 39 digits -- the partial digit of - the "approximately 76.8 digits" precision -- so the magnitude - is compared against the documented bounds themselves - (:data:`_BIGNUMERIC_MIN` / :data:`_BIGNUMERIC_MAX`), which are - one unit apart: the negative side holds one step more than the - positive one. - - A value beyond both is refused here rather than written into SQL - the server must reject. + ``-5.789604...9968E+38`` to ``5.789604...9967E+38``. The integer + part at the bound is 39 digits -- the partial digit of the + "approximately 76.8 digits" precision -- so the magnitude is + compared against the documented bounds themselves + (:data:`_BIGNUMERIC_MIN` / :data:`_BIGNUMERIC_MAX`), which are one + unit apart: the negative side holds one step more than the positive + one. - Raises: - ValueError: The value is non-finite, its scale is past - ``BIGNUMERIC``'s 38, or its magnitude is outside both - types' documented range. + A value beyond both is refused here rather than written into SQL the + server must reject. """ - if not type(expr).inline_params: - return self._bind_value(expr) - value = expr.value if not value.is_finite(): raise ValueError( "non-finite Decimal cannot be inlined into SQL (the base " @@ -209,56 +180,79 @@ def format_decimal_value(self, expr: "TypedValue") -> Tuple[str, tuple]: # The exponent marker is spelling, not value: the documented # literal examples write it in lower case. text = text.replace("E", "e") - return f"{keyword} {self.format_literal(text)}", () + return f"{keyword} {super().format_literal(text)}" - def format_date_value(self, expr: "TypedValue") -> Tuple[str, tuple]: - """A date literal: ``DATE '2014-09-27'``, keyword and quoted text. + def _time_literal(self, value: datetime.time) -> str: + """``TIME '12:30:00.45'``, keyword and quoted text. - The date literal is the ``DATE`` keyword followed by a string - literal in the canonical date format -- the quoted ISO text the - base renders, prefixed with the keyword this grammar requires. + The ``TIME`` keyword precedes the canonical time format, which is a + time of day and nothing else: the type is documented as "independent + of a specific date and time zone". """ - if not type(expr).inline_params: - return self._bind_value(expr) - return f"DATE {self.format_literal(expr.value)}", () + if value.tzinfo is not None: + raise ValueError( + f"{value.isoformat()} is an aware time, but BigQuery's TIME is " + "a time of day independent of a specific date and time zone; " + "spelling it would have to drop the offset silently, which the " + "dialect does not do" + ) + return f"TIME {super().format_literal(value)}" - def format_time_value(self, expr: "TypedValue") -> Tuple[str, tuple]: - """A time literal: ``TIME '12:30:00.45'``, keyword and quoted text. + def _datetime_literal(self, value: datetime.datetime) -> str: + """``DATETIME '...'`` or ``TIMESTAMP '...'``, by whether a zone is held. - The ``TIME`` keyword precedes the canonical time format, which is - a time of day and nothing else: the type is documented as - "independent of a specific date and time zone". + BigQuery's grammar has a zoneless date-time type and an absolute one, + and the naive/aware distinction names them exactly: a naive + ``datetime`` is wall time with no zone, spelled ``DATETIME '...'`` -- + the zoneless literal, with no offset invented -- and an aware one is + an absolute point, spelled ``TIMESTAMP '...'`` with the offset the + value carries. This mapping neither adds a zone to a naive value nor + drops the one an aware value holds, which is the same line the core + node draws between the two. + """ + keyword = "DATETIME" if value.tzinfo is None else "TIMESTAMP" + return f"{keyword} {super().format_literal(value)}" - Raises: - ValueError: An aware ``time`` carries an offset that the - spelling would have to drop silently, which this dialect - does not do -- the offset is the caller's to remove. + def _interval_literal(self, value: datetime.timedelta) -> str: + """An interval literal: the unquoted ``INTERVAL n UNIT`` form. + + GoogleSQL's single-datetime-part literal is ``INTERVAL step_size + step_unit`` with an ``INT64`` step, unquoted (the quoted + ``'datetime_parts_string'`` range form is the other literal and is + not derived here). The step is mapped over the ``timedelta``'s own + days / seconds / microseconds as one exact integer count of + microseconds, then spelled in the largest unit that divides it -- + ``INTERVAL 1 DAY``, ``INTERVAL 90 MINUTE``, ``INTERVAL 25 HOUR``, + ``INTERVAL 86402 SECOND`` for a day-plus-two-seconds span -- and in + ``MICROSECOND`` when no larger unit divides it exactly, an odd + microsecond count for instance. ``YEAR`` / ``QUARTER`` / ``MONTH`` + are calendar units a ``timedelta`` cannot state (the core node's + docstring says so), and ``WEEK`` is seven ``DAY``s exactly, so the + mapping keeps to the units that carry the value's own magnitude. """ - if not type(expr).inline_params: - return self._bind_value(expr) - if expr.value.tzinfo is not None: + total_micros = ( + value.days * 86_400 + value.seconds + ) * 1_000_000 + value.microseconds + # The largest unit that divides the span exactly; MICROSECOND, the + # one unit no remainder can escape, is the default. + unit, step = "MICROSECOND", total_micros + for candidate, micros_per in ( + ("DAY", 86_400_000_000), + ("HOUR", 3_600_000_000), + ("MINUTE", 60_000_000), + ("SECOND", 1_000_000), + ("MILLISECOND", 1_000), + ): + if total_micros % micros_per == 0: + unit, step = candidate, total_micros // micros_per + break + if not -(2**63) < step <= 2**63 - 1: raise ValueError( - f"{expr.value.isoformat()} is an aware time, but BigQuery's " - "TIME is a time of day independent of a specific date and " - "time zone; spelling it would have to drop the offset " - "silently, which the dialect does not do" + f"INTERVAL {step} {unit} does not fit a signed INT64 step " + "size; the interval literal grammar takes an INT64, so this " + "span cannot be spelled as one part" ) - return f"TIME {self.format_literal(expr.value)}", () + return f"INTERVAL {step} {unit}" - def format_timestamp_value(self, expr: "TypedValue") -> Tuple[str, tuple]: - """A date-time literal: ``DATETIME '...'`` or ``TIMESTAMP '...'``. - BigQuery's grammar has a zoneless date-time type and an absolute - one, and the naive/aware distinction names them exactly: a naive - ``datetime`` is wall time with no zone, spelled ``DATETIME '...'`` - -- the zoneless literal, with no offset invented -- and an aware - one is an absolute point, spelled ``TIMESTAMP '...'`` with the - offset the value carries. This mapping neither adds a zone to a - naive value nor drops the one an aware value holds, which is the - same line the core node draws between the two. - """ - if not type(expr).inline_params: - return self._bind_value(expr) - if expr.value.tzinfo is None: - return f"DATETIME {self.format_literal(expr.value)}", () - return f"TIMESTAMP {self.format_literal(expr.value)}", () +__all__ = ["BigQueryLiteralValueMixin"] diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_typed_values.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_literal_values.py similarity index 56% rename from tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_typed_values.py rename to tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_literal_values.py index 3305c81..2c995a2 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_typed_values.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_literal_values.py @@ -1,21 +1,21 @@ -# tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_typed_values.py -"""BigQuery's spellings for the typed value nodes (rendering only, no server). +# tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_literal_values.py +"""BigQuery's inline literal spellings (rendering only, no server). Every assertion is a rendering assertion against the documented GoogleSQL literal grammar (lexical structure and syntax / data types, read 2026-10-17 -- **not server-verified**: no BigQuery instance is reachable from this -repository). Each kind asserts both positions the class-level -``inline_params`` strategy splits rendering into: +repository). Each claim is asserted at both positions the one value node +splits rendering into: -* the **data position** (the shipped classes: ``inline_params = False``) - binds the host value behind ``?`` and converts nothing; -* the **DDL syntax position** (``inline_params = True``, declared by the - test-local ``Ddl*`` classes the way the core lifts ``Literal`` to - ``DdlLiteral``) spells the inline literal exactly. +* the **data position** -- ``Literal(dialect, value)``, ``inline`` at its + default ``False`` -- binds the host value behind ``?`` and converts + nothing; +* the **DDL syntax position** -- ``Literal(dialect, value, inline=True)`` -- + writes the inline literal text through ``format_literal``. The explicit refusals -- an INTERVAL step that cannot fit the ``INT64`` the -grammar takes, a ``Decimal`` outside both decimal types' documented range, -a non-finite ``Decimal``, an aware ``time`` -- are asserted with their +grammar takes, a ``Decimal`` outside both decimal types' documented range, a +non-finite ``Decimal``, an aware ``time`` -- are asserted with their messages. """ @@ -25,21 +25,12 @@ import pytest -from rhosocial.activerecord.backend.expression.values import ( - BinaryValue, - BooleanValue, - DateValue, - DecimalValue, - FloatValue, - IntegerValue, - IntervalValue, - NullValue, - StringValue, - TimeValue, - TimestampValue, - UUIDValue, -) +from rhosocial.activerecord.backend.dialect.base import SQLDialectBase +from rhosocial.activerecord.backend.expression.core import Literal from rhosocial.activerecord.backend.impl.bigquery.dialect import BigQueryDialect +from rhosocial.activerecord.backend.impl.bigquery.mixins import ( + BigQueryLiteralValueMixin, +) @pytest.fixture @@ -47,88 +38,66 @@ def dialect(): return BigQueryDialect() -def _ddl(cls): - """*cls* with the DDL-position strategy, declared as a class of its own. - - Whether a value inlines is the **class's** decision (spec 4.4), never a - per-instance flag -- so the test says it the only way the contract - allows, a subclass declaring ``inline_params = True``, which is the - same move the core makes when it lifts ``Literal`` to ``DdlLiteral`` - for a DDL condition it owns. - """ - return type(f"Ddl{cls.__name__}", (cls,), {"inline_params": True}) +def _inline(dialect, value): + """The DDL-position rendering of *value* as ``(sql, params)``.""" + return Literal(dialect, value, inline=True).to_sql() class TestWiring: - """The dialect composes this backend's spellings ahead of the base's.""" - - def test_backend_mixin_wins_the_overridden_kinds(self, dialect): - for name in ( - "format_binary_value", - "format_uuid_value", - "format_interval_value", - "format_decimal_value", - "format_date_value", - "format_time_value", - "format_timestamp_value", - ): - assert getattr(dialect, name).__qualname__.startswith( - "BigQueryTypedValueMixin." - ) - - def test_base_mixin_answers_the_untouched_kinds(self, dialect): - for name in ( - "format_null_value", - "format_boolean_value", - "format_integer_value", - "format_float_value", - "format_string_value", - ): - assert getattr(dialect, name).__qualname__.startswith( - "TypedValueMixin." - ) - - -class TestBinaryValue: - def test_data_position_binds_the_bytes(self, dialect): - assert BinaryValue(dialect, b"\x01\x02").to_sql() == ("?", (b"\x01\x02",)) + """The dialect composes this backend's spelling ahead of the base's.""" - def test_ddl_position_spells_the_bytes_literal(self, dialect): - assert _ddl(BinaryValue)(dialect, b"\x01\x02").to_sql() == ( - "b'\\x01\\x02'", - (), + def test_backend_mixin_outranks_the_base(self): + mro = BigQueryDialect.__mro__ + assert mro.index(BigQueryLiteralValueMixin) < mro.index(SQLDialectBase) + + def test_the_overridden_entry_point_is_this_mixin(self, dialect): + assert ( + BigQueryDialect.format_literal.__qualname__ + == "BigQueryLiteralValueMixin.format_literal" ) + +class TestBindingDecision: + """``inline`` decides bind-or-text; BigQuery's placeholder is ``?``.""" + + def test_data_position_binds_the_host_value(self, dialect): + assert Literal(dialect, 42).to_sql() == ("?", (42,)) + + def test_inline_position_writes_text(self, dialect): + assert _inline(dialect, 42) == ("42", ()) + + +class TestBinary: + def test_data_position_binds_the_bytes(self, dialect): + assert Literal(dialect, b"\x01\x02").to_sql() == ("?", (b"\x01\x02",)) + + def test_inline_position_spells_the_bytes_literal(self, dialect): + assert _inline(dialect, b"\x01\x02") == ("b'\\x01\\x02'", ()) + def test_every_byte_becomes_one_hex_escape(self, dialect): - assert _ddl(BinaryValue)(dialect, b"\x00\x0a\xff\x7f").to_sql() == ( - "b'\\x00\\x0a\\xff\\x7f'", - (), - ) + assert _inline(dialect, b"\x00\x0a\xff\x7f") == ("b'\\x00\\x0a\\xff\\x7f'", ()) def test_empty_bytes_are_the_empty_literal(self, dialect): - assert _ddl(BinaryValue)(dialect, b"").to_sql() == ("b''", ()) + assert _inline(dialect, b"") == ("b''", ()) -class TestUUIDValue: +class TestUUID: + _VALUE = uuid.UUID("123e4567-e89b-12d3-a456-426614174000") + def test_data_position_binds_the_uuid(self, dialect): - value = uuid.UUID("123e4567-e89b-12d3-a456-426614174000") - assert UUIDValue(dialect, value).to_sql() == ("?", (value,)) + assert Literal(dialect, self._VALUE).to_sql() == ("?", (self._VALUE,)) - def test_ddl_position_is_the_bare_quoted_text(self, dialect): - assert _ddl(UUIDValue)( - dialect, uuid.UUID("123e4567-e89b-12d3-a456-426614174000") - ).to_sql() == ( + def test_inline_position_is_the_bare_quoted_text(self, dialect): + assert _inline(dialect, self._VALUE) == ( "'123e4567-e89b-12d3-a456-426614174000'", (), ) -class TestIntervalValue: +class TestInterval: def test_data_position_binds_the_timedelta(self, dialect): - assert IntervalValue(dialect, datetime.timedelta(days=1)).to_sql() == ( - "?", - (datetime.timedelta(days=1),), - ) + value = datetime.timedelta(days=1) + assert Literal(dialect, value).to_sql() == ("?", (value,)) @pytest.mark.parametrize( "value, expected", @@ -160,27 +129,22 @@ def test_data_position_binds_the_timedelta(self, dialect): (datetime.timedelta(0), "INTERVAL 0 DAY"), ], ) - def test_ddl_position_spells_the_single_part_form(self, dialect, value, expected): - assert _ddl(IntervalValue)(dialect, value).to_sql() == (expected, ()) + def test_inline_position_spells_the_single_part_form(self, dialect, value, expected): + assert _inline(dialect, value) == (expected, ()) def test_the_int64_step_boundary_is_spelled(self, dialect): value = datetime.timedelta(microseconds=2**63 - 1) - assert _ddl(IntervalValue)(dialect, value).to_sql() == ( - "INTERVAL 9223372036854775807 MICROSECOND", - (), - ) + assert _inline(dialect, value) == ("INTERVAL 9223372036854775807 MICROSECOND", ()) def test_a_step_beyond_int64_is_refused(self, dialect): with pytest.raises(ValueError, match="INT64"): - _ddl(IntervalValue)(dialect, datetime.timedelta(microseconds=2**63)).to_sql() + _inline(dialect, datetime.timedelta(microseconds=2**63)) -class TestDecimalValue: +class TestDecimal: def test_data_position_binds_the_decimal(self, dialect): - assert DecimalValue(dialect, decimal.Decimal("3.14")).to_sql() == ( - "?", - (decimal.Decimal("3.14"),), - ) + value = decimal.Decimal("3.14") + assert Literal(dialect, value).to_sql() == ("?", (value,)) @pytest.mark.parametrize( "value, expected", @@ -211,17 +175,14 @@ def test_data_position_binds_the_decimal(self, dialect): (decimal.Decimal("1e-38"), "BIGNUMERIC '1e-38'"), ], ) - def test_ddl_position_spells_the_keyword_literal(self, dialect, value, expected): - assert _ddl(DecimalValue)(dialect, value).to_sql() == (expected, ()) + def test_inline_position_spells_the_keyword_literal(self, dialect, value, expected): + assert _inline(dialect, value) == (expected, ()) def test_the_partial_39th_integer_digit_is_inside_the_range(self, dialect): # 1e38 has a 39-digit integer part -- the partial digit of the # "approximately 76.8 digits" precision -- and is inside # BIGNUMERIC's documented range, so it is spelled, not refused. - assert _ddl(DecimalValue)(dialect, decimal.Decimal("1e38")).to_sql() == ( - "BIGNUMERIC '1e+38'", - (), - ) + assert _inline(dialect, decimal.Decimal("1e38")) == ("BIGNUMERIC '1e+38'", ()) def test_the_documented_bignumeric_bounds_are_spelled(self, dialect): # The documented Min and Max, transcribed from the data-types @@ -231,56 +192,54 @@ def test_the_documented_bignumeric_bounds_are_spelled(self, dialect): # significant digits), so str() gives the plain form -- 39 integer # digits, point, 38 fractional digits -- which the quoted-value # grammar takes as it stands. - assert _ddl(DecimalValue)(dialect, decimal.Decimal( - "5.7896044618658097711785492504343953926634992332820282019728792003956564819967E+38" - )).to_sql() == ( + assert _inline( + dialect, + decimal.Decimal( + "5.7896044618658097711785492504343953926634992332820282019728792003956564819967E+38" + ), + ) == ( "BIGNUMERIC '578960446186580977117854925043439539266.34992332820282019728792003956564819967'", (), ) - assert _ddl(DecimalValue)(dialect, decimal.Decimal( - "-5.7896044618658097711785492504343953926634992332820282019728792003956564819968E+38" - )).to_sql() == ( + assert _inline( + dialect, + decimal.Decimal( + "-5.7896044618658097711785492504343953926634992332820282019728792003956564819968E+38" + ), + ) == ( "BIGNUMERIC '-578960446186580977117854925043439539266.34992332820282019728792003956564819968'", (), ) def test_past_bignumerics_documented_range_is_refused(self, dialect): with pytest.raises(ValueError, match="BIGNUMERIC"): - _ddl(DecimalValue)(dialect, decimal.Decimal("6e38")).to_sql() + _inline(dialect, decimal.Decimal("6e38")) def test_a_scale_past_bignumerics_is_refused(self, dialect): with pytest.raises(ValueError, match="BIGNUMERIC"): - _ddl(DecimalValue)(dialect, decimal.Decimal("1e-39")).to_sql() + _inline(dialect, decimal.Decimal("1e-39")) def test_non_finite_is_refused(self, dialect): with pytest.raises(ValueError, match="non-finite"): - _ddl(DecimalValue)(dialect, decimal.Decimal("NaN")).to_sql() + _inline(dialect, decimal.Decimal("NaN")) -class TestDateValue: +class TestDate: def test_data_position_binds_the_date(self, dialect): - assert DateValue(dialect, datetime.date(2014, 9, 27)).to_sql() == ( - "?", - (datetime.date(2014, 9, 27),), - ) + value = datetime.date(2014, 9, 27) + assert Literal(dialect, value).to_sql() == ("?", (value,)) - def test_ddl_position_spells_the_typed_literal(self, dialect): - assert _ddl(DateValue)(dialect, datetime.date(2014, 9, 27)).to_sql() == ( - "DATE '2014-09-27'", - (), - ) + def test_inline_position_spells_the_typed_literal(self, dialect): + assert _inline(dialect, datetime.date(2014, 9, 27)) == ("DATE '2014-09-27'", ()) -class TestTimeValue: +class TestTime: def test_data_position_binds_the_time(self, dialect): - assert TimeValue(dialect, datetime.time(12, 30)).to_sql() == ( - "?", - (datetime.time(12, 30),), - ) + value = datetime.time(12, 30) + assert Literal(dialect, value).to_sql() == ("?", (value,)) - def test_ddl_position_spells_the_typed_literal(self, dialect): - value = datetime.time(12, 30, 0, 450000) - assert _ddl(TimeValue)(dialect, value).to_sql() == ( + def test_inline_position_spells_the_typed_literal(self, dialect): + assert _inline(dialect, datetime.time(12, 30, 0, 450000)) == ( "TIME '12:30:00.450000'", (), ) @@ -290,19 +249,16 @@ def test_an_aware_time_is_refused_not_silently_zone_stripped(self, dialect): 12, 30, tzinfo=datetime.timezone(datetime.timedelta(hours=5)) ) with pytest.raises(ValueError, match="aware time"): - _ddl(TimeValue)(dialect, value).to_sql() + _inline(dialect, value) -class TestTimestampValue: +class TestTimestamp: def test_data_position_binds_the_datetime(self, dialect): - assert TimestampValue(dialect, datetime.datetime(2014, 9, 27, 12, 30)).to_sql() == ( - "?", - (datetime.datetime(2014, 9, 27, 12, 30),), - ) + value = datetime.datetime(2014, 9, 27, 12, 30) + assert Literal(dialect, value).to_sql() == ("?", (value,)) def test_a_naive_datetime_is_the_zoneless_datetime_literal(self, dialect): - value = datetime.datetime(2014, 9, 27, 12, 30, 0, 450000) - assert _ddl(TimestampValue)(dialect, value).to_sql() == ( + assert _inline(dialect, datetime.datetime(2014, 9, 27, 12, 30, 0, 450000)) == ( "DATETIME '2014-09-27 12:30:00.450000'", (), ) @@ -311,10 +267,7 @@ def test_an_aware_datetime_is_the_timestamp_literal_with_its_offset(self, dialec value = datetime.datetime( 2014, 9, 27, 12, 30, tzinfo=datetime.timezone(datetime.timedelta(hours=5)) ) - assert _ddl(TimestampValue)(dialect, value).to_sql() == ( - "TIMESTAMP '2014-09-27 12:30:00+05:00'", - (), - ) + assert _inline(dialect, value) == ("TIMESTAMP '2014-09-27 12:30:00+05:00'", ()) class TestTheKindsBigQueryAgreesWith: @@ -322,31 +275,45 @@ class TestTheKindsBigQueryAgreesWith: ``NULL`` is the keyword, ``BOOL`` takes ``TRUE``/``FALSE``, ``INT64`` and ``FLOAT64`` literals are numeric text, and a ``STRING`` literal is - quoted -- all of them the core mixin's own answers, kept by position. + quoted -- all of them the base's own answers, kept by position. """ def test_null_binds_and_spells_the_keyword(self, dialect): - assert NullValue(dialect).to_sql() == ("?", (None,)) - assert _ddl(NullValue)(dialect).to_sql() == ("NULL", ()) + assert Literal(dialect, None).to_sql() == ("?", (None,)) + assert _inline(dialect, None) == ("NULL", ()) @pytest.mark.parametrize("value, expected", [(True, "TRUE"), (False, "FALSE")]) def test_boolean_spells_true_false(self, dialect, value, expected): - assert _ddl(BooleanValue)(dialect, value).to_sql() == (expected, ()) + assert _inline(dialect, value) == (expected, ()) def test_integer_spells_numeric_text(self, dialect): - assert _ddl(IntegerValue)(dialect, 123).to_sql() == ("123", ()) + assert _inline(dialect, 123) == ("123", ()) def test_float_spells_numeric_text(self, dialect): - assert _ddl(FloatValue)(dialect, 1.5).to_sql() == ("1.5", ()) + assert _inline(dialect, 1.5) == ("1.5", ()) def test_string_spells_quoted(self, dialect): - assert _ddl(StringValue)(dialect, "abc").to_sql() == ("'abc'", ()) + assert _inline(dialect, "abc") == ("'abc'", ()) def test_string_escapes_the_quote_with_a_backslash(self, dialect): - assert _ddl(StringValue)(dialect, "it's").to_sql() == ("'it\\'s'", ()) + assert _inline(dialect, "it's") == ("'it\\'s'", ()) def test_string_escapes_the_backslash_itself(self, dialect): - assert _ddl(StringValue)(dialect, "a\\b").to_sql() == ("'a\\\\b'", ()) + assert _inline(dialect, "a\\b") == ("'a\\\\b'", ()) + + +class TestTheNonFiniteFloats: + """NaN and the infinities never reach SQL text (core kinds of their own). + + The base refuses them at the literal entry point, and BigQuery adds no + spelling: a ``float`` position carrying one has no SQL text, and the + kinds that carry them travel as text in the artifact rather than as SQL. + """ + + @pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")]) + def test_refused_at_the_literal_entry_point(self, dialect, value): + with pytest.raises(ValueError): + _inline(dialect, value) class TestTheDialectWideEscapePolicy: @@ -355,7 +322,7 @@ class TestTheDialectWideEscapePolicy: The escape table documents ``\\'`` for a single quote and makes backslashes escape-sequence introducers, so a literal backslash is doubled first; quote doubling is not in the table at all. Every inline - string the dialect renders -- value nodes, OPTIONS entries, comments -- + string the dialect renders -- literals, OPTIONS entries, comments -- goes through this policy. """ From 172c67fb47132a0ff0ffac7e63c5648ec1ca85be Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 21 Oct 2026 02:37:52 +0800 Subject: [PATCH 14/35] refactor(bigquery): audit the kinds this backend overrides and owns Kind census run with the core script from a temporary copy carrying this backend's expression package and dialect class in BACKENDS (registering it in the core script is a core-repo change, reported rather than made here): core kinds 357, bigquery override 0, bigquery custom 4. Override zero is the rule that matters most: a backend may replace the rendering of a kind core ships, never the node class behind it, because two classes under one kind leave the deserializer no way to choose. This backend adds four materialized-view nodes and overrides formatters; it replaces nothing. The census's finding: all four custom kinds spelled the backend's name as two words -- big_query_create_materialized_view_expression and the other three. BigQuery is one word, and the general derivation (a run of capitals counts as one) splits at the camel-case seam instead. The fix is the data fix sqlite, mysql, clickhouse and sqlserver already made: the expression package declares KIND_RESERVED = ("BigQuery",) for every node it defines, so the kinds read bigquery_create_materialized_view_expression and the four are parallel to sqlite_integer_type / mysql_big_int_type. KIND_RESERVED is data, not behaviour -- no class overrides kind(), so class name, kind and formatting entry keep one source. The loop walks the package's own modules rather than its __all__, so a node defined but not re-exported cannot keep the split spelling. Section 11.1 order audit, run mechanically over BigQueryDialect's 108-entry MRO: of the 46 format_* methods a BigQuery mixin defines, none is reached after an earlier definer of the same name, so no override loses the lookup. That includes the one this round added -- BigQueryLiteralValueMixin ahead of SQLDialectBase, because it overrides format_literal. Recorded because it is the check that has to keep holding. Verified: the census re-run reports 0 overrides / 4 customs, every one bigquery_*; tests/.../feature/backend apart from the two modules core's own moves left red exits 0 -- test_expression_roundtrip_all.py still names the retired *Value classes and ddl/test_type_domain_negative.py still imports the retired DdlLiteral, both reconciled in the matrix commit that follows; ruff clean. --- .../impl/bigquery/expression/__init__.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/bigquery/expression/__init__.py index 4ad80af..68bc48a 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/expression/__init__.py @@ -14,3 +14,36 @@ "BigQueryCreateMaterializedViewReplicaExpression", "BigQueryDropMaterializedViewExpression", ] + +# ``BigQuery`` is one word. The general derivation -- a run of capitals counts as +# a single word -- splits at the camel-case seam instead, so this backend shipped +# ``big_query_create_materialized_view_expression``: two spellings of one +# identifier, neither of them the word the class is named after. The kind is a +# wire contract (§3), so the identifier is declared whole and the kind is derived +# from that declaration: ``KIND_RESERVED`` is data, not behaviour, so no class +# overrides ``kind()`` and class name, kind and formatting entry keep one source. +# +# The loop walks this package's own modules rather than the ``__all__`` list +# above, for the reason the sqlite and mysql packages give: a hand-maintained +# list can only ever be out of date, and a node defined but not re-exported here +# would silently keep the split spelling. +import importlib as _importlib +import pkgutil as _pkgutil + +from rhosocial.activerecord.backend.expression.core import ( + BaseExpression as _BaseExpression, +) + +for _module_info in _pkgutil.walk_packages( + __path__, prefix=__name__ + ".", onerror=lambda _error: None +): + _module = _importlib.import_module(_module_info.name) + for _value in vars(_module).values(): + if ( + isinstance(_value, type) + and issubclass(_value, _BaseExpression) + and _value.__module__ == _module_info.name + and _value.__name__.startswith("BigQuery") + ): + _value.KIND_RESERVED = ("BigQuery",) +del _module_info, _module, _value, _importlib, _pkgutil, _BaseExpression From 1342553504959de1743f85de60c9c84cadfaf038 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 21 Oct 2026 02:46:04 +0800 Subject: [PATCH 15/35] refactor(bigquery): follow the rules the org settled for kinds and overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three checks, one per clause of expression-dialect 11. **One entry per kind, named after its kind (§2, §3).** The four materialized-view nodes were named *Expression while their formatters said _statement, and all four dispatched to *core's* entry names -- format_create_materialized_view_statement and its sibling -- so one entry took two different contracts and told them apart by isinstance. Two of those contracts differ in fields the other does not declare at all (OR REPLACE, IF NOT EXISTS, PARTITION BY, CLUSTER BY, OPTIONS, AS REPLICA OF), which is the shape §2 names: an entry branching on the object it was handed to decide *which* contract to render. The four classes are now *Statement, each kind derives its own entry (bigquery_create_materialized_view_statement & co), and the two entries over each shared shape meet in one private helper -- _create_materialized_view_sql takes the five BigQuery-only clauses as keyword arguments, so nothing reads an attribute off a node that may not carry it. The protocol follows: it declares the four backend entries beside core's two, which remain BigQuery's answers because this mixin outranks core's ViewMixin. Rendered SQL is unchanged, checked rather than argued: the seven statements from the old and the new shape are byte-identical, including OPTIONS(...), PARTITION BY, project.dataset qualification and AS REPLICA OF. **Enum spelling (§11.3, §12.1).** BigQueryMaterializedViewOptionValueType was a plain Enum, so str() gave "BigQueryMaterializedViewOptionValueType.BOOLEAN" -- the one thing a plain-JSON artifact cannot carry. It is (str, Enum) with __str__ = str.__str__ now, the spelling sqlserver, mysql and clickhouse use. BigQueryMaterializedViewOption had the same gap and gets the same line. **Override order (§11.1).** The composed dialect's MRO, audited mechanically in the previous commit, already had the arrangement the clause requires: 46 format_* entries defined by a BigQuery mixin, none reached after an earlier definer of the same name. This commit's new entries keep that, since every one of them is declared on BigQueryMaterializedViewMixin, which precedes ViewMixin. New: dialect/test_bigquery_kind_identity.py pins the three sources for every node this package defines -- entry == "format_" + kind, kind starts with the backend name, class name ends with the statement nature -- and pins which mixin owns each of the six entries. It would have failed on all four nodes before this commit. Verified: dialect/schema/protocol/clause-pair-guard suites green (441 passed); the new identity file 19 passed; ruff's finding set is identical to HEAD (19 findings, all pre-existing in these directories). --- changelog.d/3.added.md | 2 +- .../schema_namespace.md | 12 +- .../schema_namespace.md | 12 +- .../impl/bigquery/expression/__init__.py | 16 +- .../bigquery/expression/materialized_view.py | 26 +-- .../bigquery/materialized_view_options.py | 14 +- .../impl/bigquery/mixins/materialized_view.py | 218 +++++++++++++----- .../bigquery/protocols/materialized_view.py | 30 ++- .../dialect/test_bigquery_kind_identity.py | 128 ++++++++++ .../test_bigquery_materialized_view.py | 66 +++--- ...test_expression_fields_match_formatters.py | 76 +++--- .../backend/schema/test_schema_support.py | 6 +- .../test_bigquery_clause_pair_guard.py | 12 +- .../backend/test_expression_roundtrip_all.py | 9 +- 14 files changed, 445 insertions(+), 182 deletions(-) create mode 100644 tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_kind_identity.py diff --git a/changelog.d/3.added.md b/changelog.d/3.added.md index 2ad2513..20efce1 100644 --- a/changelog.d/3.added.md +++ b/changelog.d/3.added.md @@ -1 +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=…)`. +Add BigQuery materialized view support: `BigQueryCreateMaterializedViewStatement` (`OR REPLACE` / `IF NOT EXISTS` / `PARTITION BY` / `CLUSTER BY` / `OPTIONS(...)`), `BigQueryDropMaterializedViewStatement`, `BigQueryAlterMaterializedViewSetOptionsStatement`, `BigQueryCreateMaterializedViewReplicaStatement` (`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/docs/en_US/bigquery_specific_features/schema_namespace.md b/docs/en_US/bigquery_specific_features/schema_namespace.md index 498ec09..a997c83 100644 --- a/docs/en_US/bigquery_specific_features/schema_namespace.md +++ b/docs/en_US/bigquery_specific_features/schema_namespace.md @@ -544,23 +544,23 @@ All four materialized view statements name their target as a slot and the project in its `catalog_name`: ```python -BigQueryCreateMaterializedViewExpression( +BigQueryCreateMaterializedViewStatement( d, MaterializedView("mv", schema_name="app"), query=inner, ).to_sql()[0] # CREATE MATERIALIZED VIEW `app`.`mv` AS SELECT `orders`.`id` FROM `app`.`orders` -BigQueryDropMaterializedViewExpression( +BigQueryDropMaterializedViewStatement( d, MaterializedView("mv", schema_name="app"), if_exists=True, ).to_sql()[0] # DROP MATERIALIZED VIEW IF EXISTS `app`.`mv` -BigQueryAlterMaterializedViewSetOptionsExpression( +BigQueryAlterMaterializedViewSetOptionsStatement( d, MaterializedView("mv", schema_name="app"), options={"enable_refresh": True, "refresh_interval_minutes": 30}, ).to_sql()[0] # ALTER MATERIALIZED VIEW `app`.`mv` SET OPTIONS(enable_refresh = true, refresh_interval_minutes = 30) -BigQueryDropMaterializedViewExpression( +BigQueryDropMaterializedViewStatement( d, MaterializedView("mv", schema_name="app", catalog_name="myproject"), ).to_sql()[0] # DROP MATERIALIZED VIEW `myproject`.`app`.`mv` @@ -570,7 +570,7 @@ Each slot is rendered where it belongs. A project with no dataset is refused, because `project.table` is not a path BigQuery can resolve: ```python -BigQueryDropMaterializedViewExpression( +BigQueryDropMaterializedViewStatement( d, MaterializedView("mv", catalog_name="myproject"), ).to_sql() # ValueError: BigQuery renders a project only together with a dataset; @@ -587,7 +587,7 @@ source choose their datasets independently — a replica normally sits in a different dataset from the view it mirrors. ```python -BigQueryCreateMaterializedViewReplicaExpression( +BigQueryCreateMaterializedViewReplicaStatement( d, MaterializedView("mv_replica", schema_name="app"), MaterializedView("mv_src", schema_name="s3_dataset"), diff --git a/docs/zh_CN/bigquery_specific_features/schema_namespace.md b/docs/zh_CN/bigquery_specific_features/schema_namespace.md index 3547ac4..e7cb0ca 100644 --- a/docs/zh_CN/bigquery_specific_features/schema_namespace.md +++ b/docs/zh_CN/bigquery_specific_features/schema_namespace.md @@ -504,23 +504,23 @@ BigQuery 另外还有 `ALTER SCHEMA` 与 `UNDROP SCHEMA`。核心库里没有对 `schema_name` 槽位里,project 放在 `catalog_name` 槽位里。 ```python -BigQueryCreateMaterializedViewExpression( +BigQueryCreateMaterializedViewStatement( d, MaterializedView("mv", schema_name="app"), query=inner, ).to_sql()[0] # CREATE MATERIALIZED VIEW `app`.`mv` AS SELECT `orders`.`id` FROM `app`.`orders` -BigQueryDropMaterializedViewExpression( +BigQueryDropMaterializedViewStatement( d, MaterializedView("mv", schema_name="app"), if_exists=True, ).to_sql()[0] # DROP MATERIALIZED VIEW IF EXISTS `app`.`mv` -BigQueryAlterMaterializedViewSetOptionsExpression( +BigQueryAlterMaterializedViewSetOptionsStatement( d, MaterializedView("mv", schema_name="app"), options={"enable_refresh": True, "refresh_interval_minutes": 30}, ).to_sql()[0] # ALTER MATERIALIZED VIEW `app`.`mv` SET OPTIONS(enable_refresh = true, refresh_interval_minutes = 30) -BigQueryDropMaterializedViewExpression( +BigQueryDropMaterializedViewStatement( d, MaterializedView("mv", schema_name="app", catalog_name="myproject"), ).to_sql()[0] # DROP MATERIALIZED VIEW `myproject`.`app`.`mv` @@ -530,7 +530,7 @@ BigQueryDropMaterializedViewExpression( `project.table` 不是 BigQuery 能解析的路径: ```python -BigQueryDropMaterializedViewExpression( +BigQueryDropMaterializedViewStatement( d, MaterializedView("mv", catalog_name="myproject"), ).to_sql() # ValueError: BigQuery renders a project only together with a dataset; @@ -546,7 +546,7 @@ BigQuery 没有 `REFRESH MATERIALIZED VIEW` 语句,因此 副本通常与它所镜像的视图不在同一个 dataset 里。 ```python -BigQueryCreateMaterializedViewReplicaExpression( +BigQueryCreateMaterializedViewReplicaStatement( d, MaterializedView("mv_replica", schema_name="app"), MaterializedView("mv_src", schema_name="s3_dataset"), diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/bigquery/expression/__init__.py index 68bc48a..54098b3 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/expression/__init__.py @@ -2,17 +2,17 @@ """BigQuery-specific DDL expressions.""" from .materialized_view import ( - BigQueryAlterMaterializedViewSetOptionsExpression, - BigQueryCreateMaterializedViewExpression, - BigQueryCreateMaterializedViewReplicaExpression, - BigQueryDropMaterializedViewExpression, + BigQueryAlterMaterializedViewSetOptionsStatement, + BigQueryCreateMaterializedViewStatement, + BigQueryCreateMaterializedViewReplicaStatement, + BigQueryDropMaterializedViewStatement, ) __all__ = [ - "BigQueryAlterMaterializedViewSetOptionsExpression", - "BigQueryCreateMaterializedViewExpression", - "BigQueryCreateMaterializedViewReplicaExpression", - "BigQueryDropMaterializedViewExpression", + "BigQueryAlterMaterializedViewSetOptionsStatement", + "BigQueryCreateMaterializedViewStatement", + "BigQueryCreateMaterializedViewReplicaStatement", + "BigQueryDropMaterializedViewStatement", ] # ``BigQuery`` is one word. The general derivation -- a run of capitals counts as diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/expression/materialized_view.py b/src/rhosocial/activerecord/backend/impl/bigquery/expression/materialized_view.py index d4dc148..f481f5f 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/expression/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/expression/materialized_view.py @@ -40,7 +40,7 @@ * **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. + :class:`BigQueryAlterMaterializedViewSetOptionsStatement` to change it. * ``DROP`` has neither ``CASCADE`` nor ``RESTRICT``. """ from typing import Any, Dict, List, Optional, TYPE_CHECKING @@ -59,14 +59,14 @@ __all__ = [ - "BigQueryAlterMaterializedViewSetOptionsExpression", - "BigQueryCreateMaterializedViewExpression", - "BigQueryCreateMaterializedViewReplicaExpression", - "BigQueryDropMaterializedViewExpression", + "BigQueryAlterMaterializedViewSetOptionsStatement", + "BigQueryCreateMaterializedViewStatement", + "BigQueryCreateMaterializedViewReplicaStatement", + "BigQueryDropMaterializedViewStatement", ] -class BigQueryCreateMaterializedViewExpression(CreateMaterializedViewStatement): +class BigQueryCreateMaterializedViewStatement(CreateMaterializedViewStatement): """``CREATE [OR REPLACE] MATERIALIZED VIEW [IF NOT EXISTS] ... AS query``. Args: @@ -132,10 +132,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_create_materialized_view_statement" + return "format_bigquery_create_materialized_view_statement" -class BigQueryDropMaterializedViewExpression(DropMaterializedViewStatement): +class BigQueryDropMaterializedViewStatement(DropMaterializedViewStatement): """``DROP MATERIALIZED VIEW [IF EXISTS] mv_name``. BigQuery has neither ``CASCADE`` nor ``RESTRICT`` for materialized views; @@ -165,10 +165,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_drop_materialized_view_statement" + return "format_bigquery_drop_materialized_view_statement" -class BigQueryAlterMaterializedViewSetOptionsExpression(BaseExpression): +class BigQueryAlterMaterializedViewSetOptionsStatement(BaseExpression): """``ALTER MATERIALIZED VIEW [IF EXISTS] mv_name SET OPTIONS(...)``. This is BigQuery's only ``ALTER MATERIALIZED VIEW`` form — there is no way to @@ -199,10 +199,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_alter_materialized_view_set_options_statement" + return "format_bigquery_alter_materialized_view_set_options_statement" -class BigQueryCreateMaterializedViewReplicaExpression(BaseExpression): +class BigQueryCreateMaterializedViewReplicaStatement(BaseExpression): """``CREATE MATERIALIZED VIEW replica_name [OPTIONS(...)] AS REPLICA OF src``.""" #: Documented bounds for ``replication_interval_seconds``. @@ -246,4 +246,4 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_create_materialized_view_replica_statement" \ No newline at end of file + return "format_bigquery_create_materialized_view_replica_statement" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/materialized_view_options.py b/src/rhosocial/activerecord/backend/impl/bigquery/materialized_view_options.py index 8fa0ed2..d88e7ac 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/materialized_view_options.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/materialized_view_options.py @@ -21,8 +21,16 @@ ] -class BigQueryMaterializedViewOptionValueType(Enum): - """Value type of a materialized view option.""" +class BigQueryMaterializedViewOptionValueType(str, Enum): + """Value type of a materialized view option. + + A ``str`` enum whose ``__str__`` is ``str.__str__``, like the option + catalogue below: a member *is* the word it carries, so it interpolates as + that word and would travel as a plain JSON string rather than as + ``BigQueryMaterializedViewOptionValueType.BOOLEAN``. + """ + + __str__ = str.__str__ BOOLEAN = "boolean" FLOAT64 = "float64" @@ -48,6 +56,8 @@ class BigQueryMaterializedViewOption(str, Enum): True """ + __str__ = str.__str__ + ENABLE_REFRESH = "enable_refresh" REFRESH_INTERVAL_MINUTES = "refresh_interval_minutes" EXPIRATION_TIMESTAMP = "expiration_timestamp" diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/materialized_view.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/materialized_view.py index 59c1530..0cf1245 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/materialized_view.py @@ -6,13 +6,20 @@ :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. + +One entry per kind (§2). The core statements and this backend's four derived +statements are different contracts — the derived ones carry ``OR REPLACE``, +``IF NOT EXISTS``, ``PARTITION BY``, ``CLUSTER BY``, ``OPTIONS(...)`` and +``AS REPLICA OF``, which the core nodes declare no field for — so each kind +names its own entry, ``format_``, and the two entries over each shared +shape meet in one private helper. A single entry branching on ``isinstance`` +to decide which contract it was handed is what the split removes. """ from typing import Any, Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.objects import MaterializedView -from ..expression.materialized_view import BigQueryCreateMaterializedViewExpression from ..materialized_view_options import resolve_materialized_view_option if TYPE_CHECKING: # pragma: no cover @@ -22,8 +29,10 @@ ) from ..expression.materialized_view import ( - BigQueryAlterMaterializedViewSetOptionsExpression, - BigQueryCreateMaterializedViewReplicaExpression, + BigQueryAlterMaterializedViewSetOptionsStatement, + BigQueryCreateMaterializedViewReplicaStatement, + BigQueryCreateMaterializedViewStatement, + BigQueryDropMaterializedViewStatement, ) @@ -43,7 +52,8 @@ def supports_refresh_materialized_view(self) -> bool: Refresh is scheduled through ``OPTIONS(enable_refresh=…, refresh_interval_minutes=…)``; use - ``format_alter_materialized_view_set_options_statement`` to change it. + ``format_bigquery_alter_materialized_view_set_options_statement`` to + change it. """ return False @@ -56,17 +66,22 @@ def supports_materialized_view_replica(self) -> bool: return True # ------------------------------------------------------------------ - # Formatters + # CREATE MATERIALIZED VIEW # ------------------------------------------------------------------ def format_create_materialized_view_statement( self, expr: "CreateMaterializedViewStatement" ) -> Tuple[str, tuple]: - """Format ``CREATE [OR REPLACE] MATERIALIZED VIEW [IF NOT EXISTS]``. + """Format the core ``CREATE MATERIALIZED VIEW`` statement. + + The core node declares no ``OR REPLACE``, ``IF NOT EXISTS``, + ``PARTITION BY``, ``CLUSTER BY`` or ``OPTIONS(...)`` field, and this + grammar has no clause for the ones it does declare beyond the view and + its query — so the clauses it cannot express are refused and the rest + renders. Args: - expr: BigQuery or generic create expression, carrying the view it - acts on. + expr: The core create statement, carrying the view it acts on. Returns: Tuple of (SQL string, params tuple) from the defining query. @@ -79,44 +94,54 @@ def format_create_materialized_view_statement( """ self._require_materialized_view(expr, "CreateMaterializedViewStatement") self._reject_unsupported_clauses(expr, "CREATE MATERIALIZED VIEW") + # The core node declares no OR REPLACE / IF NOT EXISTS / PARTITION BY / + # CLUSTER BY / OPTIONS field: its grammar has no such clause, so the + # helper is told the answers instead of asking an object that does not + # carry them. + return self._create_materialized_view_sql(expr) + + def format_bigquery_create_materialized_view_statement( + self, expr: "BigQueryCreateMaterializedViewStatement" + ) -> Tuple[str, tuple]: + """Format ``CREATE [OR REPLACE] MATERIALIZED VIEW [IF NOT EXISTS]``. - # OR REPLACE, IF NOT EXISTS, PARTITION BY, CLUSTER BY and OPTIONS are - # BigQueryCreateMaterializedViewExpression's own fields. The generic - # statement declares none of them -- its grammar has no such clause to - # emit -- so they are read only where they are declared, and the - # isinstance dispatch on the declared class is what makes each read a - # direct one. - is_bigquery_create = isinstance(expr, BigQueryCreateMaterializedViewExpression) + Args: + expr: The BigQuery create statement, whose own fields add + ``OR REPLACE``, ``IF NOT EXISTS``, ``PARTITION BY``, + ``CLUSTER BY`` and ``OPTIONS(...)`` to the shared shape. - parts = ["CREATE"] - if is_bigquery_create and expr.or_replace: - parts.append("OR REPLACE") - parts.append("MATERIALIZED VIEW") - if is_bigquery_create and expr.if_not_exists: - parts.append("IF NOT EXISTS") - parts.append(self._materialized_view_name_sql(expr)) + Returns: + Tuple of (SQL string, params tuple) from the defining query. - if is_bigquery_create: - if expr.partition_by: - parts.append(f"PARTITION BY {expr.partition_by}") - if expr.cluster_by: - cols = ", ".join(str(column) for column in expr.cluster_by) - parts.append(f"CLUSTER BY {cols}") - options = self._format_materialized_view_options(expr.options) - if options: - parts.append(f"OPTIONS({options})") + Raises: + TypeError: ``expr.view`` is not a MaterializedView. + UnsupportedFeatureError: for the generic clauses BigQuery does not + have. + """ + self._require_materialized_view( + expr, "BigQueryCreateMaterializedViewStatement" + ) + self._reject_unsupported_clauses(expr, "CREATE MATERIALIZED VIEW") + return self._create_materialized_view_sql( + expr, + or_replace=expr.or_replace, + if_not_exists=expr.if_not_exists, + partition_by=expr.partition_by, + cluster_by=expr.cluster_by, + options=expr.options, + ) - query_sql, query_params = self._materialized_view_query_sql(expr) - parts.append(f"AS {query_sql}") - return " ".join(parts), query_params + # ------------------------------------------------------------------ + # DROP MATERIALIZED VIEW + # ------------------------------------------------------------------ def format_drop_materialized_view_statement( self, expr: "DropMaterializedViewStatement" ) -> Tuple[str, tuple]: - """Format ``DROP MATERIALIZED VIEW [IF EXISTS]``. + """Format the core ``DROP MATERIALIZED VIEW [IF EXISTS]`` statement. Args: - expr: BigQuery or generic drop expression, carrying the view. + expr: The core drop statement, carrying the view. Raises: TypeError: ``expr.view`` is not a MaterializedView. @@ -124,22 +149,35 @@ def format_drop_materialized_view_statement( ``RESTRICT`` here. """ self._require_materialized_view(expr, "DropMaterializedViewStatement") - if expr.cascade: - raise UnsupportedFeatureError( - self.name, "DROP MATERIALIZED VIEW CASCADE" - ) - if expr.restrict: - raise UnsupportedFeatureError( - self.name, "DROP MATERIALIZED VIEW RESTRICT" - ) - parts = ["DROP MATERIALIZED VIEW"] - if expr.if_exists: - parts.append("IF EXISTS") - parts.append(self._materialized_view_name_sql(expr)) - return " ".join(parts), () + return self._drop_materialized_view_sql(expr) + + def format_bigquery_drop_materialized_view_statement( + self, expr: "BigQueryDropMaterializedViewStatement" + ) -> Tuple[str, tuple]: + """Format ``DROP MATERIALIZED VIEW [IF EXISTS]``. - def format_alter_materialized_view_set_options_statement( - self, expr: "BigQueryAlterMaterializedViewSetOptionsExpression" + The BigQuery node refuses ``CASCADE`` and ``RESTRICT`` at construction, + so this entry sees neither; the refusal is stated once, where the + caller writes it, rather than twice. + + Args: + expr: The BigQuery drop statement, carrying the view. + + Raises: + TypeError: ``expr.view`` is not a MaterializedView. + UnsupportedFeatureError: either qualifier reached the node anyway. + """ + self._require_materialized_view( + expr, "BigQueryDropMaterializedViewStatement" + ) + return self._drop_materialized_view_sql(expr) + + # ------------------------------------------------------------------ + # ALTER MATERIALIZED VIEW ... SET OPTIONS + # ------------------------------------------------------------------ + + def format_bigquery_alter_materialized_view_set_options_statement( + self, expr: "BigQueryAlterMaterializedViewSetOptionsStatement" ) -> Tuple[str, tuple]: """Format ``ALTER MATERIALIZED VIEW [IF EXISTS] ... SET OPTIONS(...)``. @@ -147,7 +185,7 @@ def format_alter_materialized_view_set_options_statement( TypeError: ``expr.view`` is not a MaterializedView. """ self._require_materialized_view( - expr, "BigQueryAlterMaterializedViewSetOptionsExpression" + expr, "BigQueryAlterMaterializedViewSetOptionsStatement" ) parts = ["ALTER MATERIALIZED VIEW"] if expr.if_exists: @@ -157,8 +195,12 @@ def format_alter_materialized_view_set_options_statement( parts.append(f"SET OPTIONS({options})") return " ".join(parts), () - def format_create_materialized_view_replica_statement( - self, expr: "BigQueryCreateMaterializedViewReplicaExpression" + # ------------------------------------------------------------------ + # CREATE MATERIALIZED VIEW ... AS REPLICA OF + # ------------------------------------------------------------------ + + def format_bigquery_create_materialized_view_replica_statement( + self, expr: "BigQueryCreateMaterializedViewReplicaStatement" ) -> Tuple[str, tuple]: """Format ``CREATE MATERIALIZED VIEW replica ... AS REPLICA OF source``. @@ -176,7 +218,7 @@ def format_create_materialized_view_replica_statement( ): if not isinstance(candidate, MaterializedView): raise TypeError( - f"BigQueryCreateMaterializedViewReplicaExpression.{label} must " + f"BigQueryCreateMaterializedViewReplicaStatement.{label} must " f"be a MaterializedView, got {type(candidate).__name__}" ) replica_sql, _ = expr.replica.to_sql() @@ -194,12 +236,72 @@ def format_create_materialized_view_replica_statement( # Helpers # ------------------------------------------------------------------ + def _create_materialized_view_sql( + self, + expr: "CreateMaterializedViewStatement | BigQueryCreateMaterializedViewStatement", + *, + or_replace: bool = False, + if_not_exists: bool = False, + partition_by: Any = None, + cluster_by: Any = None, + options: Any = None, + ) -> Tuple[str, tuple]: + """The CREATE statement both kinds share, clause by clause. + + Everything up to and including the name is common; the clauses after it + are the ones only some nodes declare, so each entry passes its own + fields and the core entry passes the constants its grammar has. Reading + them off ``expr`` instead would be a probe of an attribute that exists on + one node and not the other (§1, §11.2). + """ + parts = ["CREATE"] + if or_replace: + parts.append("OR REPLACE") + parts.append("MATERIALIZED VIEW") + if if_not_exists: + parts.append("IF NOT EXISTS") + parts.append(self._materialized_view_name_sql(expr)) + + if partition_by: + parts.append(f"PARTITION BY {partition_by}") + if cluster_by: + cols = ", ".join(str(column) for column in cluster_by) + parts.append(f"CLUSTER BY {cols}") + rendered_options = self._format_materialized_view_options(options) + if rendered_options: + parts.append(f"OPTIONS({rendered_options})") + + query_sql, query_params = self._materialized_view_query_sql(expr) + parts.append(f"AS {query_sql}") + return " ".join(parts), query_params + + def _drop_materialized_view_sql( + self, + expr: "DropMaterializedViewStatement | BigQueryDropMaterializedViewStatement", + ) -> Tuple[str, tuple]: + """The DROP statement both kinds share. + + BigQuery's grammar has neither qualifier, so a node that carries one is + refused rather than rendered. The BigQuery node refuses both at + construction; the core node can still arrive carrying them, and this is + where that is answered. + """ + if expr.cascade: + raise UnsupportedFeatureError(self.name, "DROP MATERIALIZED VIEW CASCADE") + if expr.restrict: + raise UnsupportedFeatureError(self.name, "DROP MATERIALIZED VIEW RESTRICT") + parts = ["DROP MATERIALIZED VIEW"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self._materialized_view_name_sql(expr)) + return " ".join(parts), () + @staticmethod def _require_materialized_view(expr: Any, statement: str) -> None: """Refuse a statement whose target is not a materialized view. - Every one of these four formatters is reached by name -- a view's - ``format_method`` says which formatter renders it -- so nothing about the + Every one of these entries is reached by name -- a view's + ``format_method`` says which entry renders it -- so nothing about the dispatch tells them whether the object they were handed is the kind the statement acts on. Left unchecked, ``CreateMaterializedViewStatement`` given a ``Table`` renders ``CREATE MATERIALIZED VIEW`orders` ...``: the @@ -285,4 +387,4 @@ def _format_materialized_view_options(self, options: Any) -> str: else: literal = str(value) rendered.append(f"{key} = {literal}") - return ", ".join(rendered) \ No newline at end of file + return ", ".join(rendered) diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/protocols/materialized_view.py b/src/rhosocial/activerecord/backend/impl/bigquery/protocols/materialized_view.py index 868ca9c..d4e4fa3 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/protocols/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/protocols/materialized_view.py @@ -28,6 +28,12 @@ class BigQueryMaterializedViewSupport(Protocol): -- dataset and, optionally, project in the object's fixed slots -- so all four statements render their target through the shared qualified-name renderer. + + One entry per kind, ``format_`` (§2, §3): the two core statements + this dialect renders, and the four kinds this backend defines. The core + ``CreateMaterializedViewStatement`` / ``DropMaterializedViewStatement`` + keep core's entry names; the BigQuery-named kinds carry the backend name at + the head of theirs, exactly as their kinds do. """ def supports_materialized_view(self) -> bool: @@ -47,17 +53,33 @@ def supports_materialized_view_replica(self) -> bool: ... def format_create_materialized_view_statement(self, expr: Any) -> Tuple[str, tuple]: - """Format ``CREATE [OR REPLACE] MATERIALIZED VIEW [IF NOT EXISTS]``.""" + """Format the core ``CREATE MATERIALIZED VIEW`` statement.""" ... def format_drop_materialized_view_statement(self, expr: Any) -> Tuple[str, tuple]: + """Format the core ``DROP MATERIALIZED VIEW`` statement.""" + ... + + def format_bigquery_create_materialized_view_statement( + self, expr: Any + ) -> Tuple[str, tuple]: + """Format ``CREATE [OR REPLACE] MATERIALIZED VIEW [IF NOT EXISTS]``.""" + ... + + def format_bigquery_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]: + def format_bigquery_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]: + def format_bigquery_create_materialized_view_replica_statement( + self, expr: Any + ) -> Tuple[str, tuple]: """Format ``CREATE MATERIALIZED VIEW replica ... AS REPLICA OF source``.""" - ... \ No newline at end of file + ... diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_kind_identity.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_kind_identity.py new file mode 100644 index 0000000..bcd98d2 --- /dev/null +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_kind_identity.py @@ -0,0 +1,128 @@ +# tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_kind_identity.py +"""Kind, class name and formatting entry are three spellings of one identity. + +§3 derives the kind from the class name and names the formatting entry +``format_`` + kind. Nothing in the framework compares the three, so nothing +detects a node whose entry is called something else -- and the reader of an +artifact has to know which entry a kind dispatches to, which is exactly the +answer that goes missing. + +These cases pin all three sources for every node this backend defines, and the +arrangement that lets a backend entry win: an override placed after the class +it overrides never wins the lookup (§11.1), so each one must resolve ahead of +the core entry of the same name. + +Everything here is construction and reflection on the class tree: no +connection, no dialect capability probe, so the file satisfies §12. +""" + +import importlib +import pkgutil + +import pytest + +from rhosocial.activerecord.backend.expression.core import BaseExpression +from rhosocial.activerecord.backend.impl.bigquery.dialect import BigQueryDialect + +BIGQUERY_EXPR_PKG = "rhosocial.activerecord.backend.impl.bigquery.expression" + + +def _bigquery_nodes(): + """Every concrete expression class this backend's package defines.""" + pkg = importlib.import_module(BIGQUERY_EXPR_PKG) + found = {} + for module_info in pkgutil.walk_packages( + pkg.__path__, pkg.__name__ + ".", onerror=lambda _error: None + ): + module = importlib.import_module(module_info.name) + for name, obj in vars(module).items(): + if ( + isinstance(obj, type) + and obj.__module__ == module_info.name + and issubclass(obj, BaseExpression) + and obj is not BaseExpression + and not getattr(obj, "__abstractmethods__", None) + ): + found[f"{module_info.name}.{name}"] = obj + return found + + +NODES = _bigquery_nodes() + + +def _declared_entry(cls): + """The entry name *cls* declares, read off the class rather than an instance.""" + declared = cls.__dict__.get("format_method") + if not isinstance(declared, property): + return None + return declared.fget(cls.__new__(cls)) + + +class TestThreeSourcesAgree: + def test_the_package_defines_the_four_materialized_view_statements(self): + """A guard on the sweep below: an empty package would pass it silently.""" + assert set(NODES) == { + f"{BIGQUERY_EXPR_PKG}.materialized_view.BigQueryAlterMaterializedViewSetOptionsStatement", + f"{BIGQUERY_EXPR_PKG}.materialized_view.BigQueryCreateMaterializedViewReplicaStatement", + f"{BIGQUERY_EXPR_PKG}.materialized_view.BigQueryCreateMaterializedViewStatement", + f"{BIGQUERY_EXPR_PKG}.materialized_view.BigQueryDropMaterializedViewStatement", + } + + @pytest.mark.parametrize("fqn", sorted(NODES)) + def test_the_entry_is_format_plus_the_kind(self, fqn): + """§3: kind and entry are one identity, so the entry names the kind.""" + cls = NODES[fqn] + entry = _declared_entry(cls) + assert entry is not None, f"{fqn} declares no format_method" + assert entry == "format_" + cls.kind(), ( + f"{fqn}: class name gives kind {cls.kind()!r}, but the class " + f"declares {entry!r}" + ) + + @pytest.mark.parametrize("fqn", sorted(NODES)) + def test_the_kind_starts_with_the_backend_name(self, fqn): + """§3: the backend name is at the head of a kind it owns.""" + kind = NODES[fqn].kind() + assert kind.startswith("bigquery_"), kind + + @pytest.mark.parametrize("fqn", sorted(NODES)) + def test_the_class_name_ends_with_its_nature(self, fqn): + """§3: these four are statements, and their names say so.""" + cls = NODES[fqn] + assert cls.__name__.endswith("Statement"), cls.__name__ + assert cls.kind().endswith("_statement"), cls.kind() + + +class TestTheEntryResolvesAheadOfCore: + """§11.1: an override placed after the class it overrides never wins.""" + + @pytest.mark.parametrize( + "entry", + [ + "format_bigquery_create_materialized_view_statement", + "format_bigquery_drop_materialized_view_statement", + "format_bigquery_alter_materialized_view_set_options_statement", + "format_bigquery_create_materialized_view_replica_statement", + ], + ) + def test_the_bigquery_mixin_owns_the_entry(self, entry): + assert ( + getattr(BigQueryDialect, entry).__qualname__.startswith( + "BigQueryMaterializedViewMixin." + ) + ) + + @pytest.mark.parametrize( + "entry", + [ + "format_create_materialized_view_statement", + "format_drop_materialized_view_statement", + ], + ) + def test_the_core_statements_still_reach_the_bigquery_mixin(self, entry): + """Core's own entry names are BigQuery's answers too, not core's.""" + assert ( + getattr(BigQueryDialect, entry).__qualname__.startswith( + "BigQueryMaterializedViewMixin." + ) + ) 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 index f053e20..dddb324 100644 --- 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 @@ -19,10 +19,10 @@ RefreshMaterializedViewStatement, ) from rhosocial.activerecord.backend.impl.bigquery.expression import ( - BigQueryAlterMaterializedViewSetOptionsExpression, - BigQueryCreateMaterializedViewExpression, - BigQueryCreateMaterializedViewReplicaExpression, - BigQueryDropMaterializedViewExpression, + BigQueryAlterMaterializedViewSetOptionsStatement, + BigQueryCreateMaterializedViewStatement, + BigQueryCreateMaterializedViewReplicaStatement, + BigQueryDropMaterializedViewStatement, ) from rhosocial.activerecord.backend.impl.bigquery.materialized_view_options import ( BigQueryMaterializedViewOption, @@ -76,7 +76,7 @@ def test_formatter_is_owned_by_bigquery_mixin(self, dialect): class TestCreateMaterializedView: def test_minimal(self, dialect): - expr = BigQueryCreateMaterializedViewExpression( + expr = BigQueryCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "sales_summary"), _query(dialect) ) sql, params = expr.to_sql() @@ -87,20 +87,20 @@ def test_minimal(self, dialect): assert params == () def test_or_replace(self, dialect): - expr = BigQueryCreateMaterializedViewExpression( + expr = BigQueryCreateMaterializedViewStatement( dialect, MaterializedView(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( + expr = BigQueryCreateMaterializedViewStatement( dialect, MaterializedView(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( + BigQueryCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv"), _query(dialect), @@ -109,7 +109,7 @@ def test_or_replace_and_if_not_exists_are_exclusive(self, dialect): ) def test_partition_by_and_cluster_by(self, dialect): - expr = BigQueryCreateMaterializedViewExpression( + expr = BigQueryCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv"), _query(dialect), @@ -121,7 +121,7 @@ def test_partition_by_and_cluster_by(self, dialect): assert "CLUSTER BY product_id, region" in sql def test_clause_order_matches_google_sql(self, dialect): - expr = BigQueryCreateMaterializedViewExpression( + expr = BigQueryCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv"), _query(dialect), @@ -136,7 +136,7 @@ def test_clause_order_matches_google_sql(self, dialect): assert sql.index("OPTIONS(") < sql.index(" AS ") def test_options_rendering(self, dialect): - expr = BigQueryCreateMaterializedViewExpression( + expr = BigQueryCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv"), _query(dialect), @@ -160,14 +160,14 @@ def test_dataset_qualified_name(self, dialect): identifier, ```analytics.sales_summary```, which names no object BigQuery has. """ - expr = BigQueryCreateMaterializedViewExpression( + expr = BigQueryCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "sales_summary", schema_name="analytics"), _query(dialect), ) assert "`analytics`.`sales_summary`" in expr.to_sql()[0] def test_project_qualified_name(self, dialect): - expr = BigQueryCreateMaterializedViewExpression( + expr = BigQueryCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "sales_summary", schema_name="analytics", catalog_name="proj"), _query(dialect), @@ -176,7 +176,7 @@ def test_project_qualified_name(self, dialect): def test_project_without_dataset_refused(self, dialect): """A BigQuery path is project.dataset.table, so this cannot render.""" - expr = BigQueryCreateMaterializedViewExpression( + expr = BigQueryCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "sales_summary", catalog_name="proj"), _query(dialect), ) @@ -184,7 +184,7 @@ def test_project_without_dataset_refused(self, dialect): expr.to_sql() def test_raw_query_string_accepted(self, dialect): - expr = BigQueryCreateMaterializedViewExpression( + expr = BigQueryCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv"), "SELECT 1 AS one" ) assert expr.to_sql()[0].endswith("AS SELECT 1 AS one") @@ -200,7 +200,7 @@ def test_empty_dataset_rejected(self, dialect): def test_empty_cluster_by_rejected(self, dialect): with pytest.raises(ValueError, match="cluster_by"): - BigQueryCreateMaterializedViewExpression( + BigQueryCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv"), _query(dialect), cluster_by=[] ) @@ -300,24 +300,24 @@ def test_drop_restrict_rejected(self, dialect): class TestDropMaterializedView: def test_plain(self, dialect): - expr = BigQueryDropMaterializedViewExpression(dialect, MaterializedView(dialect, "mv")) + expr = BigQueryDropMaterializedViewStatement(dialect, MaterializedView(dialect, "mv")) assert expr.to_sql()[0] == "DROP MATERIALIZED VIEW `mv`" def test_if_exists(self, dialect): - expr = BigQueryDropMaterializedViewExpression( + expr = BigQueryDropMaterializedViewStatement( dialect, MaterializedView(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( + BigQueryDropMaterializedViewStatement( dialect, MaterializedView(dialect, "mv"), cascade=True ) def test_restrict_rejected_at_construction(self, dialect): with pytest.raises(ValueError, match="RESTRICT"): - BigQueryDropMaterializedViewExpression( + BigQueryDropMaterializedViewStatement( dialect, MaterializedView(dialect, "mv"), restrict=True ) @@ -330,7 +330,7 @@ def test_generic_expression_renders(self, dialect): class TestAlterMaterializedViewSetOptions: def test_basic(self, dialect): - expr = BigQueryAlterMaterializedViewSetOptionsExpression( + expr = BigQueryAlterMaterializedViewSetOptionsStatement( dialect, MaterializedView(dialect, "mv"), {BigQueryMaterializedViewOption.REFRESH_INTERVAL_MINUTES: 20}, @@ -343,7 +343,7 @@ def test_basic(self, dialect): assert params == () def test_if_exists(self, dialect): - expr = BigQueryAlterMaterializedViewSetOptionsExpression( + expr = BigQueryAlterMaterializedViewSetOptionsStatement( dialect, MaterializedView(dialect, "mv"), {BigQueryMaterializedViewOption.ENABLE_REFRESH: False}, @@ -353,20 +353,20 @@ def test_if_exists(self, dialect): def test_empty_options_rejected(self, dialect): with pytest.raises(ValueError, match="options"): - BigQueryAlterMaterializedViewSetOptionsExpression( + BigQueryAlterMaterializedViewSetOptionsStatement( dialect, MaterializedView(dialect, "mv"), {} ) def test_undocumented_option_rejected(self, dialect): with pytest.raises(ValueError, match="does not document"): - BigQueryAlterMaterializedViewSetOptionsExpression( + BigQueryAlterMaterializedViewSetOptionsStatement( dialect, MaterializedView(dialect, "mv"), {"enable_reffresh": True} ) class TestMaterializedViewReplica: def test_basic(self, dialect): - expr = BigQueryCreateMaterializedViewReplicaExpression( + expr = BigQueryCreateMaterializedViewReplicaStatement( dialect, MaterializedView(dialect, "mv_replica"), MaterializedView(dialect, "mv_source"), @@ -381,7 +381,7 @@ def test_replica_and_source_may_live_in_different_datasets(self, dialect): A replica normally sits in a different dataset from the view it mirrors. Reusing one namespace for both made that inexpressible. """ - expr = BigQueryCreateMaterializedViewReplicaExpression( + expr = BigQueryCreateMaterializedViewReplicaStatement( dialect, MaterializedView(dialect, "mv_replica", schema_name="warehouse"), MaterializedView(dialect, "mv_source", schema_name="sales"), @@ -401,13 +401,13 @@ def test_a_bare_string_never_renders(self, dialect): a missing one. The formatter now checks the kind before rendering, so the refusal names the parameter and says what it wanted instead. """ - expr = BigQueryCreateMaterializedViewReplicaExpression( + expr = BigQueryCreateMaterializedViewReplicaStatement( dialect, "mv_replica", MaterializedView(dialect, "mv_source") ) with pytest.raises(TypeError) as exc_info: expr.to_sql() assert ( - "BigQueryCreateMaterializedViewReplicaExpression.replica must be a " + "BigQueryCreateMaterializedViewReplicaStatement.replica must be a " "MaterializedView, got str" in str(exc_info.value) ) @@ -418,18 +418,18 @@ def test_a_bare_string_source_view_is_refused_too(self, dialect): a different dataset from the view it mirrors -- which is exactly why the two are separate parameters and each has to be right. """ - expr = BigQueryCreateMaterializedViewReplicaExpression( + expr = BigQueryCreateMaterializedViewReplicaStatement( dialect, MaterializedView(dialect, "mv_replica"), "mv_source" ) with pytest.raises(TypeError) as exc_info: expr.to_sql() assert ( - "BigQueryCreateMaterializedViewReplicaExpression.source_view must be " + "BigQueryCreateMaterializedViewReplicaStatement.source_view must be " "a MaterializedView, got str" in str(exc_info.value) ) def test_with_interval(self, dialect): - expr = BigQueryCreateMaterializedViewReplicaExpression( + expr = BigQueryCreateMaterializedViewReplicaStatement( dialect, MaterializedView(dialect, "mv_replica"), MaterializedView(dialect, "mv_source"), replication_interval_seconds=600 @@ -440,14 +440,14 @@ def test_with_interval(self, dialect): def test_interval_bounds_enforced(self, dialect, seconds): """Documented range is 60..3600 inclusive.""" with pytest.raises(ValueError, match="replication_interval_seconds"): - BigQueryCreateMaterializedViewReplicaExpression( + BigQueryCreateMaterializedViewReplicaStatement( dialect, MaterializedView(dialect, "mv_replica"), MaterializedView(dialect, "mv_source"), replication_interval_seconds=seconds ) def test_documented_defaults(self): - expr_cls = BigQueryCreateMaterializedViewReplicaExpression + expr_cls = BigQueryCreateMaterializedViewReplicaStatement assert expr_cls.MIN_REPLICATION_INTERVAL_SECONDS == 60 assert expr_cls.MAX_REPLICATION_INTERVAL_SECONDS == 3600 assert expr_cls.DEFAULT_REPLICATION_INTERVAL_SECONDS == 300 diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_expression_fields_match_formatters.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_expression_fields_match_formatters.py index 573e808..07c0383 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_expression_fields_match_formatters.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_expression_fields_match_formatters.py @@ -54,17 +54,17 @@ def test_create_materialized_view(self, dialect): """A class defined in this repository, qualified by a formatter here.""" from rhosocial.activerecord.backend.expression import Column, QueryStatement from rhosocial.activerecord.backend.impl.bigquery.expression.materialized_view import ( - BigQueryCreateMaterializedViewExpression, + BigQueryCreateMaterializedViewStatement, ) query = QueryStatement(dialect, [Column(dialect, "id")], from_="orders") - expr = BigQueryCreateMaterializedViewExpression( + expr = BigQueryCreateMaterializedViewStatement( dialect, view=MaterializedView(dialect, "mv_orders"), query=query ) assert expr.to_sql()[0] == ( "CREATE MATERIALIZED VIEW `mv_orders` AS SELECT `id` FROM `orders`" ), expr.to_sql()[0] - qualified = BigQueryCreateMaterializedViewExpression( + qualified = BigQueryCreateMaterializedViewStatement( dialect, view=MaterializedView(dialect, "mv_orders", schema_name="app"), query=query ) assert qualified.to_sql()[0] == ( @@ -74,14 +74,14 @@ def test_create_materialized_view(self, dialect): def test_drop_materialized_view(self, dialect): from rhosocial.activerecord.backend.impl.bigquery.expression.materialized_view import ( - BigQueryDropMaterializedViewExpression, + BigQueryDropMaterializedViewStatement, ) - expr = BigQueryDropMaterializedViewExpression( + expr = BigQueryDropMaterializedViewStatement( dialect, view=MaterializedView(dialect, "mv_orders") ) assert expr.to_sql()[0] == "DROP MATERIALIZED VIEW `mv_orders`", expr.to_sql()[0] - qualified = BigQueryDropMaterializedViewExpression( + qualified = BigQueryDropMaterializedViewStatement( dialect, view=MaterializedView(dialect, "mv_orders", schema_name="app") ) assert qualified.to_sql()[0] == ( @@ -95,11 +95,11 @@ def test_alter_materialized_view_set_options(self, dialect): class, so it is one of the two that reached CI this way. """ from rhosocial.activerecord.backend.impl.bigquery.expression.materialized_view import ( - BigQueryAlterMaterializedViewSetOptionsExpression, + BigQueryAlterMaterializedViewSetOptionsStatement, ) def build(schema_name=None): - return BigQueryAlterMaterializedViewSetOptionsExpression( + return BigQueryAlterMaterializedViewSetOptionsStatement( dialect, view=MaterializedView(dialect, "mv_orders", schema_name=schema_name), options={"enable_refresh": True}, @@ -129,17 +129,17 @@ def test_each_mv_statement_refuses_a_table_in_place_of_the_view(self, dialect): from rhosocial.activerecord.backend.expression import Column, QueryStatement from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.impl.bigquery.expression.materialized_view import ( - BigQueryAlterMaterializedViewSetOptionsExpression, - BigQueryCreateMaterializedViewExpression, - BigQueryCreateMaterializedViewReplicaExpression, - BigQueryDropMaterializedViewExpression, + BigQueryAlterMaterializedViewSetOptionsStatement, + BigQueryCreateMaterializedViewStatement, + BigQueryCreateMaterializedViewReplicaStatement, + BigQueryDropMaterializedViewStatement, ) query = QueryStatement(dialect, [Column(dialect, "id")], from_="orders") wrong = Table(dialect, "orders") with pytest.raises(TypeError) as exc: - BigQueryCreateMaterializedViewExpression( + BigQueryCreateMaterializedViewStatement( dialect, view=wrong, query=query ).to_sql() assert "CreateMaterializedViewStatement.view must be a MaterializedView" in str( @@ -147,17 +147,17 @@ def test_each_mv_statement_refuses_a_table_in_place_of_the_view(self, dialect): ) with pytest.raises(TypeError) as exc: - BigQueryDropMaterializedViewExpression(dialect, view=wrong).to_sql() + BigQueryDropMaterializedViewStatement(dialect, view=wrong).to_sql() assert "DropMaterializedViewStatement.view must be a MaterializedView" in str( exc.value ) with pytest.raises(TypeError) as exc: - BigQueryAlterMaterializedViewSetOptionsExpression( + BigQueryAlterMaterializedViewSetOptionsStatement( dialect, view=wrong, options={"enable_refresh": True} ).to_sql() assert ( - "BigQueryAlterMaterializedViewSetOptionsExpression.view must be a " + "BigQueryAlterMaterializedViewSetOptionsStatement.view must be a " "MaterializedView" in str(exc.value) ) @@ -170,10 +170,10 @@ def test_a_dataset_is_still_accepted_where_a_view_belongs(self, dialect): of the wrong kind that happens to carry the slots. """ from rhosocial.activerecord.backend.impl.bigquery.expression import ( - BigQueryDropMaterializedViewExpression, + BigQueryDropMaterializedViewStatement, ) - sql, _ = BigQueryDropMaterializedViewExpression( + sql, _ = BigQueryDropMaterializedViewStatement( dialect, view=MaterializedView(dialect, "mv", schema_name="app"), ).to_sql() @@ -213,18 +213,18 @@ def test_all_three_mv_statements_render_one_carrier(self, dialect): """ from rhosocial.activerecord.backend.expression import Column, QueryStatement from rhosocial.activerecord.backend.impl.bigquery.expression.materialized_view import ( - BigQueryAlterMaterializedViewSetOptionsExpression, - BigQueryCreateMaterializedViewExpression, - BigQueryDropMaterializedViewExpression, + BigQueryAlterMaterializedViewSetOptionsStatement, + BigQueryCreateMaterializedViewStatement, + BigQueryDropMaterializedViewStatement, ) view = MaterializedView(dialect, "mv_orders", schema_name="app") query = QueryStatement(dialect, [Column(dialect, "id")], from_="orders") rendered = [ - BigQueryCreateMaterializedViewExpression(dialect, view=view, query=query) + BigQueryCreateMaterializedViewStatement(dialect, view=view, query=query) .to_sql()[0], - BigQueryDropMaterializedViewExpression(dialect, view=view).to_sql()[0], - BigQueryAlterMaterializedViewSetOptionsExpression( + BigQueryDropMaterializedViewStatement(dialect, view=view).to_sql()[0], + BigQueryAlterMaterializedViewSetOptionsStatement( dialect, view=view, options={"enable_refresh": True} ).to_sql()[0], ] @@ -253,10 +253,10 @@ def test_a_project_reaches_the_sql_through_the_object(self, dialect): """ from rhosocial.activerecord.backend.expression import Column, QueryStatement from rhosocial.activerecord.backend.impl.bigquery.expression.materialized_view import ( - BigQueryDropMaterializedViewExpression, + BigQueryDropMaterializedViewStatement, ) - expr = BigQueryDropMaterializedViewExpression( + expr = BigQueryDropMaterializedViewStatement( dialect, view=MaterializedView(dialect, "mv_orders", schema_name="app", catalog_name="proj"), ) @@ -271,10 +271,10 @@ def test_a_project_without_a_dataset_is_refused(self, dialect): is known -- not from the constructor, where it is not. """ from rhosocial.activerecord.backend.impl.bigquery.expression.materialized_view import ( - BigQueryDropMaterializedViewExpression, + BigQueryDropMaterializedViewStatement, ) - expr = BigQueryDropMaterializedViewExpression( + expr = BigQueryDropMaterializedViewStatement( dialect, view=MaterializedView(dialect, "mv_orders", catalog_name="proj") ) with pytest.raises(ValueError, match="project only together with a dataset"): @@ -310,19 +310,19 @@ class TestExpressionSignatures: [ ( "rhosocial.activerecord.backend.impl.bigquery.expression.materialized_view", - "BigQueryCreateMaterializedViewExpression", + "BigQueryCreateMaterializedViewStatement", ), ( "rhosocial.activerecord.backend.impl.bigquery.expression.materialized_view", - "BigQueryDropMaterializedViewExpression", + "BigQueryDropMaterializedViewStatement", ), ( "rhosocial.activerecord.backend.impl.bigquery.expression.materialized_view", - "BigQueryAlterMaterializedViewSetOptionsExpression", + "BigQueryAlterMaterializedViewSetOptionsStatement", ), ( "rhosocial.activerecord.backend.impl.bigquery.expression.materialized_view", - "BigQueryCreateMaterializedViewReplicaExpression", + "BigQueryCreateMaterializedViewReplicaStatement", ), ], ) @@ -357,15 +357,15 @@ def test_view_defaults_to_unqualified(self): separately. """ from rhosocial.activerecord.backend.impl.bigquery.expression.materialized_view import ( - BigQueryAlterMaterializedViewSetOptionsExpression, - BigQueryCreateMaterializedViewExpression, - BigQueryDropMaterializedViewExpression, + BigQueryAlterMaterializedViewSetOptionsStatement, + BigQueryCreateMaterializedViewStatement, + BigQueryDropMaterializedViewStatement, ) for cls in ( - BigQueryCreateMaterializedViewExpression, - BigQueryDropMaterializedViewExpression, - BigQueryAlterMaterializedViewSetOptionsExpression, + BigQueryCreateMaterializedViewStatement, + BigQueryDropMaterializedViewStatement, + BigQueryAlterMaterializedViewSetOptionsStatement, ): params = inspect.signature(cls.__init__).parameters assert "schema_name" not in params, ( diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/schema/test_schema_support.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/schema/test_schema_support.py index 7c9a251..4cb8abc 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/schema/test_schema_support.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/schema/test_schema_support.py @@ -107,14 +107,14 @@ def test_every_materialized_view_statement_uses_the_slots(self): silently dropped on the way through. """ from rhosocial.activerecord.backend.impl.bigquery.expression import ( - BigQueryDropMaterializedViewExpression, + BigQueryDropMaterializedViewStatement, ) dialect = BigQueryDialect() - assert BigQueryDropMaterializedViewExpression( + assert BigQueryDropMaterializedViewStatement( dialect, MaterializedView(dialect, "mv", schema_name="app") ).to_sql()[0] == "DROP MATERIALIZED VIEW `app`.`mv`" - assert BigQueryDropMaterializedViewExpression( + assert BigQueryDropMaterializedViewStatement( dialect, MaterializedView(dialect, "mv", schema_name="app", catalog_name="myproject"), ).to_sql()[0] == "DROP MATERIALIZED VIEW `myproject`.`app`.`mv`" diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_clause_pair_guard.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_clause_pair_guard.py index 7e9d9d3..b6724a1 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_clause_pair_guard.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_clause_pair_guard.py @@ -64,8 +64,8 @@ ) from rhosocial.activerecord.backend.impl.bigquery.dialect import BigQueryDialect from rhosocial.activerecord.backend.impl.bigquery.expression import ( - BigQueryCreateMaterializedViewExpression, - BigQueryDropMaterializedViewExpression, + BigQueryCreateMaterializedViewStatement, + BigQueryDropMaterializedViewStatement, ) @@ -267,7 +267,7 @@ def test_both_parameters_are_api_misuse(self, dialect): self._generic(dialect, with_data=True, no_data=True) def test_bigquery_subclass_renders_the_plain_form(self, dialect): - expr = BigQueryCreateMaterializedViewExpression( + expr = BigQueryCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv"), _query(dialect) ) assert expr.to_sql()[0] == ( @@ -308,16 +308,16 @@ def test_both_parameters_are_api_misuse(self, dialect): def test_bigquery_subclass_refuses_both_at_construction(self, dialect): with pytest.raises(ValueError, match="CASCADE"): - BigQueryDropMaterializedViewExpression( + BigQueryDropMaterializedViewStatement( dialect, MaterializedView(dialect, "mv"), cascade=True ) with pytest.raises(ValueError, match="RESTRICT"): - BigQueryDropMaterializedViewExpression( + BigQueryDropMaterializedViewStatement( dialect, MaterializedView(dialect, "mv"), restrict=True ) def test_bigquery_subclass_renders_the_plain_form(self, dialect): - expr = BigQueryDropMaterializedViewExpression( + expr = BigQueryDropMaterializedViewStatement( dialect, MaterializedView(dialect, "mv") ) assert expr.to_sql()[0] == "DROP MATERIALIZED VIEW `mv`" diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py index 8e3f067..e4eca5a 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py @@ -155,6 +155,7 @@ View, ) from rhosocial.activerecord.backend.expression.predicates import ( + LikeOperator, ComparisonPredicate, ILikePredicate, LikePredicate, @@ -227,7 +228,7 @@ XMLTableExpression, ) from rhosocial.activerecord.backend.impl.bigquery.expression import ( - BigQueryAlterMaterializedViewSetOptionsExpression, + BigQueryAlterMaterializedViewSetOptionsStatement, ) from rhosocial.activerecord_bigquery_test.feature.backend.expression_roundtrip_harness import ( collect_expression_classes, @@ -645,7 +646,7 @@ def register_specials(): # are core's own, copied from the core dummy2 matrix so the two agree. register_special_constructor( "predicates.LikePredicate", - lambda d: LikePredicate(d, "LIKE", Column(d, "a"), Literal(d, "%x%")), + lambda d: LikePredicate(d, LikeOperator.LIKE, Column(d, "a"), Literal(d, "%x%")), ) register_special_constructor( "predicates.ILikePredicate", @@ -870,8 +871,8 @@ def path_pattern(d): # OPTIONS must be a non-empty mapping, which the introspective guess # supplies as ``{}``. register_special_constructor( - "materialized_view.BigQueryAlterMaterializedViewSetOptionsExpression", - lambda d: BigQueryAlterMaterializedViewSetOptionsExpression( + "materialized_view.BigQueryAlterMaterializedViewSetOptionsStatement", + lambda d: BigQueryAlterMaterializedViewSetOptionsStatement( d, MaterializedView(d, "mv"), {"enable_refresh": True} ), ) From e37fa4bd1869eec8c0a06b1229d2ada9e2a2742c Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 21 Oct 2026 02:50:12 +0800 Subject: [PATCH 16/35] test(bigquery): move live-connection checks out of expression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The org rules (expression-dialect §12) say it in three sentences: expression modules are independent of I/O, so their tests start no connection, create no database and touch no file; every case carries its own dialect instance and shares no mutable state; and therefore tests run in parallel. The first of those was already true here, and worth recording rather than assuming: the three pure-render locations -- feature/backend/expression, feature/backend/dialect and the round-trip matrix -- contain no client construction, no execute() and no scenario fixture. This backend's literal spellings were never server-verified (no BigQuery instance is reachable), so wave ② wrote the CREATE TABLE / INSERT / readback cases as *rendering* assertions from the start; there is nothing to migrate out. The probe names that look like a connection -- api_endpoint, _ensure_client -- are in backend/, the emulator scenario loader, and the emulator row-count fallback tests, which is where they belong. The second half of §12 is configuration, and it was missing. Parallel is now the default rather than something each run remembers to ask for (§12.1). The worker count is read by pytest-xdist before any conftest loads, so a conftest cannot inject -n; the default is a repository-local plugin, tests/plugins/expression_xdist.py, registered from pyproject.toml and therefore active for every run here. It is narrow on purpose: it enables nothing when pytest-xdist is absent, it acts only when every target is a pure-render one (a whole-repository run, a mixed run, a backend-directory run and the emulator scenario loader keep the serial default), it never overrides an explicit -n, it never overrides an explicit --dist, and it refuses --dist=loadscope and --dist=loadfile outright because those bind whole modules together and hide the case-to-case dependencies parallelism exists to expose. -n without --dist gets --dist=loadgroup, the one scheduling the rules allow. The covered locations are three, not one, because this repository's pure-render tests are spread by necessity: dialect/ holds the same kind of test as expression/, and test_expression_roundtrip_all.py walks core's package *and* this backend's, so it cannot live inside either subject directory. feature/backend/expression/conftest.py records the three properties, says where the parallel default is wired and why it cannot be wired here, and lists the three covered locations so the next person to add a case knows which ones it has to keep. No case carries xdist_group: there is no I/O in the area to serialise around, and the mark is reserved for a case that genuinely cannot share a worker, with the reason beside it. Evidence (central venv, PYTHONPATH=src;tests): - feature/backend/dialect (single file, and the whole directory): exit 0, and the run reports "bringing up nodes" -- the plugin's own default firing, with no -n on the command line. - feature/backend/expression: exit 0, same default. - expression + protocol together (a mixed run): exit 0, no worker pool, which is the scope rule keeping every other directory serial. - --dist=loadscope against dialect/: refused by the plugin with the §12.1 reason, exit 1. - ruff on tests/plugins and the expression area: clean. Not run here: the round-trip matrix, which is red before this commit for a reason that belongs to the next one -- it still imports core's retired *Value classes. It is the matrix commit's subject. --- pyproject.toml | 2 +- tests/plugins/expression_xdist.py | 154 ++++++++++++++++++ .../feature/backend/expression/conftest.py | 35 ++++ 3 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 tests/plugins/expression_xdist.py create mode 100644 tests/rhosocial/activerecord_bigquery_test/feature/backend/expression/conftest.py diff --git a/pyproject.toml b/pyproject.toml index 73b7065..79c98e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,7 +75,7 @@ namespaces = true [tool.pytest.ini_options] minversion = "7.0" -addopts = "-ra -q" +addopts = "-ra -q -p plugins.expression_xdist" asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" testpaths = ["tests"] diff --git a/tests/plugins/expression_xdist.py b/tests/plugins/expression_xdist.py new file mode 100644 index 0000000..cd67dcb --- /dev/null +++ b/tests/plugins/expression_xdist.py @@ -0,0 +1,154 @@ +# tests/plugins/expression_xdist.py +"""Default the expression test areas to parallel when one of them is the target. + +The org rules (expression-dialect §12.1) make pytest-xdist parallel the default +configuration of the expression test area. 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 named target is a pure-render test location. This + repository keeps them in two places rather than one: ``expression/`` and + ``dialect/``, plus the top-level round-trip matrix, because the matrix spans + both packages and cannot live inside either. A whole-repository run, a mixed + run, or a run of any other directory keeps the serial default; the backend + modules that start a client, and the emulator scenario loader, are not dragged + into a worker pool they did not ask for. +* **Choice respected**: an explicit ``-n`` / ``--numprocesses`` is never + overridden. An explicit ``--dist`` is never overridden either, except that + ``loadscope`` and ``loadfile`` are refused outright (§12.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 areas this plugin defaults to parallel, relative to the repository root +#: (where ``pyproject.toml`` -- and therefore this plugin's registration -- +#: lives). Both hold construction -> render tests only: no case starts a client, +#: creates a dataset or reads a file, which is what makes the default sound. +_BACKEND_TEST_ROOT = os.path.join( + "tests", "rhosocial", "activerecord_bigquery_test", "feature", "backend" +) +_EXPRESSION_AREAS = ( + os.path.join(_BACKEND_TEST_ROOT, "expression"), + os.path.join(_BACKEND_TEST_ROOT, "dialect"), +) + +#: Single files that are pure render too, and live above the two directories +#: because they span both packages. The round-trip matrix walks core's package +#: and this backend's, so no one subject directory could hold it. +_EXPRESSION_FILES = (os.path.join(_BACKEND_TEST_ROOT, "test_expression_roundtrip_all.py"),) + +#: 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 _repository_root() -> Path: + """This repository's root, two levels above ``tests/plugins``.""" + return Path(__file__).resolve().parent.parent.parent + + +def _pure_render_locations() -> tuple[Path, ...]: + """The absolute directories and files the parallel default covers.""" + root = _repository_root() + return tuple(root / area for area in _EXPRESSION_AREAS + _EXPRESSION_FILES) + + +def _is_pure_render_target(arg: str) -> bool: + """Whether *arg* names exactly one of the pure-render locations or is inside one. + + Inside means below one of the two directories. A file argument may also name + any .py test inside those directories, which is how a single case file is run. + """ + path = Path(os.path.abspath(arg)) + for location in _pure_render_locations(): + if path == location or location in path.parents: + return True + return False + + +def pytest_load_initial_conftests(args, early_config, parser): + """Append the parallel default when every target is a pure-render one.""" + if not _xdist_available(): + print( + "[expression_xdist] pytest-xdist is not installed; the " + "expression areas run serially (install it to get the " + "parallel default the test rules ask for)" + ) + return + + targets = [] + skip_next = False + for arg in args: + if skip_next: + skip_next = False + continue + if arg in _VALUE_TAKING: + skip_next = True + continue + if arg.startswith("--") and "=" in arg: + continue + if _looks_like_path(arg): + targets.append(arg) + + if not targets or not all(_is_pure_render_target(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 areas (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"]) + + +def _xdist_available() -> bool: + """The availability condition: the plugin enables nothing without xdist, + and installing it is nobody's business but the environment's.""" + try: + import xdist # noqa: F401 + except ImportError: + return False + return True diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/expression/conftest.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/expression/conftest.py new file mode 100644 index 0000000..cac0c36 --- /dev/null +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/expression/conftest.py @@ -0,0 +1,35 @@ +# tests/rhosocial/activerecord_bigquery_test/feature/backend/expression/conftest.py +"""The expression test area: construction -> render, no I/O, always parallel. + +This directory holds this backend's expression-level tests. The org rules +(expression-dialect §12) give the area three properties, and the tests here are +written to all three: + +* every case carries its own dialect instance and shares no mutable state; +* nothing opens a connection, creates a dataset, or touches the filesystem, so + the whole area runs without the emulator; +* the cases are therefore independent, and parallelism is the default + scheduling, not an option. + +The parallel default itself cannot live in a conftest: pytest-xdist reads its +worker count before any conftest loads, so a conftest cannot inject ``-n``. The +default is wired one level out, in the repository-local plugin +``tests/plugins/expression_xdist.py`` (registered from ``pyproject.toml``), +which appends ``-n auto --dist=loadgroup`` when the invocation names only +pure-render targets and the caller has not chosen a worker count itself. The +plugin is the availability check too: with pytest-xdist absent it enables +nothing and says so. + +This backend's pure-render tests are not all in this one directory. ``dialect/`` +holds the same kind of test (formatting, literal spellings, kind identity), and +``../test_expression_roundtrip_all.py`` walks both core's expression package and +this backend's, so it lives above the subject tree. The plugin covers all three +and nothing else -- the backend modules that start a client, and the emulator +scenario loader, stay serial. + +Serial cases: a test that genuinely cannot share a worker marks ``xdist_group`` +explicitly and states the reason beside the mark. None does today -- no case +here has I/O to serialize around. ``--dist=loadscope`` and ``--dist=loadfile`` +are refused by the plugin: they bind whole modules together and would hide +exactly the case-to-case dependencies parallelism exists to expose. +""" From 1493c7abc78622929e22aef13f222bcee1c51234 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 21 Oct 2026 03:06:54 +0800 Subject: [PATCH 17/35] test(bigquery): reconcile the roundtrip matrix with the core's current tree Reconciled against core 4774c502 (f2bdf386 before it). The matrix was red at HEAD for four independent reasons, all of them core-side moves this backend had not followed, plus one that only surfaced once those were cleared. The value family is one node now. Core replaced TypedValueMixin and the twelve *Value classes with one Literal whose kind is a LiteralKind member, so the module imported seven names that no longer exist. The matrix registers one representative Literal instead: the single node carries every kind, and the kinds an int cannot state are the literal-spelling file's subject, not this file's. The unmodelled-formatter row for the retired TypedValue base goes with them -- the dispatch no longer names an empty 'format_' for anything. ddl/test_type_domain_negative.py imported DdlLiteral, which the same core move retired: a DDL position now says inline=True on the Literal itself, and the CHECK condition is written that way. ArrayType's element_type is keyword-only now, so that construction names it. The five ALTER TABLE action classes lost their bare aliases (AddColumn -> AddColumnAction & co), and the harness's AlterTableStatement constructor and graph helpers still named the retired spellings -- EdgeTable in particular, which core renamed EdgeTableDefinition, so that helper is gone rather than renamed (a helper matching nothing is worse than no helper). The matrix's constructors follow the same renames. Then the classes whose introspective guess the core's constructors began refusing: - ComparisonPredicate normalises its operator (core 9659c483), and the guess hands the slot a string that is not one; registered. - TableCheckConstraint and ColumnGeneratedConstraint are DDL positions that refuse a bind parameter, so the guess's bind-bearing comparison cannot render them -- two columns compose a predicate with nothing that binds. The generated constraint also needs STORED, because the node defaults to VIRTUAL and this dialect's probe refuses VIRTUAL. - The ten declared-result annotations read the alias of their source, and a Literal is deliberately not aliasable; a Column is what the node is for. - DataType's pin followed the dispatch's new generic spelling ("does not support the generic type None") rather than the private one it used before. Finally, four claims the matrix could not express before. A class can render and still lose one encoding, which "does not render" would misreport and a failure would blame on BigQuery. LEGITIMATE_NON_ROUNDTRIPS records each such class per channel, with the type and message fragment, and the new test_pinned_non_roundtrip_refuses_as_pinned asserts it in both directions -- a pinned channel that starts working fails the run, so a core fix cannot leave a stale entry. The twelve entries are core's to fix: DateTimeField/IntervalUnit inherit (str, Enum) without str.__str__, so the XML channel writes "DateTimeField.YEAR"; CreateTableCloneMode, ViewOptions, ReferentialAction and GeneratedColumnType are host objects the plain-JSON artifact cannot name. Bind parameters are compared as sequences rather than as the same container: the artifact carries a tuple as an array, and Subquery's formatter passes its query_params through as whatever it received, so a restored render hands back a list where the original handed a tuple. The values are the contract; the container is not. Coverage at 4774c502: 376 classes collected (core + this backend), 253 rendered, 53 unsupported, 52 pinned non-render, 15 not-implemented, 3 pinned unconstructible, 12 pinned non-roundtrip per channel. Evidence (central venv, PYTHONPATH=src;tests): - test_expression_roundtrip_all.py, full matrix under the parallel default: 373 passed / 3 skipped, exit 0 (the three skips are the UUID nodes core refuses at construction, pinned in UNCONSTRUCTIBLE). - feature/backend, the whole directory: 1333 passed / 3 skipped, exit 0. - ruff on the three changed test files: clean. --- .../backend/ddl/test_type_domain_negative.py | 6 +- .../backend/expression_roundtrip_harness.py | 21 +- .../backend/test_expression_roundtrip_all.py | 322 ++++++++++++++---- 3 files changed, 261 insertions(+), 88 deletions(-) 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 index 88831f4..4e6ec2a 100644 --- 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 @@ -20,7 +20,7 @@ DropTypeSupport, ) from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError -from rhosocial.activerecord.backend.expression import BaseExpression, DdlLiteral +from rhosocial.activerecord.backend.expression import BaseExpression, Literal from rhosocial.activerecord.backend.expression.objects import Domain, Schema, Type from rhosocial.activerecord.backend.expression.statements import ( AlterDomainStatement, @@ -147,7 +147,7 @@ def _type_nodes(dialect: BigQueryDialect) -> Tuple[BaseExpression, ...]: def _domain_nodes(dialect: BigQueryDialect) -> Tuple[BaseExpression, ...]: - condition = DomainValueExpression(dialect) > DdlLiteral(dialect, 0) + condition = DomainValueExpression(dialect) > Literal(dialect, 0, inline=True) check = DomainCheckConstraint(dialect, condition, name="nonnegative") action = DropDomainDefaultAction(dialect) return ( @@ -264,7 +264,7 @@ def test_column_types_and_dataset_schema_remain_separate( ) -> None: data_types = ( JsonType(dialect), - ArrayType(dialect, IntegerType(dialect)), + ArrayType(dialect, element_type=IntegerType(dialect)), ) for data_type in data_types: assert isinstance(data_type, DataType) diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/expression_roundtrip_harness.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/expression_roundtrip_harness.py index 1befcc5..d94ec20 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/expression_roundtrip_harness.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/expression_roundtrip_harness.py @@ -42,7 +42,7 @@ # annotation naming it wants an object too. _MENTIONS_RELATION = re.compile( r"\b(Table|View|MaterializedView|Index|Sequence|Type|Domain|Function|Trigger" - r"|Schema|Database|RelationObject|SchemaObject|NodeTable|EdgeTable)\b" + r"|Schema|Database|RelationObject|SchemaObject|NodeTable)\b" ) # An INSERT's data source. It is an expression now, so it needs a real one -- @@ -332,7 +332,6 @@ class that was split needs one entry per resulting class. "graph.GraphVertex": _graph_vertex, "graph.GraphEdge": _graph_edge, "graph.VertexTable": _node_table, - "graph.EdgeTable": _edge_table, "graph.GraphTableExpression": _graph_table_expr, "graph.CreatePropertyGraphStatement": _create_property_graph_expr, "graph.DropPropertyGraphStatement": _drop_property_graph_expr, @@ -669,16 +668,6 @@ def _node_table(dialect): return VertexTable(dialect, NodeTable(dialect, "n")) -def _edge_table(dialect): - """A graph edge table declaration.""" - from rhosocial.activerecord.backend.expression.graph import EdgeTable - from rhosocial.activerecord.backend.expression.objects import ( - EdgeTable as EdgeTableObject, - ) - - return EdgeTable(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 @@ -773,7 +762,7 @@ def _relation(dialect, annotation): """ import rhosocial.activerecord.backend.expression.objects as objects - for kind in ("MaterializedView", "ForeignTable", "NodeTable", "EdgeTable", + for kind in ("MaterializedView", "ForeignTable", "NodeTable", "Sequence", "Index", "View", "Table", "Type", "Domain", "Function", "Trigger", "Schema", "Database"): if re.search(rf"\b{kind}\b", annotation): @@ -849,16 +838,16 @@ def _update_expr(dialect): def _alter_table_expr(dialect): from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( - AddColumn, + AddColumnAction, AlterTableStatement, - DropColumn, + DropColumnAction, ) column = _column_definition(dialect) return AlterTableStatement( dialect, table=_table(dialect, "t"), - actions=[DropColumn(dialect, "a"), AddColumn(dialect, column=column)], + actions=[DropColumnAction(dialect, "a"), AddColumnAction(dialect, column=column)], ) diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py index e4eca5a..d5e47d5 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py @@ -99,13 +99,12 @@ rescue, and it is pinned in both directions so neither list grows silently. """ -import datetime import inspect -import uuid import pytest from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression import declared from rhosocial.activerecord.backend.expression import graph as graph_mod from rhosocial.activerecord.backend.expression.advanced_functions import ( CaseExpression, @@ -130,15 +129,6 @@ SpaceFunction, SubstringExpression, ) -from rhosocial.activerecord.backend.expression.values import ( - BinaryValue, - BooleanValue, - DateValue, - StringValue, - TimeValue, - TimestampValue, - UUIDValue, -) from rhosocial.activerecord.backend.expression.objects import ( Database, Domain, @@ -442,19 +432,20 @@ def register_specials(): ), ) 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")), ) 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.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.AddTableConstraint", - lambda d: ddl_alter.AddTableConstraint( + "statements.ddl_alter.AddTableConstraintAction", + lambda d: ddl_alter.AddTableConstraintAction( d, TableConstraint( d, branch=TableUniqueConstraint(d, ["a"]), name="c" @@ -464,20 +455,20 @@ def register_specials(): # Both constraint modifiers hide their required arguments behind defaulted # positionals, so the introspective constructor skips them and the action # arrives incomplete. The enforcement keyword is mandatory in - # AlterConstraint's grammar (exactly one of enforced / not_enforced), so - # the constructor names one of them. + # AlterConstraintAction's grammar (exactly one of enforced / not_enforced), + # so the constructor names one of them. register_special_constructor( - "statements.ddl_alter.AlterConstraint", - lambda d: ddl_alter.AlterConstraint( + "statements.ddl_alter.AlterConstraintAction", + lambda d: ddl_alter.AlterConstraintAction( d, "c", enforced=True, constraint_kind=TableCheckConstraint ), ) register_special_constructor( - "statements.ddl_alter.ValidateConstraint", + "statements.ddl_alter.ValidateConstraintAction", # The action names its constraint positionally now; the keyword-only # ``name`` must repeat it exactly, so naming it twice says less than # naming it once. - lambda d: ddl_alter.ValidateConstraint(d, "c"), + lambda d: ddl_alter.ValidateConstraintAction(d, "c"), ) # -- schemas, sequences, databases, domains, types --------------------- @@ -644,6 +635,13 @@ def register_specials(): # ``column`` and ``pattern`` are annotated loosely, so the generic guess # leaves a bare string where the formatter calls ``.to_sql()``. Both shapes # are core's own, copied from the core dummy2 matrix so the two agree. + # ``op`` is normalised at construction now (core 9659c483), and the guess + # hands the slot a bare string that is neither a member nor a spelling it + # accepts -- so the class is constructible but the guess cannot build it. + register_special_constructor( + "predicates.ComparisonPredicate", + lambda d: ComparisonPredicate(d, "=", Column(d, "a"), Column(d, "b")), + ) register_special_constructor( "predicates.LikePredicate", lambda d: LikePredicate(d, LikeOperator.LIKE, Column(d, "a"), Literal(d, "%x%")), @@ -653,6 +651,47 @@ def register_specials(): lambda d: ILikePredicate(d, Column(d, "a"), "%x%"), ) + # -- DDL positions: a CHECK condition takes no bind parameters ---------- + # The constructor declares the position inline-only and refuses a residual + # bind value, so the guess's Literal-bearing comparison cannot render. Two + # columns compose the same predicate with nothing that binds in it. + register_special_constructor( + "statements.ddl_table.TableCheckConstraint", + lambda d: TableCheckConstraint(d, _column_predicate(d)), + ) + register_special_constructor( + "statements.ddl_table.ColumnGeneratedConstraint", + # The node defaults to VIRTUAL, which BigQuery's probe refuses; STORED + # is the spelling this dialect supports, so the constructor names it. + lambda d: ddl_table.ColumnGeneratedConstraint( + d, _column_predicate(d), storage_type=ddl_table.GeneratedColumnType.STORED + ), + ) + + # -- declared-result annotations: the source must be aliasable ----------- + # ``format_declared_result`` reads the alias of both the annotation and the + # node it annotates, and the rewritten Literal is deliberately not + # aliasable -- the guess's Literal source reaches the formatter and dies on + # ``source.alias``. A Column source is what the node is for: a statement + # whose result type the caller declares, rendered unchanged with the + # knowledge attached. + for _annotation in ( + "TextAnnotation", + "NumberAnnotation", + "IntegerAnnotation", + "BooleanAnnotation", + "TemporalAnnotation", + "BinaryAnnotation", + "UUIDAnnotation", + "JSONAnnotation", + "XMLAnnotation", + "ArrayAnnotation", + ): + register_special_constructor( + f"declared.{_annotation}", + lambda d, _n=_annotation: getattr(declared, _n)(d, Column(d, "c")), + ) + # -- expressions that must not be empty --------------------------------- register_special_constructor( "advanced_functions.CaseExpression", @@ -699,37 +738,13 @@ def register_specials(): "types.enum_.EnumType", lambda d: EnumType(d, values=["a", "b"]) ) - # -- the typed value nodes ---------------------------------------------- - # The base's ``value`` parameter is annotated ``Any``, so the filler's - # name-based guess hands every value class an int. The classes whose host - # type an int states exactly -- the numbers and the interval, through the - # coercions the nodes declare -- accept it; the seven below coerce - # nothing, because an int states no honest string, date, time, bytes or - # UUID, so each gets the real host value it exists to carry. + # -- the value node ------------------------------------------------------ + # One node carries every value kind, so the introspective filler's ``Any`` + # guess (an int) constructs it and the kind follows the host value. The + # kinds an int cannot state are exercised by the literal spelling tests; + # here the matrix needs one representative per shape, not twelve. register_special_constructor( - "values.BinaryValue", lambda d: BinaryValue(d, b"\x01\x02") - ) - register_special_constructor( - "values.BooleanValue", lambda d: BooleanValue(d, True) - ) - register_special_constructor( - "values.DateValue", lambda d: DateValue(d, datetime.date(2026, 10, 18)) - ) - register_special_constructor( - "values.StringValue", lambda d: StringValue(d, "x") - ) - register_special_constructor( - "values.TimeValue", lambda d: TimeValue(d, datetime.time(12, 30)) - ) - register_special_constructor( - "values.TimestampValue", - lambda d: TimestampValue(d, datetime.datetime(2026, 10, 18, 12, 30)), - ) - register_special_constructor( - "values.UUIDValue", - lambda d: UUIDValue( - d, uuid.UUID("123e4567-e89b-12d3-a456-426614174000") - ), + "core.Literal", lambda d: Literal(d, 1) ) # -- scalar string nodes whose operands are forward-ref strings --------- @@ -1109,7 +1124,7 @@ def path_pattern(d): # 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", + TypeError, "does not support the generic type None", ), # ---- generic types this dialect has no formatter for ------------------- # Each is a type name the dialect declines. BigQuery spells VARBINARY @@ -1210,6 +1225,143 @@ def path_pattern(d): } +# --------------------------------------------------------------------------- +# Channels that cannot round-trip: pinned per class and per channel +# --------------------------------------------------------------------------- +# +# Everything above classifies a class as a whole: it renders or it does not. A +# third outcome exists and it is neither -- the class renders, and one of the +# three encodings cannot bring it back. Pinning those as "non-renders" would be +# a lie, and letting them fail would make a core-side omission look like a +# BigQuery defect. So each entry names the channels it covers and what happens +# on them, and :func:`_assert_pinned_channel` asserts it in both directions: a +# pinned channel that starts working fails the run, because the reason it was +# pinned is then gone. +# +# Every entry here is core's to fix, not this backend's. None of the classes is +# BigQuery-specific; the artefact contract is the same one every backend reads. +LEGITIMATE_NON_ROUNDTRIPS = { + # -- the datetime family: DateTimeField / IntervalUnit are (str, Enum) + # without ``__str__ = str.__str__``, so the XML channel writes + # ``"DateTimeField.YEAR"``, which the constructor's own normaliser + # refuses. (JSON survives -- it writes the member's underlying string; + # dict survives -- it passes the member through untouched.) + "rhosocial.activerecord.backend.expression.datetime._ExtractBase": { + "channels": {"xml": ("raises", ValueError, "unsupported datetime field")}, + }, + "rhosocial.activerecord.backend.expression.datetime.DatePartExpression": { + "channels": {"xml": ("raises", ValueError, "unsupported datetime field")}, + }, + "rhosocial.activerecord.backend.expression.datetime.ExtractExpression": { + "channels": {"xml": ("raises", ValueError, "unsupported datetime field")}, + }, + "rhosocial.activerecord.backend.expression.datetime.ExtractFieldExpression": { + "channels": {"xml": ("raises", ValueError, "unsupported datetime field")}, + }, + "rhosocial.activerecord.backend.expression.datetime.DateTruncExpression": { + "channels": {"xml": ("raises", ValueError, "unsupported datetime field")}, + }, + "rhosocial.activerecord.backend.expression.datetime.IntervalExpression": { + "channels": {"xml": ("raises", ValueError, "unsupported interval unit")}, + }, + "rhosocial.activerecord.backend.expression.datetime.DateTimeAddExpression": { + "channels": {"xml": ("raises", ValueError, "unsupported interval unit")}, + }, + "rhosocial.activerecord.backend.expression.datetime.DateTimeDiffExpression": { + "channels": {"xml": ("raises", ValueError, "unsupported interval unit")}, + }, + "rhosocial.activerecord.backend.expression.datetime.DateTimeSubtractExpression": { + "channels": {"xml": ("raises", ValueError, "unsupported interval unit")}, + }, + # -- ``mode`` is declared ``CreateTableCloneMode`` with a member default; + # the JSON and XML channels write the word (``"CLONE"``), and the + # constructor demands the member -- the isinstance half of the contract + # without the normalise half. + "rhosocial.activerecord.backend.expression.statements.ddl_table.CreateTableCloneStatement": { + "channels": { + "json": ("raises", TypeError, "mode must be a CreateTableCloneMode"), + "xml": ("raises", TypeError, "mode must be a CreateTableCloneMode"), + }, + }, + # -- ``options`` is a ``ViewOptions`` *instance*, a plain class in a + # parameter slot: the codec writes nodes, scalars and containers of + # those two, and a bare ``options or ViewOptions()`` puts one in every + # CREATE VIEW. Serialization refuses before any round-trip can start. + "rhosocial.activerecord.backend.expression.statements.ddl_view.CreateViewStatement": { + "channels": { + "dict": ("raises", TypeError, "ViewOptions is not one of the value kinds"), + "json": ("raises", TypeError, "ViewOptions is not one of the value kinds"), + "xml": ("raises", TypeError, "ViewOptions is not one of the value kinds"), + }, + }, + # -- ``storage_type`` defaults to a ``GeneratedColumnType`` member, a plain + # (non-str) enum the artifact cannot name; the constructor above names + # STORED for BigQuery's sake, and that member is the one that travels. + "rhosocial.activerecord.backend.expression.statements.ddl_table.ColumnGeneratedConstraint": { + "channels": { + "dict": ("raises", TypeError, "GeneratedColumnType is not one of the value kinds"), + "json": ("raises", TypeError, "GeneratedColumnType is not one of the value kinds"), + "xml": ("raises", TypeError, "GeneratedColumnType is not one of the value kinds"), + }, + }, + # -- ``on_delete`` / ``on_update`` default to ``ReferentialAction`` members, + # a plain (non-str) enum the artifact cannot name. The instances built + # here leave both at their defaults, so every channel meets it. + "rhosocial.activerecord.backend.expression.statements.ddl_table.ForeignKeyConstraint": { + "channels": { + "dict": ("raises", TypeError, "ReferentialAction is not one of the value kinds"), + "json": ("raises", TypeError, "ReferentialAction is not one of the value kinds"), + "xml": ("raises", TypeError, "ReferentialAction is not one of the value kinds"), + }, + }, +} + + +def _roundtrip_pin(fqn, channel): + """The pin for one class on one channel, or ``None`` when it travels free.""" + entry = LEGITIMATE_NON_ROUNDTRIPS.get(fqn) + if entry is None: + return None + return entry["channels"].get(channel) + + +def _replay_channel(channel, instance, dialect): + """Encode *instance*, decode it back, and hand the result over.""" + 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 _assert_pinned_channel(fqn, channel, instance, dialect, pin): + """Assert one pinned channel, in the shape its entry names. + + ``raises`` -- the channel refuses outright, with the pinned type and the + pinned message fragment. A channel that stops refusing fails the assertion, + so a core fix cannot leave a stale entry behind. + """ + _, expected_type, fragment = pin + try: + _replay_channel(channel, instance, dialect) + except Exception as exc: + assert isinstance(exc, expected_type), ( + f"{fqn}: the {channel} channel was pinned as refusing with " + f"{expected_type.__name__}, but it raised {type(exc).__name__}: {exc}" + ) + assert fragment in str(exc), ( + f"{fqn}: expected the {channel} refusal to mention {fragment!r}, " + f"but it said: {exc}" + ) + else: + raise AssertionError( + f"{fqn}: the {channel} channel is pinned in " + f"LEGITIMATE_NON_ROUNDTRIPS but the round-trip now succeeds. The " + f"reason it was pinned is gone -- remove the entry rather than " + f"leave a lie in the table." + ) + + # --------------------------------------------------------------------------- # The dispatch gap: a missing formatter is a decision, not an omission # --------------------------------------------------------------------------- @@ -1333,15 +1485,6 @@ def _dispatched_formatter(exc): "renders elsewhere)": ("format_temporal_options",), "the TableSource root (every concrete row source overrides its formatter; " "the root carries nothing but an alias and is never rendered)": ("format_table_source",), - # Not a feature BigQuery lacks: the typed-value category base declares an - # empty kind_name, so the dispatch names the empty 'format_' for the bare - # base. Every concrete value class declares its own kind and renders; the - # base constructs (the filler hands it an int) and has nothing to dispatch - # to. The row exists because the unmodelled list is exact in both - # directions: the observed refusal is real, and its name is what separates - # it from any future gap a class might report. - "the typed-value category base (TypedValue declares no kind of its own; " - "each concrete value class overrides it)": ("format_",), } @@ -1354,6 +1497,24 @@ def _unmodelled_methods(): # The local assertion: classify the outcome instead of swallowing it # --------------------------------------------------------------------------- +def _bind_params_equal(a, b) -> bool: + """Bind-parameter sequences compared as sequences. + + The artifact carries a tuple as an array (§4.8: JSON draws no distinction, + the kind does, and a constructor normalises on the way back), so a + round-tripped render may hand back a list where the original handed a tuple. + ``Subquery`` is the case in the tree: its ``query_params`` arrive as a list + on restore and its formatter passes them through. The values are the + contract; the container is not. Order still matters -- this is a sequence + comparison, not a set comparison. + """ + if isinstance(a, (list, tuple)) and isinstance(b, (list, tuple)): + return len(a) == len(b) and all( + _bind_params_equal(x, y) for x, y in zip(a, b, strict=True) + ) + return a == b + + def classify_sql_roundtrip(fqn, instance, dialect): """Assert an expression's SQL survives the round-trip, or say precisely why not. @@ -1426,18 +1587,23 @@ def classify_sql_roundtrip(fqn, instance, dialect): f" Exception: {exc}" ) from exc - for channel, decoded in ( - ("dict", deserialize(serialize(instance), dialect)), - ("json", deserialize_json(serialize_json(instance), dialect)), - ("xml", deserialize_xml(serialize_xml(instance), dialect)), + for channel, build in ( + ("dict", lambda: deserialize(serialize(instance), dialect)), + ("json", lambda: deserialize_json(serialize_json(instance), dialect)), + ("xml", lambda: deserialize_xml(serialize_xml(instance), dialect)), ): + pin = _roundtrip_pin(fqn, channel) + if pin is not None: + _assert_pinned_channel(fqn, channel, instance, dialect, pin) + continue + decoded = build() decoded_sql, decoded_params = decoded.to_sql() assert decoded_sql == expected_sql, ( f"{fqn}: {channel} round-trip changed the SQL.\n" f" original: {expected_sql!r}\n" f" {channel}: {decoded_sql!r}" ) - assert decoded_params == expected_params, ( + assert _bind_params_equal(decoded_params, expected_params), ( f"{fqn}: {channel} round-trip changed the bind parameters.\n" f" original: {expected_params!r}\n" f" {channel}: {decoded_params!r}" @@ -1521,6 +1687,7 @@ def test_pinned_entries_are_real_collected_classes(self): for table, entries in ( ("LEGITIMATE_NON_RENDERS", LEGITIMATE_NON_RENDERS), ("LEGITIMATE_NOT_IMPLEMENTED", LEGITIMATE_NOT_IMPLEMENTED), + ("LEGITIMATE_NON_ROUNDTRIPS", LEGITIMATE_NON_ROUNDTRIPS), ): unknown = set(entries) - set(REGISTERED) assert not unknown, ( @@ -1546,6 +1713,23 @@ def test_pinned_non_renders_still_raise_what_they_claim(self, dialect): f"{exc_info.value}" ) + def test_pinned_non_roundtrip_refuses_as_pinned(self, dialect): + """Each pinned channel still fails the way the table says, for that reason. + + The mirror of the non-render pin, one level down: a class may render and + still lose one encoding, and this is where that is stated rather than + hidden among the classes that do not render at all. + """ + for fqn, entry in LEGITIMATE_NON_ROUNDTRIPS.items(): + instance, source = make_instance(REGISTERED[fqn], dialect) + assert instance is not None, ( + f"{fqn} is pinned as a non-roundtrip but could not be " + f"constructed ({source})" + ) + register_all({fqn: REGISTERED[fqn]}) + for channel, pin in entry["channels"].items(): + _assert_pinned_channel(fqn, channel, instance, dialect, pin) + def test_pinned_not_implementeds_still_raise_what_they_claim(self, dialect): """Each pinned entry still raises, and says the reason the table names.""" for fqn, fragment in LEGITIMATE_NOT_IMPLEMENTED.items(): From 231ab2c3b9a138d20493599c302ffaeb909e8b3c Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 21 Oct 2026 03:11:55 +0800 Subject: [PATCH 18/35] refactor(bigquery): build the backend's own probes from expression nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The org rules (expression-dialect §16.2) make SQL text the formatter's business: a backend may collect parameters into a node and execute what that node renders, but assembling the statement itself is a call site doing a formatter's job. This backend had four such sites, two in each of the sync and async backends -- the PK probe and the emulator's row-count probe -- each an f-string that pasted a rendered relation into a hand-written SELECT. The consequence was not stylistic. The relation's quoting was restated by hand inside those statements, in code that never saw the dialect's escaping, so a dataset or table name containing a backtick produced SQL the server refuses. The same two statements also disagreed with themselves about their own projection: `AS next_id` was unquoted while the relation beside it was quoted. backend/qualified_names.py already owned naming a relation; it owns the two statements now, as nodes: - _next_pk_expression builds SELECT COALESCE(MAX(pk), 0) + 1 AS next_id over the relation, from MaxFunction, CoalesceFunction, ArithmeticOperation and QueryStatement. The zero and the one are Literal values marked inline=True: they are constants of the statement, not caller data, and inlining keeps the text identical to what the f-string produced. Nothing binds. - _count_expression builds SELECT COUNT(*) AS n over the relation with the caller's WHERE. The old path rendered the clause and then cut "WHERE " off the front of the result -- a formatter's output being edited by string surgery, in a place that could not know whether the prefix was there. The clause renders it, and the condition arrives either as the WhereClause the options carry or as a bare predicate the QueryStatement wraps. Both backend classes call the builders and execute (sql, params); the async twin gets the same two calls, so the two paths have one answer between them. What changes in the rendered text, stated rather than glossed: the projection alias is dialect-quoted now (`AS `next_id``), and the COUNT alias likewise. The PK probe's parameters stay empty because the constants inline; the COUNT probe's parameters are the caller's, exactly as before. New: backend/test_internal_statements.py pins both statements -- shape, alias, that the PK probe binds nothing, that a caller's string is a parameter rather than text, and that WHERE appears exactly once. It never builds a client, so it belongs to the offline leg. The two remaining f-strings under the §16.2 grep are the CREATE / DROP SCHEMA bodies in mixins/schema.py, which are formatters -- exactly where the rule puts SQL text -- and the docstring in qualified_names.py that quotes the old spelling to explain it. Verified: feature/backend 1341 passed / 3 skipped, exit 0; the new file 8 passed, exit 0; ruff's finding set on backend/ is identical to HEAD (10 pre-existing findings, unchanged). --- .../impl/bigquery/backend/async_backend.py | 12 +- .../backend/impl/bigquery/backend/backend.py | 12 +- .../impl/bigquery/backend/qualified_names.py | 86 +++++++++++--- .../backend/test_internal_statements.py | 108 ++++++++++++++++++ 4 files changed, 185 insertions(+), 33 deletions(-) create mode 100644 tests/rhosocial/activerecord_bigquery_test/feature/backend/backend/test_internal_statements.py diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/backend/async_backend.py b/src/rhosocial/activerecord/backend/impl/bigquery/backend/async_backend.py index 9d51ea6..683f35c 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/backend/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/backend/async_backend.py @@ -157,9 +157,8 @@ async def _next_pk_value(self, table: str, schema_name, pk_column: str) -> int: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - qualified = self._render_relation(table, schema_name) - sql = f"SELECT COALESCE(MAX(`{pk_column}`), 0) + 1 AS next_id FROM {qualified}" - result = await self.execute(sql, options=ExecutionOptions(stmt_type=StatementType.DQL)) + sql, params = self._next_pk_expression(table, schema_name, pk_column).to_sql() + result = await self.execute(sql, params, options=ExecutionOptions(stmt_type=StatementType.DQL)) row = result.data[0] value = row.get("next_id") if isinstance(row, dict) else row[0] key = (schema_name, table, pk_column) @@ -277,12 +276,7 @@ async def _count_matching(self, table: str, schema_name, where): from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - where_sql, params = where.to_sql() - stripped = where_sql.strip() - if stripped.upper().startswith("WHERE "): - where_sql = stripped[5:].strip() - qualified = self._render_relation(table, schema_name) - sql = f"SELECT COUNT(*) AS n FROM {qualified} WHERE {where_sql}" + sql, params = self._count_expression(table, schema_name, where).to_sql() result = await self.execute(sql, params, options=ExecutionOptions(stmt_type=StatementType.DQL)) if not result.data: return None diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/backend/backend.py b/src/rhosocial/activerecord/backend/impl/bigquery/backend/backend.py index 04a55c2..0511d59 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/backend/backend.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/backend/backend.py @@ -538,9 +538,8 @@ def _next_pk_value(self, table: str, schema_name: Optional[str], pk_column: str) from rhosocial.activerecord.backend.schema import StatementType key = (schema_name, table, pk_column) - qualified = self._render_relation(table, schema_name) - sql = f"SELECT COALESCE(MAX(`{pk_column}`), 0) + 1 AS next_id FROM {qualified}" - result = self.execute(sql, options=ExecutionOptions(stmt_type=StatementType.DQL)) + sql, params = self._next_pk_expression(table, schema_name, pk_column).to_sql() + result = self.execute(sql, params, options=ExecutionOptions(stmt_type=StatementType.DQL)) row = result.data[0] value = row.get("next_id") if isinstance(row, dict) else row[0] # Stay monotonic across deletes: MAX(pk)+1 reuses the id of a just @@ -682,12 +681,7 @@ def _count_matching(self, table: str, schema_name: Optional[str], where) -> Opti from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - where_sql, params = where.to_sql() - stripped = where_sql.strip() - if stripped.upper().startswith("WHERE "): - where_sql = stripped[5:].strip() - qualified = self._render_relation(table, schema_name) - sql = f"SELECT COUNT(*) AS n FROM {qualified} WHERE {where_sql}" + sql, params = self._count_expression(table, schema_name, where).to_sql() result = self.execute(sql, params, options=ExecutionOptions(stmt_type=StatementType.DQL)) if not result.data: return None diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/backend/qualified_names.py b/src/rhosocial/activerecord/backend/impl/bigquery/backend/qualified_names.py index 77470d4..d138cb6 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/backend/qualified_names.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/backend/qualified_names.py @@ -1,26 +1,39 @@ # src/rhosocial/activerecord/backend/impl/bigquery/backend/qualified_names.py -"""Naming a relation for the backend's own SQL. +"""Naming a relation, and the two statements the backend runs, through nodes. Both the sync and the async backend run two internal queries of their own -- the ``MAX(pk) + 1`` probe and the emulator's ``COUNT(*)`` row-count probe -- and -both used to write the relation into the SQL with an f-string: - - f"`{schema_name}`.`{table}`" if schema_name else f"`{table}`" - -That is BigQuery's quoting rule restated by hand, in code that never sees the -dialect's own escaping, so a dataset or table name containing a backtick -produced SQL the server refuses. It is also a second opinion about which -identifiers need quoting, which is the kind of duplication this refactor -removes: naming the relation as an object and handing it to the dialect's own -``format_table_object`` leaves one quoting rule for the whole backend. - -It lives in a mixin rather than on one backend class because the async backend -has the same two probes and there must be only one answer. +both used to write the whole statement with an f-string: + + f"SELECT COALESCE(MAX(`{pk_column}`), 0) + 1 AS next_id FROM {qualified}" + +That is SQL text assembled at the call site, which the org rules make the +formatter's business (§16.2): the backend may name a relation, but the SELECT, +its projection, its aggregate and its alias belong to expression nodes. The +consequence was not hypothetical -- the quoting rule was restated by hand, in +code that never saw the dialect's escaping, so a dataset or table name +containing a backtick produced SQL the server refuses. + +So this module owns both statements as nodes. The projections and the relation +are built here, ``to_sql()`` renders them, and the caller executes the text and +parameters it gets back. It lives in a mixin rather than on one backend class +because the async backend runs the same two probes and there must be only one +answer. """ -from typing import Optional +from typing import Any, Optional +from rhosocial.activerecord.backend.expression.core import Column, Literal, RowCountWildcard +from rhosocial.activerecord.backend.expression.functions.aggregate import ( + CountFunction, + MaxFunction, +) +from rhosocial.activerecord.backend.expression.functions.string import CoalesceFunction from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.operators import ArithmeticOperation +from rhosocial.activerecord.backend.expression.query_parts import WhereClause +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef +from rhosocial.activerecord.backend.expression.statements.dql import QueryStatement class BigQueryQualifiedNameMixin: @@ -56,5 +69,48 @@ def _render_relation(self, table: str, schema_name: Optional[str]) -> str: qualified_sql, _params = self._relation_object(table, schema_name).to_sql() return qualified_sql + def _next_pk_expression( + self, table: str, schema_name: Optional[str], pk_column: str + ) -> QueryStatement: + """``SELECT COALESCE(MAX(pk), 0) + 1 AS next_id FROM ``. + + The expression behind :meth:`_next_pk_value`. The zero and the one are + :class:`~...expression.core.Literal` values marked ``inline=True``: they + are constants of the statement rather than caller data, and inlining + keeps the rendered text what the f-string used to produce. + """ + dialect = self.dialect + highest = MaxFunction(dialect, Column(dialect, pk_column)) + next_value = ArithmeticOperation( + dialect, + "add", + CoalesceFunction(dialect, highest, Literal(dialect, 0, inline=True)), + Literal(dialect, 1, inline=True), + ) + return QueryStatement( + dialect, + select=[next_value.as_("next_id")], + from_=NamedRelationRef(dialect, self._relation_object(table, schema_name)), + ) + + def _count_expression( + self, table: str, schema_name: Optional[str], where: Any + ) -> QueryStatement: + """``SELECT COUNT(*) AS n FROM WHERE ``. + + The expression behind :meth:`_count_matching`. ``where`` is the caller's + ``WhereClause`` as the update/delete options carry it, so its condition + is rendered by the clause rather than by a prefix strip: the previous + version read the rendered text back and cut ``"WHERE "`` off the front, + which is a formatter's job being done by string surgery. + """ + dialect = self.dialect + return QueryStatement( + dialect, + select=[CountFunction(dialect, RowCountWildcard(dialect)).as_("n")], + from_=NamedRelationRef(dialect, self._relation_object(table, schema_name)), + where=where if isinstance(where, WhereClause) else WhereClause(dialect, where), + ) + __all__ = ["BigQueryQualifiedNameMixin"] diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/backend/test_internal_statements.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/backend/test_internal_statements.py new file mode 100644 index 0000000..c84fa27 --- /dev/null +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/backend/test_internal_statements.py @@ -0,0 +1,108 @@ +# tests/rhosocial/activerecord_bigquery_test/feature/backend/backend/test_internal_statements.py +"""The two statements the backend runs itself, built as nodes rather than text. + +The PK probe and the emulator's row-count probe used to be f-strings assembled +in the backend -- SQL text at a call site, which expression-dialect §16.2 makes +the formatter's business. They are nodes now, and these cases pin what they +render: the statement's shape, the alias the caller reads, and that no caller +data is spelled into the text where the driver could bind it. + +The nodes are reached through the mixin that owns them, so nothing here needs a +client: `to_sql()` is the whole subject, which is what keeps this file in the +offline leg. +""" + +import pytest + +from rhosocial.activerecord.backend.expression.core import Column, Literal +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.query_parts import WhereClause +from rhosocial.activerecord.backend.impl.bigquery.backend.backend import BigQueryBackend +from rhosocial.activerecord.backend.impl.bigquery.dialect import BigQueryDialect + + +class _Statements: + """The mixin's statement builders, without a client behind them. + + ``_next_pk_expression`` and ``_count_expression`` read only ``self.dialect`` + and the relation-object helper, so borrowing the three methods is enough to + ask a backend's own implementation what it renders. + """ + + dialect = BigQueryDialect() + _relation_object = BigQueryBackend._relation_object + _next_pk_expression = BigQueryBackend._next_pk_expression + _count_expression = BigQueryBackend._count_expression + + +@pytest.fixture +def statements(): + return _Statements() + + +@pytest.fixture +def dialect(): + return BigQueryDialect() + + +class TestNextPkExpression: + def test_a_bare_relation_renders_unqualified(self, statements): + sql, params = statements._next_pk_expression("t", None, "id").to_sql() + assert sql == "SELECT COALESCE(MAX(`id`), 0) + 1 AS `next_id` FROM `t`" + assert params == () + + def test_the_dataset_reaches_the_relation(self, statements): + sql, _ = statements._next_pk_expression("t", "app", "id").to_sql() + assert sql == ( + "SELECT COALESCE(MAX(`id`), 0) + 1 AS `next_id` FROM `app`.`t`" + ) + + def test_the_constants_are_inline_and_nothing_binds(self, statements): + """The zero and the one belong to the statement, not to the caller.""" + _, params = statements._next_pk_expression("t", None, "id").to_sql() + assert params == () + + def test_the_primary_key_name_is_quoted_by_the_dialect(self, statements): + """A name the dialect must quote is the dialect's to quote, not ours.""" + sql, _ = statements._next_pk_expression("t", None, "order").to_sql() + assert "MAX(`order`)" in sql + + +class TestCountExpression: + def test_a_where_clause_renders_its_own_keyword(self, statements, dialect): + where = WhereClause( + dialect, + ComparisonPredicate(dialect, "=", Column(dialect, "a"), Literal(dialect, 1)), + ) + sql, params = statements._count_expression("t", "app", where).to_sql() + assert sql == "SELECT COUNT(*) AS `n` FROM `app`.`t` WHERE `a` = ?" + assert params == (1,) + + def test_a_bare_predicate_is_wrapped_rather_than_stripped(self, statements, dialect): + """The condition arrives as the options carry it, in either shape.""" + predicate = ComparisonPredicate( + dialect, "=", Column(dialect, "a"), Literal(dialect, 1) + ) + sql, params = statements._count_expression("t", None, predicate).to_sql() + assert sql == "SELECT COUNT(*) AS `n` FROM `t` WHERE `a` = ?" + assert params == (1,) + + def test_the_where_keyword_appears_exactly_once(self, statements, dialect): + """The old path cut \"WHERE \" off the rendered text; the clause owns it.""" + where = WhereClause( + dialect, + ComparisonPredicate(dialect, "=", Column(dialect, "a"), Literal(dialect, 1)), + ) + sql, _ = statements._count_expression("t", None, where).to_sql() + assert sql.count("WHERE") == 1 + + def test_caller_values_are_parameters_not_text(self, statements, dialect): + where = WhereClause( + dialect, + ComparisonPredicate( + dialect, "=", Column(dialect, "a"), Literal(dialect, "x'y") + ), + ) + sql, params = statements._count_expression("t", None, where).to_sql() + assert "x'y" not in sql + assert params == ("x'y",) From c4f3bff84737bbeb953cf0ff13d360020d4b5eb6 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 21 Oct 2026 03:15:51 +0800 Subject: [PATCH 19/35] fix(bigquery): refuse the ILIKE spelling the grammar does not have MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core's LIKE family became words-with-keywords (f2bdf386, 4774c502): the node carries `like` / `not_like` / `ilike`, and the dialect writes the SQL keyword. That made the three members three answers this backend owes, and two of them were right by accident: the inherited formatter interpolates `expr.op.keyword`, so `ILIKE` went straight into the statement for an engine whose grammar has no such operator. The caller built a node, the renderer produced SQL, and only the server would have said no. format_like_predicate now writes the LIKE family itself: LIKE and NOT LIKE render as before, and the ILIKE member raises UnsupportedFeatureError naming the operator and the portable answer (compare LOWER(column) with LOWER(pattern)). No other backend overrides this entry, so this is the first one to draw the line where §17 draws it -- a spelling the engine lacks is refused, not silently written. ILikePredicate keeps its own separate refusal: it is a different kind making the portable LOWER() promise, and its absent formatter is what refuses it. Routing one into the other would silently rewrite SQL callers depend on, which is the reason core keeps them apart. The new test file pins both, and asserts the two refusals stay distinct -- a rejection that collapsed into a single message would mean the two promises had been merged. Verified: the new dialect file 7 passed; feature/backend 1348 passed / 3 skipped, exit 0; ruff clean on the two changed files. --- .../backend/impl/bigquery/mixins/dql.py | 40 +++++++ .../dialect/test_bigquery_like_operator.py | 100 ++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_like_operator.py diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/dql.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/dql.py index b678855..1945483 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/dql.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/dql.py @@ -9,6 +9,7 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.predicates import LikePredicate #: The node's operation words mapped to the spelling GoogleSQL needs. Every #: entry carries the explicit ALL/DISTINCT qualifier the grammar requires -- @@ -69,6 +70,45 @@ def _render(node) -> Tuple[str, list]: base_sql = f"{left_sql} {_OP_SPELLINGS[expr.op]} {right_sql}" return base_sql, tuple(left_params + right_params) + def format_like_predicate(self, expr: "LikePredicate") -> Tuple[str, tuple]: + """Format a ``LIKE`` predicate, refusing the ``ILIKE`` spelling. + + ``LikePredicate`` carries the operation as a wire word, and the SQL + keyword is written here. Two of the three words are BigQuery's: + ``LIKE`` and ``NOT LIKE``. The third is not -- GoogleSQL has no + ``ILIKE`` operator, and the portable case-insensitive answer is a + ``LOWER()`` comparison -- so rendering it would hand the server a + keyword it rejects, from a node the caller built, with nothing on the + way saying so (§6, §17: a spelling the engine lacks is refused, not + silently written). + + ``ILikePredicate`` stays a separate promise and a separate kind; it is + refused by its own absent formatter, not by this method. + + Args: + expr: The pattern expression exposing ``left``, ``op`` and + ``pattern``. + + Returns: + Tuple of (SQL string, parameters tuple). + + Raises: + UnsupportedFeatureError: ``expr.op`` is ``ILIKE``. + """ + from rhosocial.activerecord.backend.expression.predicates import LikeOperator + + if expr.op is LikeOperator.ILIKE: + raise UnsupportedFeatureError( + self.name, + "ILIKE", + "BigQuery has no ILIKE operator; compare LOWER(column) with " + "LOWER(pattern), or build an ILikePredicate for the portable " + "form.", + ) + left_sql, left_params = expr.left.to_sql() + pattern_sql, pattern_params = expr.pattern.to_sql() + return (f"{left_sql} {expr.op.keyword} {pattern_sql}", left_params + pattern_params) + def supports_fetch_with_ties(self) -> bool: """BigQuery does not support FETCH ... WITH TIES.""" return False diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_like_operator.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_like_operator.py new file mode 100644 index 0000000..d949e30 --- /dev/null +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_like_operator.py @@ -0,0 +1,100 @@ +# tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_like_operator.py +"""The LIKE family: two words BigQuery spells, and the one it refuses. + +``LikePredicate`` carries the operation as a wire word and the dialect writes the +SQL keyword, so the three members are the dialect's three answers. BigQuery's +grammar has ``LIKE`` and ``NOT LIKE`` and no ``ILIKE``; the portable +case-insensitive form is a ``LOWER()`` comparison. Rendering ``ILIKE`` would hand +the server a keyword it rejects, from a node the caller built, with nothing on +the way saying so -- expression-dialect §6 and §17 make that a refusal. + +``ILikePredicate`` is a different promise (the portable ``LOWER(x) LIKE +LOWER(y)`` emulation) and a different kind, so it is refused by its own absent +formatter. These cases keep the two apart: routing one into the other would +silently rewrite SQL callers depend on. +""" + +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.core import Column, Literal +from rhosocial.activerecord.backend.expression.predicates import ( + ILikePredicate, + LikeOperator, + LikePredicate, +) +from rhosocial.activerecord.backend.impl.bigquery.dialect import BigQueryDialect + + +@pytest.fixture +def dialect(): + return BigQueryDialect() + + +@pytest.fixture +def pattern(dialect): + return LikePredicate( + dialect, LikeOperator.LIKE, Column(dialect, "name"), Literal(dialect, "%a%") + ) + + +class TestTheWordsBigQuerySpells: + def test_like(self, dialect): + expr = LikePredicate( + dialect, LikeOperator.LIKE, Column(dialect, "name"), Literal(dialect, "%a%") + ) + assert expr.to_sql() == ("`name` LIKE ?", ("%a%",)) + + def test_not_like(self, dialect): + expr = LikePredicate( + dialect, + LikeOperator.NOT_LIKE, + Column(dialect, "name"), + Literal(dialect, "%a%"), + ) + assert expr.to_sql() == ("`name` NOT LIKE ?", ("%a%",)) + + def test_the_keyword_comes_from_the_operator_not_the_word(self, dialect, pattern): + """The node carries ``like``; the SQL says ``LIKE``.""" + sql, _ = pattern.to_sql() + assert "like" not in sql.replace("LIKE", "") + assert LikeOperator.LIKE.keyword in sql + + +class TestTheWordBigQueryRefuses: + def test_ilike_is_refused_by_name(self, dialect): + expr = LikePredicate( + dialect, LikeOperator.ILIKE, Column(dialect, "name"), Literal(dialect, "%a%") + ) + with pytest.raises(UnsupportedFeatureError) as exc_info: + expr.to_sql() + assert "ILIKE" in str(exc_info.value) + + def test_the_refusal_points_at_the_portable_answer(self, dialect): + """A caller told no should be told what to do instead.""" + expr = LikePredicate( + dialect, LikeOperator.ILIKE, Column(dialect, "name"), Literal(dialect, "%a%") + ) + with pytest.raises(UnsupportedFeatureError, match="LOWER"): + expr.to_sql() + + def test_the_case_insensitive_node_has_its_own_refusal(self, dialect): + """``ILikePredicate`` is a separate kind with a separate answer.""" + with pytest.raises(UnsupportedFeatureError) as exc_info: + ILikePredicate(dialect, Column(dialect, "name"), "a%").to_sql() + assert "format_ilike_predicate" in str(exc_info.value) + + def test_the_two_refusals_are_not_the_same_one(self, dialect): + """Neither is routed into the other; each names its own cause.""" + like_sql = None + with pytest.raises(UnsupportedFeatureError) as like_exc: + LikePredicate( + dialect, + LikeOperator.ILIKE, + Column(dialect, "name"), + Literal(dialect, "%a%"), + ).to_sql() + with pytest.raises(UnsupportedFeatureError) as ilike_exc: + ILikePredicate(dialect, Column(dialect, "name"), "a%").to_sql() + assert str(like_exc.value) != str(ilike_exc.value) + assert like_sql is None From 92caf6cd21954a8179c86c28aa5b936e92374d2f Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 21 Oct 2026 05:03:59 +0800 Subject: [PATCH 20/35] test(bigquery): drop the codec pins the core fix retired Eleven rows in LEGITIMATE_NON_ROUNDTRIPS now round-trip, so the table existed only to record a defect core has fixed: * nine datetime classes lost their XML channel because DateTimeField and IntervalUnit were (str, Enum) without __str__ = str.__str__, so the wire carried 'DateTimeField.YEAR' and the constructor's normaliser refused it; * ColumnGeneratedConstraint and ForeignKeyConstraint lost all three channels because GeneratedColumnType and ReferentialAction were bare Enum, which the writer cannot name at all. Core's 3e23158e gave both families the str base and __str__, and the matrix now classifies all eleven classes as rendered. Deleting them leaves two real entries: CreateTableCloneStatement, whose constructor refuses the word its own (str, Enum) mode encodes, and CreateViewStatement, whose ViewOptions instance sits in a parameter slot the codec has no kind for. Neither is an encoding omission, so both stay, and the two integrity tests still have entries to assert instead of being deleted. The comment above the table now records both retired causes and says what the survivors are, rather than describing a root cause that no longer exists. --- .../backend/test_expression_roundtrip_all.py | 82 ++++++------------- 1 file changed, 26 insertions(+), 56 deletions(-) diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py index d5e47d5..3862793 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py @@ -1240,43 +1240,30 @@ def path_pattern(d): # # Every entry here is core's to fix, not this backend's. None of the classes is # BigQuery-specific; the artefact contract is the same one every backend reads. +# +# Two enum-shaped root causes used to be pinned here. Both were core's, and +# both are gone: +# +# * nine ``(str, Enum)`` word carriers (``DateTimeField``, ``IntervalUnit`` and +# the XML vocabulary) lacked ``__str__ = str.__str__``, so the XML channel +# wrote ``"DateTimeField.YEAR"`` and the constructor's own normaliser refused +# it -- core gave every word-carrying enum that method, and the datetime +# family's XML channel now travels; +# * twelve SQL-word enums were bare ``Enum``, which the writer could not name +# at all -- core made them ``(str, Enum)`` with the same ``__str__``, so +# ``GeneratedColumnType`` / ``ReferentialAction`` and their family now encode +# on every channel. +# +# What is left is a different problem in the same place: a value, not a word, +# sitting in a parameter slot. Neither is an encoding omission, so neither is +# retired by the enum fix, and both are still reproduced above. LEGITIMATE_NON_ROUNDTRIPS = { - # -- the datetime family: DateTimeField / IntervalUnit are (str, Enum) - # without ``__str__ = str.__str__``, so the XML channel writes - # ``"DateTimeField.YEAR"``, which the constructor's own normaliser - # refuses. (JSON survives -- it writes the member's underlying string; - # dict survives -- it passes the member through untouched.) - "rhosocial.activerecord.backend.expression.datetime._ExtractBase": { - "channels": {"xml": ("raises", ValueError, "unsupported datetime field")}, - }, - "rhosocial.activerecord.backend.expression.datetime.DatePartExpression": { - "channels": {"xml": ("raises", ValueError, "unsupported datetime field")}, - }, - "rhosocial.activerecord.backend.expression.datetime.ExtractExpression": { - "channels": {"xml": ("raises", ValueError, "unsupported datetime field")}, - }, - "rhosocial.activerecord.backend.expression.datetime.ExtractFieldExpression": { - "channels": {"xml": ("raises", ValueError, "unsupported datetime field")}, - }, - "rhosocial.activerecord.backend.expression.datetime.DateTruncExpression": { - "channels": {"xml": ("raises", ValueError, "unsupported datetime field")}, - }, - "rhosocial.activerecord.backend.expression.datetime.IntervalExpression": { - "channels": {"xml": ("raises", ValueError, "unsupported interval unit")}, - }, - "rhosocial.activerecord.backend.expression.datetime.DateTimeAddExpression": { - "channels": {"xml": ("raises", ValueError, "unsupported interval unit")}, - }, - "rhosocial.activerecord.backend.expression.datetime.DateTimeDiffExpression": { - "channels": {"xml": ("raises", ValueError, "unsupported interval unit")}, - }, - "rhosocial.activerecord.backend.expression.datetime.DateTimeSubtractExpression": { - "channels": {"xml": ("raises", ValueError, "unsupported interval unit")}, - }, - # -- ``mode`` is declared ``CreateTableCloneMode`` with a member default; - # the JSON and XML channels write the word (``"CLONE"``), and the - # constructor demands the member -- the isinstance half of the contract - # without the normalise half. + # -- ``mode`` is declared ``CreateTableCloneMode`` with a member default. + # That enum is a ``(str, Enum)`` word carrier, so the JSON and XML + # channels encode it fine -- and the constructor then demands the member + # rather than normalising the word, which is the isinstance half of the + # contract without the normalise half. Still core's, still real: this is + # a constructor that refuses its own enum's word, not an encoding gap. "rhosocial.activerecord.backend.expression.statements.ddl_table.CreateTableCloneStatement": { "channels": { "json": ("raises", TypeError, "mode must be a CreateTableCloneMode"), @@ -1287,6 +1274,9 @@ def path_pattern(d): # parameter slot: the codec writes nodes, scalars and containers of # those two, and a bare ``options or ViewOptions()`` puts one in every # CREATE VIEW. Serialization refuses before any round-trip can start. + # Nothing about ``ViewAlgorithm`` / ``ViewCheckOption`` here -- both are + # ``(str, Enum)`` word carriers now and encode; the refusal is the + # object in the slot, which no enum fix reaches. "rhosocial.activerecord.backend.expression.statements.ddl_view.CreateViewStatement": { "channels": { "dict": ("raises", TypeError, "ViewOptions is not one of the value kinds"), @@ -1294,26 +1284,6 @@ def path_pattern(d): "xml": ("raises", TypeError, "ViewOptions is not one of the value kinds"), }, }, - # -- ``storage_type`` defaults to a ``GeneratedColumnType`` member, a plain - # (non-str) enum the artifact cannot name; the constructor above names - # STORED for BigQuery's sake, and that member is the one that travels. - "rhosocial.activerecord.backend.expression.statements.ddl_table.ColumnGeneratedConstraint": { - "channels": { - "dict": ("raises", TypeError, "GeneratedColumnType is not one of the value kinds"), - "json": ("raises", TypeError, "GeneratedColumnType is not one of the value kinds"), - "xml": ("raises", TypeError, "GeneratedColumnType is not one of the value kinds"), - }, - }, - # -- ``on_delete`` / ``on_update`` default to ``ReferentialAction`` members, - # a plain (non-str) enum the artifact cannot name. The instances built - # here leave both at their defaults, so every channel meets it. - "rhosocial.activerecord.backend.expression.statements.ddl_table.ForeignKeyConstraint": { - "channels": { - "dict": ("raises", TypeError, "ReferentialAction is not one of the value kinds"), - "json": ("raises", TypeError, "ReferentialAction is not one of the value kinds"), - "xml": ("raises", TypeError, "ReferentialAction is not one of the value kinds"), - }, - }, } From 1d0026dd8846aabc93d3fe04512efab2d88d8f97 Mon Sep 17 00:00:00 2001 From: vistart Date: Wed, 21 Oct 2026 13:30:57 +0800 Subject: [PATCH 21/35] chore: ignore .worktree/, the branch toolchain directories --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index bf307f4..678c0ac 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,8 @@ coverage.xml # Local PR drafts and other scratch files used by the merge flow .claude/tmp*/ +# 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/ From 074be7fd7b82ba033c0dfc493f2247a03bb741f5 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 22 Oct 2026 22:49:42 +0800 Subject: [PATCH 22/35] fix(bigquery): the dialect surface, and the type positions it decides The dialect's surface is two kinds of member: capability switches (``supports_*()``) and formatters (``format_*(self, expr)``). This change makes BigQuery's code match that. **A string is escaped by the dialect that renders it.** The escape policy belongs to the dialect, because SQL dialects differ in how a string literal represents a quote -- BigQuery spells a quote as a backslash-quote. The base class's private copy is gone; each dialect overrides the public one. **The backend's own answers are the effective ones.** BigQuery's mixin precedes the core mixin it overrides, and a test holds that in place. **The mixin list says what it is for.** The base-list convention is written down where the list is. --- .../backend/impl/bigquery/dialect.py | 11 ++++- .../impl/bigquery/mixins/capabilities.py | 2 +- .../backend/impl/bigquery/mixins/ddl.py | 2 +- .../backend/impl/bigquery/mixins/dql.py | 2 +- .../backend/impl/bigquery/mixins/literal.py | 2 +- .../impl/bigquery/mixins/materialized_view.py | 4 +- .../dialect/test_bigquery_literal_values.py | 2 +- .../protocol/test_protocol_conformance.py | 44 +++++++++++++++++++ 8 files changed, 60 insertions(+), 9 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/dialect.py b/src/rhosocial/activerecord/backend/impl/bigquery/dialect.py index 780b8e2..7dc18d5 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/dialect.py @@ -100,6 +100,14 @@ class BigQueryDialect( + # 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). # New BigQuery-specific mixins (BEFORE SQLDialectBase and generic mixins they override) BigQueryCapabilityMixin, BigQueryTypeSupportMixin, @@ -208,8 +216,7 @@ def get_parameter_placeholder(self, position: int = 0) -> str: """BigQuery Standard SQL uses ``?`` positional bind markers.""" return "?" - @staticmethod - def _escape_sql_string(value: str) -> str: + def escape_sql_string(self, value: str) -> str: """GoogleSQL's string escaping: backslashes, not quote doubling. The base helper doubles single quotes, which is the SQL-standard diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/capabilities.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/capabilities.py index 286a22d..bc39858 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/capabilities.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/capabilities.py @@ -29,7 +29,7 @@ def format_table_comment(self, comment: str) -> Tuple[str, tuple]: The generic ``TableMixin.format_table_comment_clause`` adds the leading space and appends the fragment after the column list. """ - escaped = self._escape_sql_string(comment) + escaped = self.escape_sql_string(comment) return f"OPTIONS(description='{escaped}')", () def supports_cte(self) -> bool: diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/ddl.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/ddl.py index ba3440f..581b270 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/ddl.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/ddl.py @@ -89,7 +89,7 @@ def format_column_comment_clause(self, clause: Any) -> Tuple[str, tuple]: reached from ``CREATE TABLE``'s column loop, where the value is whatever clause class the dialect dispatches on. """ - escaped = self._escape_sql_string(clause.comment) + escaped = self.escape_sql_string(clause.comment) return f" OPTIONS(description='{escaped}')", () def format_column_attribute(self, attr: Any) -> Tuple[str, tuple]: diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/dql.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/dql.py index 1945483..0447832 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/dql.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/dql.py @@ -219,7 +219,7 @@ def format_datetime_diff_expression(self, expr) -> Tuple[str, tuple]: else: sql = ( f"DATE_DIFF({end_sql}, {start_sql}, " - f"{self._escape_sql_string(part)})" + f"{self.escape_sql_string(part)})" ) return self.apply_alias(sql, end_params + start_params, expr) diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/literal.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/literal.py index 762d4fa..d2ef7ce 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/literal.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/literal.py @@ -29,7 +29,7 @@ The kinds BigQuery agrees with the base on -- NULL, booleans (``TRUE`` / ``FALSE``), ``INT64``/``FLOAT64`` numeric text, and strings -- keep the base's rendering (strings inline correctly because the dialect overrides -``_escape_sql_string`` for GoogleSQL's backslash escaping; see +``escape_sql_string`` for GoogleSQL's backslash escaping; see :mod:`...impl.bigquery.dialect`). Everything else this grammar spells its own way: diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/materialized_view.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/materialized_view.py index 0cf1245..cfa31e3 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/materialized_view.py @@ -382,8 +382,8 @@ def _format_materialized_view_options(self, options: Any) -> str: 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)}'" + # 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}") diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_literal_values.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_literal_values.py index 2c995a2..8cec810 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_literal_values.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_literal_values.py @@ -317,7 +317,7 @@ def test_refused_at_the_literal_entry_point(self, dialect, value): class TestTheDialectWideEscapePolicy: - """``_escape_sql_string`` is the one place GoogleSQL's escaping lives. + """``escape_sql_string`` is the one place GoogleSQL's escaping lives. The escape table documents ``\\'`` for a single quote and makes backslashes escape-sequence introducers, so a literal backslash is 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 681fc05..0490f61 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 @@ -169,3 +169,47 @@ def test_dialect_has_type_and_domain_protocols(self): assert isinstance(dialect, protocol), protocol.__name__ assert isinstance(dialect, UserDefinedTypeMixin) assert isinstance(dialect, DomainMixin) + + +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): + from rhosocial.activerecord.backend.impl.bigquery.dialect import ( + BigQueryDialect, + ) + + mro = type(BigQueryDialect()).__mro__ + own = "rhosocial.activerecord.backend.impl.bigquery" + 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}" + ) From 5ffc3183de92ea20e33c4c8df64df2af5f30607a Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 22 Oct 2026 23:32:53 +0800 Subject: [PATCH 23/35] refactor(expression)!: follow core's statement rename Core renamed the four statement-shaped nodes it still called *Expression: TransactionExpression -> TransactionStatement SetTransactionExpression -> SetTransactionStatement SQLiteAttachExpression -> SQLiteAttachStatement SQLiteDetachExpression -> SQLiteDetachStatement This repository refers to them in its dialect signatures and its tests, so the same names follow here. Nothing changes but the names. --- .../impl/bigquery/mixins/transaction.py | 6 +++--- .../test_bigquery_master_probe_guard.py | 18 +++++++++--------- .../backend/test_expression_roundtrip_all.py | 4 ++-- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/transaction.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/transaction.py index fae8a51..d82217b 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/transaction.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/transaction.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.expression.transaction import ( BeginTransactionStatement, - SetTransactionExpression, + SetTransactionStatement, ) @@ -33,7 +33,7 @@ def supports_transaction_wait(self) -> bool: ``COMMIT TRANSACTION`` and ``ROLLBACK TRANSACTION``; the string ``WAIT`` does not occur on it, and neither does ``SET TRANSACTION``. The pair is carried by ``BeginTransactionStatement`` and - ``SetTransactionExpression`` all the same, so both spellings are + ``SetTransactionStatement`` all the same, so both spellings are refused by name below rather than dropped. Reference (fetched 2026-10-08): @@ -72,7 +72,7 @@ def format_begin_transaction_statement( return " ".join(parts), () def format_set_transaction( - self, expr: "SetTransactionExpression" + self, expr: "SetTransactionStatement" ) -> Tuple[str, tuple]: """Refuse ``SET TRANSACTION``; name the wait pair when it was requested. diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_master_probe_guard.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_master_probe_guard.py index edff05e..b6e6729 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_master_probe_guard.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_bigquery_master_probe_guard.py @@ -15,7 +15,7 @@ MATERIALIZED VIEW 4 ``supports_transaction_wait()`` the ``wait`` / ``no_wait`` pair on ``BeginTransactionStatement`` and - ``SetTransactionExpression`` + ``SetTransactionStatement`` = =================================== ========================================== A dialect that does not declare one of the first three now refuses what it @@ -79,7 +79,7 @@ ) from rhosocial.activerecord.backend.expression.transaction import ( BeginTransactionStatement, - SetTransactionExpression, + SetTransactionStatement, ) from rhosocial.activerecord.backend.impl.bigquery.dialect import BigQueryDialect @@ -326,23 +326,23 @@ def test_begin_both_parameters_are_api_misuse(self, dialect): def test_set_transaction_neither_keeps_the_statement_refusal(self, dialect): """The pinned round-trip classification: no SET TRANSACTION at all.""" with pytest.raises(NotImplementedError, match="does not support SET TRANSACTION"): - SetTransactionExpression(dialect).to_sql() + SetTransactionStatement(dialect).to_sql() def test_set_transaction_wait_is_refused_by_name(self, dialect): with pytest.raises(UnsupportedFeatureError) as exc: - SetTransactionExpression(dialect, wait=True).to_sql() + SetTransactionStatement(dialect, wait=True).to_sql() assert exc.value.feature_name == "transaction WAIT" assert exc.value.dialect_name == "BigQuery" def test_set_transaction_no_wait_is_refused_by_name(self, dialect): with pytest.raises(UnsupportedFeatureError) as exc: - SetTransactionExpression(dialect, no_wait=True).to_sql() + SetTransactionStatement(dialect, no_wait=True).to_sql() assert exc.value.feature_name == "transaction NO WAIT" assert exc.value.dialect_name == "BigQuery" def test_set_transaction_both_parameters_are_api_misuse(self, dialect): with pytest.raises(ValueError, match="wait and no_wait are mutually exclusive"): - SetTransactionExpression(dialect, wait=True, no_wait=True) + SetTransactionStatement(dialect, wait=True, no_wait=True) def test_probe_and_refusals_agree(self, dialect): """The declared probe and the formatter branches cannot drift apart. @@ -357,8 +357,8 @@ def test_probe_and_refusals_agree(self, dialect): for expr in ( BeginTransactionStatement(dialect, wait=True), BeginTransactionStatement(dialect, no_wait=True), - SetTransactionExpression(dialect, wait=True), - SetTransactionExpression(dialect, no_wait=True), + SetTransactionStatement(dialect, wait=True), + SetTransactionStatement(dialect, no_wait=True), ): with pytest.raises(UnsupportedFeatureError): expr.to_sql() @@ -391,4 +391,4 @@ def supports_transaction_wait(self) -> bool: # The statement still does not exist, so SET TRANSACTION keeps its # refusal even under a subclass that declares the clause. with pytest.raises(UnsupportedFeatureError): - SetTransactionExpression(waits, wait=True).to_sql() + SetTransactionStatement(waits, wait=True).to_sql() diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py index 3862793..da46d23 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py @@ -1215,11 +1215,11 @@ def path_pattern(d): "rhosocial.activerecord.backend.expression.statements.dml.InsertDataSource": ( _NO_DISPATCH ), - "rhosocial.activerecord.backend.expression.transaction.TransactionExpression": ( + "rhosocial.activerecord.backend.expression.transaction.TransactionStatement": ( _NO_DISPATCH ), # ---- a statement this dialect declines to render ------------------------ - "rhosocial.activerecord.backend.expression.transaction.SetTransactionExpression": ( + "rhosocial.activerecord.backend.expression.transaction.SetTransactionStatement": ( "does not support SET TRANSACTION statement" ), } From a69ce3078130c5d86af7e5fa113be90960583f5c Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 23 Oct 2026 18:06:03 +0800 Subject: [PATCH 24/35] refactor(expression)!: follow the renamed core nodes and formatters BigQuery's own statement nodes already named their classes, kinds and formatters alike, so this only tracks core: the datetime nodes are ``Datetime*`` because datetime is one word, the introspection nodes are ``*QueryStatement`` because they render a SELECT, and SET TRANSACTION's formatter is ``format_set_transaction_statement`` so the name it answers to matches the kind it renders. --- .../schema_namespace.md | 2 +- .../schema_namespace.md | 2 +- .../impl/bigquery/mixins/transaction.py | 4 +- .../backend/expression_roundtrip_harness.py | 18 +++---- .../backend/test_expression_roundtrip_all.py | 52 +++++++++---------- 5 files changed, 39 insertions(+), 39 deletions(-) diff --git a/docs/en_US/bigquery_specific_features/schema_namespace.md b/docs/en_US/bigquery_specific_features/schema_namespace.md index a997c83..7fb2020 100644 --- a/docs/en_US/bigquery_specific_features/schema_namespace.md +++ b/docs/en_US/bigquery_specific_features/schema_namespace.md @@ -839,7 +839,7 @@ dialect.get_supported_introspection_scopes() # [] Every metadata query formatter therefore refuses: ```python -TableInfoExpression(d, "orders").to_sql() +TableInfoQueryStatement(d, "orders").to_sql() # UnsupportedFeatureError: 'BigQuery' dialect does not support table # introspection. ``` diff --git a/docs/zh_CN/bigquery_specific_features/schema_namespace.md b/docs/zh_CN/bigquery_specific_features/schema_namespace.md index e7cb0ca..45d8928 100644 --- a/docs/zh_CN/bigquery_specific_features/schema_namespace.md +++ b/docs/zh_CN/bigquery_specific_features/schema_namespace.md @@ -771,7 +771,7 @@ dialect.get_supported_introspection_scopes() # [] 于是每个元数据查询格式化方法都会拒绝: ```python -TableInfoExpression(d, "orders").to_sql() +TableInfoQueryStatement(d, "orders").to_sql() # UnsupportedFeatureError: 'BigQuery' dialect does not support table # introspection. ``` diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/transaction.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/transaction.py index d82217b..8b7807a 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/transaction.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/transaction.py @@ -71,7 +71,7 @@ def format_begin_transaction_statement( parts.append("WAIT" if wait else "NO WAIT") return " ".join(parts), () - def format_set_transaction( + def format_set_transaction_statement( self, expr: "SetTransactionStatement" ) -> Tuple[str, tuple]: """Refuse ``SET TRANSACTION``; name the wait pair when it was requested. @@ -97,7 +97,7 @@ def format_set_transaction( f"{self.name} has no SET TRANSACTION statement, so the " f"{feature} clause cannot be expressed.", ) - return super().format_set_transaction(expr) + return super().format_set_transaction_statement(expr) __all__ = ['BigQueryTransactionMixin'] diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/expression_roundtrip_harness.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/expression_roundtrip_harness.py index d94ec20..59f2aa7 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/expression_roundtrip_harness.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/expression_roundtrip_harness.py @@ -340,9 +340,9 @@ class that was split needs one entry per resulting class. "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, + "datetime.DatetimeDiffExpression": _datetime_diff_expr, + "datetime.DatetimeSubtractExpression": _datetime_sub_expr, + "datetime.DatetimeAddExpression": _datetime_add_expr, } @@ -890,29 +890,29 @@ def _interval_expr(dialect): def _datetime_diff_expr(dialect): from rhosocial.activerecord.backend.expression.core import Column - from rhosocial.activerecord.backend.expression.datetime import DateTimeDiffExpression + from rhosocial.activerecord.backend.expression.datetime import DatetimeDiffExpression - return DateTimeDiffExpression(dialect, "day", Column(dialect, "a"), Column(dialect, "b")) + 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, + DatetimeSubtractExpression, IntervalExpression, ) - return DateTimeSubtractExpression(dialect, Column(dialect, "a"), IntervalExpression(dialect, 1, "day")) + 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, + DatetimeAddExpression, IntervalExpression, ) - return DateTimeAddExpression(dialect, Column(dialect, "a"), IntervalExpression(dialect, 1, "day")) + return DatetimeAddExpression(dialect, Column(dialect, "a"), IntervalExpression(dialect, 1, "day")) _SPECIAL_REGISTRY: Dict[str, Callable] = special_constructors() diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py index da46d23..55d2dc3 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/test_expression_roundtrip_all.py @@ -964,43 +964,43 @@ def path_pattern(d): LEGITIMATE_NON_RENDERS = { # ---- SQL/XML: BigQuery has no SQL/XML functions at all ----------------- "rhosocial.activerecord.backend.expression.xml.XMLAggExpression": ( - UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_xmlagg_expression'", + UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_xml_agg_expression'", ), "rhosocial.activerecord.backend.expression.xml.XMLAttributesExpression": ( - UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_xmlattributes_expression'", + UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_xml_attributes_expression'", ), "rhosocial.activerecord.backend.expression.xml.XMLCommentExpression": ( - UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_xmlcomment_expression'", + UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_xml_comment_expression'", ), "rhosocial.activerecord.backend.expression.xml.XMLConcatExpression": ( - UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_xmlconcat_expression'", + UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_xml_concat_expression'", ), "rhosocial.activerecord.backend.expression.xml.XMLElementExpression": ( - UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_xmlelement_expression'", + UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_xml_element_expression'", ), "rhosocial.activerecord.backend.expression.xml.XMLExistsPredicate": ( UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_xmlexists_predicate'", ), "rhosocial.activerecord.backend.expression.xml.XMLForestExpression": ( - UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_xmlforest_expression'", + UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_xml_forest_expression'", ), "rhosocial.activerecord.backend.expression.xml.XMLPIExpression": ( UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_xmlpi_expression'", ), "rhosocial.activerecord.backend.expression.xml.XMLParseExpression": ( - UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_xmlparse_expression'", + UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_xml_parse_expression'", ), "rhosocial.activerecord.backend.expression.xml.XMLQueryExpression": ( - UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_xmlquery_expression'", + UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_xml_query_expression'", ), "rhosocial.activerecord.backend.expression.xml.XMLRootExpression": ( - UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_xmlroot_expression'", + UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_xml_root_expression'", ), "rhosocial.activerecord.backend.expression.xml.XMLSerializeExpression": ( - UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_xmlserialize_expression'", + UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_xml_serialize_expression'", ), "rhosocial.activerecord.backend.expression.xml.XMLTableExpression": ( - UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_xmltable_expression'", + UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_xml_table_expression'", ), # SQL/XML PASSING is a clause of the three XML nodes, and the dialect has # no SQL/XML at all -- so the clause node core split out in S1-c is a gap by @@ -1083,7 +1083,7 @@ def path_pattern(d): ), # ---- COMMENT ON, PARTITION clause, PIVOT, ILIKE ------------------------- "rhosocial.activerecord.backend.expression.statements.ddl_comment.CommentOnStatement": ( - UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_comment_statement'", + UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_comment_on_statement'", ), "rhosocial.activerecord.backend.expression.statements.ddl_partition.PartitionClause": ( UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_partition_clause'", @@ -1099,7 +1099,7 @@ def path_pattern(d): ), # ---- time travel: a BigQuery FROM names no temporal version ------------ "rhosocial.activerecord.backend.expression.datetime.TemporalOptionsExpression": ( - UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_temporal_options'", + UnsupportedFeatureError, f"{_NO_FORMATTER} 'format_temporal_options_expression'", ), # ---- roots whose incompleteness is structural -------------------------- # The root of the row-source tree. No concrete source renders through @@ -1389,19 +1389,19 @@ def _dispatched_formatter(exc): #: and TRIM through BigQueryTrimMixin -- so none of the four appears here. UNMODELLED_FORMATTERS = { "SQL/XML (BigQuery has no SQL/XML functions at all)": ( - "format_xmlagg_expression", - "format_xmlattributes_expression", - "format_xmlcomment_expression", - "format_xmlconcat_expression", - "format_xmlelement_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_xmlforest_expression", + "format_xml_forest_expression", "format_xmlpi_expression", - "format_xmlparse_expression", - "format_xmlquery_expression", - "format_xmlroot_expression", - "format_xmlserialize_expression", - "format_xmltable_expression", + "format_xml_parse_expression", + "format_xml_query_expression", + "format_xml_root_expression", + "format_xml_serialize_expression", + "format_xml_table_expression", "format_passing_clause", ), "SQL/PGQ property graphs (BigQuery has no CREATE PROPERTY GRAPH, no " @@ -1439,7 +1439,7 @@ def _dispatched_formatter(exc): "format_alter_database_statement", "format_drop_database_statement", ), - "COMMENT ON (BigQuery has no standalone COMMENT ON statement)": ("format_comment_statement",), + "COMMENT ON (BigQuery has no standalone COMMENT ON statement)": ("format_comment_on_statement",), "the generic DDL PARTITION clause (BigQuery partitions tables through its " "own partition options, not this clause)": ("format_partition_clause",), "the core PIVOT / UNPIVOT expressions (this backend composes no formatter " @@ -1452,7 +1452,7 @@ def _dispatched_formatter(exc): "LOWER() comparisons)": ("format_ilike_predicate",), "the core temporal-options clause (a BigQuery FROM names no temporal " "version; time travel is spelled FOR SYSTEM_TIME AS OF, which the dialect " - "renders elsewhere)": ("format_temporal_options",), + "renders elsewhere)": ("format_temporal_options_expression",), "the TableSource root (every concrete row source overrides its formatter; " "the root carries nothing but an alias and is never rendered)": ("format_table_source",), } From 3c04003b3977ca495634ca93097203a2d931f423 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 23 Oct 2026 18:06:03 +0800 Subject: [PATCH 25/35] refactor(backend)!: call the core helpers by their public names Core moved ten cross-package helpers out from behind a leading underscore: they are called from this backend, so the underscore was claiming a scope the names never had. Each old name had exactly one new name, checked against every public definition and call site in the ten repositories first, so nothing is shadowed by the rename. --- .../impl/bigquery/backend/async_backend.py | 2 +- .../backend/impl/bigquery/backend/backend.py | 8 +++---- .../backend/impl/bigquery/mixins/types.py | 24 +++++++++---------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/backend/async_backend.py b/src/rhosocial/activerecord/backend/impl/bigquery/backend/async_backend.py index 683f35c..312a98d 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/backend/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/backend/async_backend.py @@ -139,7 +139,7 @@ async def execute(self, sql: str, params: Optional[Tuple] = None, returning: Opt rows = [] for row in rows_iter: row_dict = dict(row.items()) - rows.append(self._remap_row_columns(self._adapt_row_types(row_dict, adapters), mapping)) + rows.append(self.remap_row_columns(self.adapt_row_types(row_dict, adapters), mapping)) affected_rows = len(rows) result_data = rows else: diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/backend/backend.py b/src/rhosocial/activerecord/backend/impl/bigquery/backend/backend.py index 0511d59..825abdb 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/backend/backend.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/backend/backend.py @@ -341,7 +341,7 @@ def execute(self, sql: str, params: Optional[Tuple] = None, returning: Optional[ rows = [] for row in rows_iter: row_dict = dict(row.items()) - rows.append(self._remap_row_columns(self._adapt_row_types(row_dict, adapters), mapping)) + rows.append(self.remap_row_columns(self.adapt_row_types(row_dict, adapters), mapping)) affected_rows = len(rows) result_data = rows else: @@ -479,7 +479,7 @@ def _execute_via_rest(self, sql: str, params: Optional[Tuple], is_select: bool, adapters = column_adapters or {} mapping = column_mapping or {} rows = [ - self._remap_row_columns(self._adapt_row_types(row, adapters), mapping) + self.remap_row_columns(self.adapt_row_types(row, adapters), mapping) for row in BigQueryBackend._rest_rows(self, body) ] # Follow pagination if the emulator ever splits results. @@ -496,7 +496,7 @@ def _execute_via_rest(self, sql: str, params: Optional[Tuple], is_select: bool, message = (pbody.get("error") or {}).get("message") or page.text BigQueryBackend._raise_http_error(page.status_code, message) rows.extend( - self._remap_row_columns(self._adapt_row_types(row, adapters), mapping) + self.remap_row_columns(self.adapt_row_types(row, adapters), mapping) for row in BigQueryBackend._rest_rows(self, pbody) ) page_token = pbody.get("pageToken") @@ -616,7 +616,7 @@ def bulk_update(self, options) -> QueryResult: # rows, and the BigQuery analyzer rejects an untyped NULL CASE result # ("Value of type INT64 cannot be assigned to ..."). Reusing the # column itself is also the safe semantics. - from rhosocial.activerecord.backend.base.operations import _is_sql_expression + from rhosocial.activerecord.backend.base.operations import is_sql_expression from rhosocial.activerecord.backend.expression import ( Column, Literal, UpdateStatement, ComparisonPredicate, InPredicate, ) diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/types.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/types.py index 7e4fc1b..c66dd2e 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/types.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/types.py @@ -9,7 +9,7 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError # Imported for real, not under TYPE_CHECKING: the spelling gates below pass the -# concept class to ``_check_spelling`` as a *value* (it reads ``SPELLINGS`` off +# concept class to ``check_spelling`` as a *value* (it reads ``SPELLINGS`` off # it), so these names have to exist at runtime. The core types package imports # no backend, so this does not close a cycle. from rhosocial.activerecord.backend.expression.types import ( @@ -255,7 +255,7 @@ def format_data_type_tinyint(self, data_type: TinyIntType) -> Tuple[str, tuple]: :meth:`_refuse_unsigned_integer`, which is also where the reason for the refusal is written down. """ - self._check_spelling(data_type, TinyIntType) + self.check_spelling(data_type, TinyIntType) self._refuse_unsigned_integer(data_type, "TINYINT") return "INT64", () @@ -272,7 +272,7 @@ def format_data_type_smallint(self, data_type: SmallIntType) -> Tuple[str, tuple ``unsigned`` is refused rather than ignored; see :meth:`_refuse_unsigned_integer`. """ - self._check_spelling(data_type, SmallIntType) + self.check_spelling(data_type, SmallIntType) self._refuse_unsigned_integer(data_type, "SMALLINT") return "INT64", () @@ -289,7 +289,7 @@ def format_data_type_integer(self, data_type: IntegerType) -> Tuple[str, tuple]: ``unsigned`` is refused rather than ignored; see :meth:`_refuse_unsigned_integer`. """ - self._check_spelling(data_type, IntegerType) + self.check_spelling(data_type, IntegerType) self._refuse_unsigned_integer(data_type, "INT") return "INT64", () @@ -307,7 +307,7 @@ def format_data_type_bigint(self, data_type: BigIntType) -> Tuple[str, tuple]: ``unsigned`` is refused rather than ignored; see :meth:`_refuse_unsigned_integer`. """ - self._check_spelling(data_type, BigIntType) + self.check_spelling(data_type, BigIntType) self._refuse_unsigned_integer(data_type, "BIGINT") return "INT64", () @@ -579,7 +579,7 @@ def format_data_type_double(self, data_type: DoubleType) -> Tuple[str, tuple]: about zero and whose field layout has a signed exponent, so there is no unsigned double-precision column for the request to have become. """ - self._check_spelling(data_type, DoubleType) + self.check_spelling(data_type, DoubleType) self._refuse_unsigned_numeric(data_type, "DOUBLE PRECISION") return "FLOAT64", () @@ -745,7 +745,7 @@ def format_data_type_decimal(self, data_type: DecimalType) -> Tuple[str, tuple]: type-modifier position, so there is no ``NUMERIC UNSIGNED`` to write. """ self._refuse_unsigned_numeric(data_type, "DECIMAL") - self._check_spelling(data_type, DecimalType) + self.check_spelling(data_type, DecimalType) self._check_numeric_parameters(data_type) if data_type.scale is not None and data_type.precision is None: ranges = ( @@ -797,7 +797,7 @@ def format_data_type_boolean(self, data_type: BooleanType) -> Tuple[str, tuple]: spellings are accepted and normalised, since the concept is the same two-valued column either way. """ - self._check_spelling(data_type, BooleanType) + self.check_spelling(data_type, BooleanType) return "BOOL", () # ------------------------------------------------------------------ @@ -887,7 +887,7 @@ def format_data_type_char(self, data_type: CharType) -> Tuple[str, tuple]: A declared length is range-checked against the one bound the reference states; see :meth:`_check_string_length`. """ - self._check_spelling(data_type, CharType) + self.check_spelling(data_type, CharType) length = data_type.length self._check_string_length(length) # ``is not None`` rather than a truthiness test: a length of 0 is an @@ -906,7 +906,7 @@ def format_data_type_varchar(self, data_type: VarCharType) -> Tuple[str, tuple]: ``STRING(length)`` is. Both spellings render identically, and a declared length is range-checked the same way; see :meth:`_check_string_length`. """ - self._check_spelling(data_type, VarCharType) + self.check_spelling(data_type, VarCharType) length = data_type.length self._check_string_length(length) return (f"STRING({length})", ()) if length is not None else ("STRING", ()) @@ -923,7 +923,7 @@ def format_data_type_text(self, data_type: TextType) -> Tuple[str, tuple]: would refuse a spelling of a type BigQuery does have, which tells the caller less than the widened column does. """ - self._check_spelling(data_type, TextType) + self.check_spelling(data_type, TextType) return "STRING", () # ------------------------------------------------------------------ @@ -1157,7 +1157,7 @@ def format_data_type_blob(self, data_type: BlobType) -> Tuple[str, tuple]: here at all — see :meth:`suggested_data_types` for why ``BYTES``'s declared length is a maximum rather than a width. """ - self._check_spelling(data_type, BlobType) + self.check_spelling(data_type, BlobType) return "BYTES", () # ------------------------------------------------------------------ From 1ab01e1322bf794cb6c8437dd1501639ff02fb74 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 24 Oct 2026 07:25:14 +0800 Subject: [PATCH 26/35] feat(introspection): answer the metadata questions from INFORMATION_SCHEMA Add BigQuery introspection end to end: the statements, the dialect that spells them, the sync/async introspectors behind `backend.introspector`, a CLI over them, and a runnable example. No BigQuery instance was reachable while writing any of it; every statement shape is read from the documentation, and the URLs are cited beside the class that rests on them (information-schema-tables, -columns, -views, -table-constraints, -key-column-usage, -datasets-schemata, -table-options, -partitions, the INFORMATION_SCHEMA intro for the dataset/region/project qualifiers, and the system-variables page for @@project_id). What an interrupted run had left here built each question as string- concatenated SQL (`return "SELECT ... FROM " + view + ...`), which puts the backtick quoting of a `project.dataset` path in code that never sees the dialect and restates it by hand -- a project ID with a backtick would produce SQL the server refuses. That SQL is gone: every question is now a statement object in expression/introspection.py, rendered by format_* methods on BigQueryIntrospectionMixin, which assemble a core QueryStatement from Column / NamedRelationRef / ComparisonPredicate / InPredicate / LogicalPredicate / OrderByClause nodes and render it through core's own format_query_statement. The introspector only resolves *which* dataset and project a question is about, runs the rendered text, and parses the rows. Dataset is BigQuery's schema level: the core `schema` argument carries the dataset ID, and BigQueryNamespaceMixin now admits the one documented project-only path (`[PROJECT_ID.]INFORMATION_SCHEMA.SCHEMATA`) while still refusing a bare project above a relation. Core's own introspection statement names are answered too -- as adapters onto the BigQuery statements -- because declaring a capability the core entry cannot reach is worse than declining it. Foreign keys and triggers refuse with the reason: the catalog reports that a foreign key exists without naming the referenced relation, and BigQuery has no triggers. Primary keys are reported as IndexInfo with IndexType UNKNOWN, because there is no index structure behind them. CLI: tables / views / table / columns / datasets, in table/json/csv/tsv, through backend.introspector; --dataset is the schema argument, which is BigQuery's dataset. Example: prints datasets/tables/columns and shows the cache as cold vs warm executor question counts; without credentials it prints the exact env vars to set and exits 2, fabricating nothing. Tests: 74, none of them skipping, none of them touching a server -- SQL shape per builder, row->dataclass parsing fed the documented column names, cold vs warm executor question counts on both backends, and CLI dispatch with a mocked backend. The round-trip matrix and the kind-identity guard are updated for the new expression classes. --- .../backend/impl/bigquery/__main__.py | 47 +- .../impl/bigquery/backend/async_backend.py | 30 +- .../backend/impl/bigquery/backend/backend.py | 28 +- .../backend/impl/bigquery/cli/__init__.py | 58 ++ .../backend/impl/bigquery/cli/connection.py | 120 +++ .../backend/impl/bigquery/cli/introspect.py | 324 ++++++ .../backend/impl/bigquery/cli/output.py | 52 + .../backend/impl/bigquery/dialect.py | 8 + .../impl/bigquery/examples/__init__.py | 8 + .../examples/introspection_datasets.py | 251 +++++ .../impl/bigquery/expression/__init__.py | 28 +- .../impl/bigquery/expression/introspection.py | 405 ++++++++ .../impl/bigquery/introspection/__init__.py | 31 + .../impl/bigquery/introspection/executor.py | 76 ++ .../bigquery/introspection/introspector.py | 967 ++++++++++++++++++ .../impl/bigquery/introspection/types.py | 143 +++ .../backend/impl/bigquery/mixins/__init__.py | 54 +- .../impl/bigquery/mixins/introspection.py | 837 +++++++++++++++ .../backend/impl/bigquery/mixins/namespace.py | 12 +- .../dialect/test_bigquery_kind_identity.py | 40 +- .../introspection/test_cli_introspect.py | 407 ++++++++ .../introspection/test_introspection_cache.py | 284 +++++ .../test_introspection_parsing.py | 582 +++++++++++ .../introspection/test_introspection_sql.py | 342 +++++++ 24 files changed, 5080 insertions(+), 54 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/bigquery/cli/__init__.py create mode 100644 src/rhosocial/activerecord/backend/impl/bigquery/cli/connection.py create mode 100644 src/rhosocial/activerecord/backend/impl/bigquery/cli/introspect.py create mode 100644 src/rhosocial/activerecord/backend/impl/bigquery/cli/output.py create mode 100644 src/rhosocial/activerecord/backend/impl/bigquery/examples/__init__.py create mode 100644 src/rhosocial/activerecord/backend/impl/bigquery/examples/introspection_datasets.py create mode 100644 src/rhosocial/activerecord/backend/impl/bigquery/expression/introspection.py create mode 100644 src/rhosocial/activerecord/backend/impl/bigquery/introspection/__init__.py create mode 100644 src/rhosocial/activerecord/backend/impl/bigquery/introspection/executor.py create mode 100644 src/rhosocial/activerecord/backend/impl/bigquery/introspection/introspector.py create mode 100644 src/rhosocial/activerecord/backend/impl/bigquery/introspection/types.py create mode 100644 src/rhosocial/activerecord/backend/impl/bigquery/mixins/introspection.py create mode 100644 tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_cli_introspect.py create mode 100644 tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_cache.py create mode 100644 tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_parsing.py create mode 100644 tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_sql.py diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/__main__.py b/src/rhosocial/activerecord/backend/impl/bigquery/__main__.py index a15f7fb..5a85b4e 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/__main__.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/__main__.py @@ -1,17 +1,56 @@ # src/rhosocial/activerecord/backend/impl/bigquery/__main__.py -"""BigQuery backend command-line interface.""" +"""BigQuery backend command-line interface. + +Parses ``rhosocial-activerecord-bigquery [options]`` and +dispatches to the handler :mod:`~.cli` resolved. The five introspection +commands are ``tables``, ``views``, ``table``, ``columns`` and +``datasets``; ``--help`` lists them with the environment variables each +connection flag reads. +""" import argparse +import sys def main(): """Main CLI entry point for the BigQuery backend.""" parser = argparse.ArgumentParser( - description="BigQuery backend for rhosocial-activerecord.", + prog="rhosocial-activerecord-bigquery", + description=( + "Introspect a BigQuery project: datasets, tables, views and " + "columns, from the INFORMATION_SCHEMA views." + ), formatter_class=argparse.RawTextHelpFormatter, + epilog=( + "Connection flags read the environment: BIGQUERY_PROJECT, " + "BIGQUERY_DATASET, BIGQUERY_LOCATION, BIGQUERY_CREDENTIALS_PATH " + "(or GOOGLE_APPLICATION_CREDENTIALS), BIGQUERY_CREDENTIALS_JSON, " + "BIGQUERY_API_ENDPOINT." + ), + ) + parser.add_argument( + "--version", + action="version", + version="rhosocial-activerecord-bigquery 1.0.0.dev1", ) - parser.parse_args() - print("BigQuery backend for rhosocial-activerecord") + + from .cli import COMMAND_NAMES, get_handler, register_commands + + subparsers = parser.add_subparsers(dest="command") + register_commands(subparsers) + + args = parser.parse_args() + + if args.command is None: + print( + f"Error: Please specify a command: {', '.join(COMMAND_NAMES)}", + file=sys.stderr, + ) + print("Use --help for more information.", file=sys.stderr) + sys.exit(1) + + handler = get_handler(args.command) + handler(args) if __name__ == "__main__": diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/backend/async_backend.py b/src/rhosocial/activerecord/backend/impl/bigquery/backend/async_backend.py index 312a98d..46e1e06 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/backend/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/backend/async_backend.py @@ -5,6 +5,9 @@ from rhosocial.activerecord.backend.errors import ConnectionError, DatabaseError from rhosocial.activerecord.backend.result import QueryResult from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.introspection.backend_mixin import ( + IntrospectorBackendMixin, +) from ..config import BigQueryConnectionConfig from ..dialect import BigQueryDialect @@ -12,8 +15,15 @@ from .qualified_names import BigQueryQualifiedNameMixin -class AsyncBigQueryBackend(BigQueryQualifiedNameMixin, AsyncStorageBackend): - """BigQuery native async backend.""" +class AsyncBigQueryBackend( + IntrospectorBackendMixin, BigQueryQualifiedNameMixin, AsyncStorageBackend +): + """BigQuery native async backend. + + ``backend.introspector`` answers metadata questions from BigQuery's + ``INFORMATION_SCHEMA`` views; see + :class:`~...introspection.AsyncBigQueryIntrospector`. + """ def __init__(self, **kwargs): version = kwargs.pop('version', None) or (3, 0, 0) @@ -99,6 +109,22 @@ def transaction_manager(self): def dialect(self) -> BigQueryDialect: return BigQueryDialect(version=self._version) + def _create_introspector(self) -> Any: + """Return the BigQuery introspector with its own executor (async). + + The executor is BigQuery's: the core one awaits a DB-API cursor, + which this backend does not have -- statements run through + ``backend.execute()`` and come back already dict-shaped. + """ + from ..introspection import ( + AsyncBigQueryIntrospector, + AsyncBigQueryIntrospectorExecutor, + ) + + return AsyncBigQueryIntrospector( + self, AsyncBigQueryIntrospectorExecutor(self) + ) + async def execute(self, sql: str, params: Optional[Tuple] = None, returning: Optional[Any] = None, column_adapters: Optional[Dict] = None, *, options: Optional[Any] = None) -> QueryResult: from rhosocial.activerecord.backend.schema import StatementType from .backend import BigQueryBackend diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/backend/backend.py b/src/rhosocial/activerecord/backend/impl/bigquery/backend/backend.py index 825abdb..eb5b40d 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/backend/backend.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/backend/backend.py @@ -8,6 +8,9 @@ ) from rhosocial.activerecord.backend.result import QueryResult from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.introspection.backend_mixin import ( + IntrospectorBackendMixin, +) from ..config import BigQueryConnectionConfig from ..dialect import BigQueryDialect @@ -15,8 +18,15 @@ from .qualified_names import BigQueryQualifiedNameMixin -class BigQueryBackend(BigQueryQualifiedNameMixin, StorageBackend): - """BigQuery-specific backend (synchronous).""" +class BigQueryBackend( + IntrospectorBackendMixin, BigQueryQualifiedNameMixin, StorageBackend +): + """BigQuery-specific backend (synchronous). + + ``backend.introspector`` answers metadata questions from BigQuery's + ``INFORMATION_SCHEMA`` views; see + :class:`~...introspection.SyncBigQueryIntrospector`. + """ def __init__(self, **kwargs): version = kwargs.pop('version', None) or (3, 0, 0) @@ -361,6 +371,20 @@ def transaction_manager(self): def dialect(self) -> BigQueryDialect: return BigQueryDialect(version=self._version) + def _create_introspector(self) -> Any: + """Return the BigQuery introspector with its own executor. + + The executor is BigQuery's: the core one drives a DB-API cursor, + which this backend does not have -- statements run through + ``backend.execute()`` and come back already dict-shaped. + """ + from ..introspection import ( + SyncBigQueryIntrospector, + SyncBigQueryIntrospectorExecutor, + ) + + return SyncBigQueryIntrospector(self, SyncBigQueryIntrospectorExecutor(self)) + # -- Emulator REST fast path --------------------------------------------------- @staticmethod diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/cli/__init__.py b/src/rhosocial/activerecord/backend/impl/bigquery/cli/__init__.py new file mode 100644 index 0000000..e099d3c --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/bigquery/cli/__init__.py @@ -0,0 +1,58 @@ +# src/rhosocial/activerecord/backend/impl/bigquery/cli/__init__.py +"""BigQuery CLI: subcommand registration and handler lookup. + +The package's shape follows the other backends' small CLIs: ``__main__.py`` +parses, this module registers and routes, and each subcommand module owns +its parser and its handler. The five introspection subcommands all live in +:mod:`~.introspect`, so this module routes their names there directly. +""" + +#: The subcommands the package defines. ``__main__`` names them when a run +#: specifies none. +COMMAND_NAMES = [ + "tables", + "views", + "table", + "columns", + "datasets", +] + + +def register_commands(subparsers): + """Register all subcommands. + + Args: + subparsers: The parser's subcommand group. + """ + from .introspect import create_parser as introspect_parser + + introspect_parser(subparsers) + + +def get_handler(command_name: str): + """Return the handler function for a subcommand. + + Args: + command_name: The subcommand as spelled on the command line. + + Returns: + The ``handle(args)`` entry point for that command. + + Raises: + SystemExit: The name is not a command this package defines. + """ + import sys + + from .introspect import get_handler as introspect_handler + + if command_name not in COMMAND_NAMES: + print( + f"Error: unknown command {command_name!r}: " + f"expected one of {', '.join(COMMAND_NAMES)}", + file=sys.stderr, + ) + sys.exit(1) + return introspect_handler(command_name) + + +__all__ = ["COMMAND_NAMES", "register_commands", "get_handler"] diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/cli/connection.py b/src/rhosocial/activerecord/backend/impl/bigquery/cli/connection.py new file mode 100644 index 0000000..c3ea4a5 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/bigquery/cli/connection.py @@ -0,0 +1,120 @@ +# src/rhosocial/activerecord/backend/impl/bigquery/cli/connection.py +"""Connection argument parsing and backend creation for the BigQuery CLI. + +Every flag defaults to the environment variable of the same name, and each +one maps one-to-one onto a :class:`BigQueryConnectionConfig` field: that is +what "the same variables the config uses" means here, since the config +itself reads no environment -- a caller (this CLI, the example script) +supplies what it read. + +The two Google variables are read as fallbacks because +``google-cloud-bigquery`` honours them natively: ``GOOGLE_APPLICATION_CREDENTIALS`` +is where Application Default Credentials look for a service-account key, +and ``GOOGLE_CLOUD_PROJECT`` is the project it falls back to when the client +is built without one. + +No flag and no variable is required: an example run with nothing set is how +a caller learns which variable they still have to set. +""" + +import os + +#: Environment variable names, one per connection argument. Kept beside the +#: arguments so the two cannot drift apart. +ENV_PROJECT = "BIGQUERY_PROJECT" +ENV_PROJECT_FALLBACK = "GOOGLE_CLOUD_PROJECT" +ENV_DATASET = "BIGQUERY_DATASET" +ENV_LOCATION = "BIGQUERY_LOCATION" +ENV_CREDENTIALS_PATH = "BIGQUERY_CREDENTIALS_PATH" +ENV_CREDENTIALS_PATH_FALLBACK = "GOOGLE_APPLICATION_CREDENTIALS" +ENV_CREDENTIALS_JSON = "BIGQUERY_CREDENTIALS_JSON" +ENV_API_ENDPOINT = "BIGQUERY_API_ENDPOINT" + + +def add_connection_args(parser): + """Add the BigQuery connection arguments to a subcommand parser. + + Args: + parser: The ``argparse`` parser the arguments are added to. + """ + parser.add_argument( + "--project", + default=os.getenv(ENV_PROJECT) or os.getenv(ENV_PROJECT_FALLBACK), + help=("Google Cloud project ID (env: BIGQUERY_PROJECT, or " "GOOGLE_CLOUD_PROJECT)"), + ) + parser.add_argument( + "--dataset", + default=os.getenv(ENV_DATASET), + help=( + "Default dataset ID (env: BIGQUERY_DATASET). The dataset is " + "BigQuery's schema level, so it is what the schema argument " + "defaults to." + ), + ) + parser.add_argument( + "--location", + default=os.getenv(ENV_LOCATION), + help="Dataset location, e.g. US or eu (env: BIGQUERY_LOCATION)", + ) + parser.add_argument( + "--credentials-path", + default=(os.getenv(ENV_CREDENTIALS_PATH) or os.getenv(ENV_CREDENTIALS_PATH_FALLBACK)), + help=( + "Path to a service-account JSON key file (env: " + "BIGQUERY_CREDENTIALS_PATH, or GOOGLE_APPLICATION_CREDENTIALS)" + ), + ) + parser.add_argument( + "--credentials-json", + default=os.getenv(ENV_CREDENTIALS_JSON), + help=( + "Service-account key as a JSON string (env: " + "BIGQUERY_CREDENTIALS_JSON). Ignored when --credentials-path " + "is given." + ), + ) + parser.add_argument( + "--api-endpoint", + default=os.getenv(ENV_API_ENDPOINT), + help=( + "BigQuery API endpoint override, e.g. http://localhost:9050 " + "for the emulator (env: BIGQUERY_API_ENDPOINT)" + ), + ) + parser.add_argument( + "--use-async", + action="store_true", + help="Use the asynchronous backend and introspector", + ) + + +def resolve_connection_config_from_args(args): + """Build a :class:`BigQueryConnectionConfig` from parsed args. + + Args: + args: The parsed namespace :func:`add_connection_args` filled. + + Returns: + The connection config the backend is built from. + """ + import json + + from rhosocial.activerecord.backend.impl.bigquery.config import ( + BigQueryConnectionConfig, + ) + + credentials_json = None + if args.credentials_json: + try: + credentials_json = json.loads(args.credentials_json) + except json.JSONDecodeError as exc: + raise SystemExit(f"Error: --credentials-json is not valid JSON: {exc}") from exc + + return BigQueryConnectionConfig( + project=args.project, + dataset=args.dataset, + location=args.location, + credentials_path=args.credentials_path, + credentials_json=credentials_json, + api_endpoint=args.api_endpoint, + ) diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/cli/introspect.py b/src/rhosocial/activerecord/backend/impl/bigquery/cli/introspect.py new file mode 100644 index 0000000..6bc81ac --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/bigquery/cli/introspect.py @@ -0,0 +1,324 @@ +# src/rhosocial/activerecord/backend/impl/bigquery/cli/introspect.py +"""The introspection subcommands: ``tables``, ``views``, ``table``, +``columns`` and ``datasets``. + +Each one wires ``backend.introspector`` to an output format: + +=============================== ====================================== +Command Introspector question +=============================== ====================================== +``tables`` ``list_tables()`` +``views`` ``list_views()`` +``table NAME`` ``get_table_info()`` -- columns and + primary key of one table +``columns NAME`` ``list_columns()`` +``datasets`` ``list_datasets()`` (the SCHEMATA view) +=============================== ====================================== + +``--dataset`` selects the dataset every question is scoped to, and the +schema argument each question receives is BigQuery's **dataset** -- the two +are the same level, which is why one flag answers both. + +No command fabricates an answer. Without a dataset the introspector raises +with the exact instruction, and without credentials the client refuses to +connect; both exit non-zero with the reason on stderr. +""" + +import argparse +import asyncio +import sys +from dataclasses import asdict, is_dataclass +from enum import Enum +from typing import Any, Callable, Dict + +from rhosocial.activerecord.backend.errors import ConnectionError, QueryError + +from .connection import ( + add_connection_args, + resolve_connection_config_from_args, +) +from .output import create_provider + +OUTPUT_CHOICES = ["table", "json", "csv", "tsv"] + +#: The subcommands this module owns. ``cli.get_handler`` routes each name +#: here; nothing else registers one of them. +INTROSPECT_SUBCOMMANDS = ("tables", "views", "table", "columns", "datasets") + +#: The two subcommands that act on one named table, so they carry the +#: positional name the other three do not. +_NAME_COMMANDS = ("table", "columns") + +_EPILOG = """Examples: + # Every dataset in the project + %(prog)s datasets --project my-project + + # Tables and views in one dataset + %(prog)s tables --project my-project --dataset my_dataset + %(prog)s views --project my-project --dataset my_dataset + + # One table's columns and primary key + %(prog)s table orders --project my-project --dataset my_dataset + %(prog)s columns orders --project my-project --dataset my_dataset + + # JSON output, and the environment-variable form + %(prog)s tables -o json + export BIGQUERY_PROJECT=my-project BIGQUERY_DATASET=my_dataset + export BIGQUERY_CREDENTIALS_PATH=/path/to/key.json + %(prog)s tables +""" + + +def create_parser(subparsers): + """Register every introspection subcommand. + + Args: + subparsers: The parser's subcommand group. + + Returns: + None; the subcommands are registered into *subparsers*. + """ + for name in INTROSPECT_SUBCOMMANDS: + parser = subparsers.add_parser( + name, + help=_HELP[name], + epilog=_EPILOG, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + if name in _NAME_COMMANDS: + # Only these two act on one named table; the others list + # everything and take no positional. + parser.add_argument( + "name", + help=_POSITIONAL_HELP[name], + ) + parser.add_argument( + "-o", + "--output", + choices=OUTPUT_CHOICES, + default="table", + help="Output format (default: table)", + ) + parser.add_argument( + "--schema", + dest="schema", + default=None, + help=( + "Dataset to introspect (default: --dataset / the " + "connection default). A dataset ID, or project.dataset." + ), + ) + parser.add_argument( + "--include-system", + action="store_true", + help=("Include system tables (ignored: BigQuery's INFORMATION_SCHEMA " "lists none)"), + ) + parser.add_argument( + "--rich-ascii", + action="store_true", + help="Use ASCII characters for rich table borders.", + ) + add_connection_args(parser) + + +_HELP = { + "tables": "List the tables and views in a dataset", + "views": "List the views in a dataset", + "table": "Show one table's columns and primary key", + "columns": "List one table's columns", + "datasets": "List the project's datasets", +} + +_POSITIONAL_HELP = { + "table": "Table name (required)", + "columns": "Table name (required)", +} + + +def get_handler(command_name: str) -> Callable[[argparse.Namespace], None]: + """Return :func:`handle` for one introspection subcommand name. + + Args: + command_name: One of :data:`INTROSPECT_SUBCOMMANDS`. + + Returns: + The ``handle`` entry point. + + Raises: + KeyError: *command_name* is not an introspection subcommand. + """ + if command_name not in INTROSPECT_SUBCOMMANDS: + raise KeyError(f"{command_name!r} is not one of {list(INTROSPECT_SUBCOMMANDS)}") + return handle + + +def handle(args): + """Handle one introspection subcommand. + + The backend is built from the parsed arguments and handed to the sync or + the async runner, which runs the question and prints the result. + """ + provider = create_provider(args.output, ascii_borders=args.rich_ascii) + backend = _build_backend(args) + + if args.use_async: + asyncio.run(_run_async(args, backend, provider)) + else: + _run_sync(args, backend, provider) + + +def _build_backend(args): + """The backend class and config this run talks to.""" + from rhosocial.activerecord.backend.impl.bigquery.backend.async_backend import ( + AsyncBigQueryBackend, + ) + from rhosocial.activerecord.backend.impl.bigquery.backend.backend import ( + BigQueryBackend, + ) + + config = resolve_connection_config_from_args(args) + backend_class = AsyncBigQueryBackend if args.use_async else BigQueryBackend + return backend_class(connection_config=config) + + +def _serialize_for_output(obj: Any) -> Any: + """Make an introspection result JSON/CSV-serialisable. + + Dataclasses become dicts, enums their wire word, and anything left over + its ``str()`` -- a representation the reader can see rather than a + ``TypeError`` naming the type. + """ + if obj is None: + return None + if is_dataclass(obj) and not isinstance(obj, type): + return {k: _serialize_for_output(v) for k, v in asdict(obj).items()} + if isinstance(obj, Enum): + return obj.value + if isinstance(obj, dict): + return {k: _serialize_for_output(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple)): + return [_serialize_for_output(item) for item in obj] + if isinstance(obj, (str, int, float, bool)): + return obj + return str(obj) + + +def _one(name: str, args) -> str: + """The positional name of a command that requires one, or an exit.""" + if not args.name: + print( + f"Error: a table name is required for the '{name}' command", + file=sys.stderr, + ) + sys.exit(1) + return args.name + + +def _schema_arg(args): + """The dataset a question is scoped to: ``--schema`` over ``--dataset``.""" + return args.schema if args.schema is not None else args.dataset + + +# --------------------------------------------------------------------------- +# The five questions, as data: each sync handler and its async twin +# --------------------------------------------------------------------------- + + +def _questions(args) -> Dict[str, Callable[[Any], Any]]: + """Map each subcommand onto the introspector call it runs (sync).""" + schema = _schema_arg(args) + return { + "tables": lambda introspector: introspector.list_tables( + schema=schema, include_system=args.include_system + ), + "views": lambda introspector: introspector.list_views(schema=schema), + "table": lambda introspector: introspector.get_table_info( + _one("table", args), schema=schema + ), + "columns": lambda introspector: introspector.list_columns( + _one("columns", args), schema=schema + ), + "datasets": lambda introspector: introspector.list_datasets(), + } + + +def _async_questions(args) -> Dict[str, Callable[[Any], Any]]: + """The same mapping, for the async introspector. + + The calls are literally the same: an async introspector's method + returns the awaitable the runner awaits, so the mapping is built once + in :func:`_questions` and read here unchanged. + """ + return _questions(args) + + +def _report(result: Any, args, provider) -> None: + """Print one question's result. + + ``table`` answers a whole object, so its columns and primary key are + two tables under their own titles -- the shape the question has. A + missing table is an exit, not an empty table. + """ + if args.command == "table": + if result is None: + print(f"Error: table '{args.name}' was not found", file=sys.stderr) + sys.exit(1) + provider.display_results( + _serialize_for_output(result.columns), + title=f"Columns of {args.name}", + ) + if result.indexes: + provider.display_results( + _serialize_for_output(result.indexes), + title=f"Primary key of {args.name}", + ) + return + title = "Datasets" if args.command == "datasets" else args.command.title() + provider.display_results(_serialize_for_output(list(result)), title=title) + + +def _run_sync(args, backend, provider) -> None: + """Run the question synchronously and report it.""" + try: + backend.connect() + _report(_questions(args)[args.command](backend.introspector), args, provider) + except ConnectionError as exc: + print(f"Connection error: {exc}", file=sys.stderr) + sys.exit(1) + except QueryError as exc: + print(f"Query error: {exc}", file=sys.stderr) + sys.exit(1) + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + finally: + if getattr(backend, "_client", None): + backend.disconnect() + + +async def _run_async(args, backend, provider) -> None: + """Run the question asynchronously and report it.""" + try: + await backend.connect() + call = _async_questions(args)[args.command] + _report(await call(backend.introspector), args, provider) + except ConnectionError as exc: + print(f"Connection error: {exc}", file=sys.stderr) + sys.exit(1) + except QueryError as exc: + print(f"Query error: {exc}", file=sys.stderr) + sys.exit(1) + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + finally: + if getattr(backend, "_client", None): + await backend.disconnect() + + +__all__ = [ + "INTROSPECT_SUBCOMMANDS", + "create_parser", + "get_handler", + "handle", +] diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/cli/output.py b/src/rhosocial/activerecord/backend/impl/bigquery/cli/output.py new file mode 100644 index 0000000..b563f95 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/bigquery/cli/output.py @@ -0,0 +1,52 @@ +# src/rhosocial/activerecord/backend/impl/bigquery/cli/output.py +"""Output provider factory and display helpers. + +Four formats, one factory: ``table`` renders through ``rich`` when it is +installed and falls back to ``json`` when it is not, because the table +renderer is an optional dependency and a missing one must degrade rather +than refuse the run. +""" + +from rhosocial.activerecord.backend.output import ( + CsvOutputProvider, + JsonOutputProvider, + TsvOutputProvider, +) + +try: + from rhosocial.activerecord.backend.output_rich import RichOutputProvider + + RICH_AVAILABLE = True +except ImportError: # pragma: no cover -- exercised only without rich + RICH_AVAILABLE = False + RichOutputProvider = None # type: ignore[misc,assignment] + + +def create_provider(output_format: str, ascii_borders: bool = False): + """Return the output provider for one format. + + Args: + output_format: One of ``table``, ``json``, ``csv``, ``tsv``. + ascii_borders: Whether a rich table draws ASCII borders. + + Returns: + The provider, or a JSON provider for an unknown format. + """ + if output_format == "table" and not RICH_AVAILABLE: + output_format = "json" + + if output_format == "table" and RICH_AVAILABLE: + from rich.console import Console + + return RichOutputProvider(console=Console(), ascii_borders=ascii_borders) + if output_format == "json": + return JsonOutputProvider() + if output_format == "csv": + return CsvOutputProvider() + if output_format == "tsv": + return TsvOutputProvider() + + return JsonOutputProvider() + + +__all__ = ["create_provider", "RICH_AVAILABLE"] diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/dialect.py b/src/rhosocial/activerecord/backend/impl/bigquery/dialect.py index 7dc18d5..614e876 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/dialect.py @@ -85,6 +85,10 @@ BigQueryIdentityColumnMixin, BigQueryIdentifierMixin, BigQueryMaterializedViewMixin, + # Introspection: the capability probes and the format_* entries that + # render the BigQuery introspection statements. It subclasses core's + # IntrospectionMixin, so C3 requires it ahead of it in the base list. + BigQueryIntrospectionMixin, # Transaction formatting owns the refusals for the lock-wait pair # (WAIT / NO WAIT) that BigQuery's grammar does not have; core's generic # renderer would drop them silently. Before TransactionControlMixin. @@ -159,6 +163,10 @@ class BigQueryDialect( ArrayMixin, ExplainMixin, MergeMixin, UpsertMixin, LateralJoinMixin, JoinMixin, ViewMixin, SchemaMixin, IndexMixin, TableMixin, ConstraintMixin, + # BigQuery's introspection answers ahead of the core mixin whose + # format_* entries (and whose probes) they replace: a mixin placed after + # the class it overrides never wins the lookup. + BigQueryIntrospectionMixin, IntrospectionMixin, TruncateMixin, PredicateMixin, ExpressionMixin, DQLMixin, DMLMixin, SetOperationMixin, DateTimeMixin, diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/examples/__init__.py b/src/rhosocial/activerecord/backend/impl/bigquery/examples/__init__.py new file mode 100644 index 0000000..73e0f90 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/bigquery/examples/__init__.py @@ -0,0 +1,8 @@ +# src/rhosocial/activerecord/backend/impl/bigquery/examples/__init__.py +"""Runnable BigQuery examples. + +Each file here is a program, not a test: run it as +``python -m rhosocial.activerecord.backend.impl.bigquery.examples.`` +from a machine with credentials. Nothing imports pytest, and no example +pretends to have reached a server it could not reach. +""" diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/examples/introspection_datasets.py b/src/rhosocial/activerecord/backend/impl/bigquery/examples/introspection_datasets.py new file mode 100644 index 0000000..6badf11 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/bigquery/examples/introspection_datasets.py @@ -0,0 +1,251 @@ +# src/rhosocial/activerecord/backend/impl/bigquery/examples/introspection_datasets.py +"""Print a project's datasets, their tables and one table's columns, then +show the introspection cache at work. + +Run it:: + + python -m rhosocial.activerecord.backend.impl.bigquery.examples.introspection_datasets + +Configuration comes from the environment -- the same variables the CLI +reads, mapping one-to-one onto ``BigQueryConnectionConfig``: + +=============================== ========================================= +Variable Config field +=============================== ========================================= +``BIGQUERY_PROJECT`` ``project`` (falls back to the Google + client's own ``GOOGLE_CLOUD_PROJECT``) +``BIGQUERY_DATASET`` ``dataset`` (**required**: the questions are + dataset-scoped) +``BIGQUERY_LOCATION`` ``location`` +``BIGQUERY_CREDENTIALS_PATH`` ``credentials_path`` (falls back to Google's + ``GOOGLE_APPLICATION_CREDENTIALS``) +``BIGQUERY_CREDENTIALS_JSON`` ``credentials_json`` +``BIGQUERY_API_ENDPOINT`` ``api_endpoint`` (point it at the emulator) +=============================== ========================================= + +``BIGQUERY_DATASET`` plus one credentials source (a path or inline JSON) +plus a project -- or ``GOOGLE_APPLICATION_CREDENTIALS`` for the +credentials -- are what a run needs. With them missing the script prints +exactly those variables and exits 2; it never connects to nothing and +never fabricates a row. + +What the cache demonstration shows: the introspector answers each question +once and keeps the answer, so the *cold* pass -- first time each question +is asked -- issues a query per question, and the *warm* pass -- the same +questions again -- issues none. The executor counts its questions; the +numbers below are that count. +""" + +import os +import sys +from typing import Any, Dict, List, Tuple + +REQUIRED_DATASET_ENV = "BIGQUERY_DATASET" + +#: The variables a caller can set, in the order the script checks them. +#: Printed verbatim when one is missing -- a caller should not have to +#: guess which name to export. +ENV_VARS = ( + "BIGQUERY_PROJECT", + "BIGQUERY_CREDENTIALS_PATH", + "BIGQUERY_DATASET", + "BIGQUERY_LOCATION", + "BIGQUERY_CREDENTIALS_JSON", + "BIGQUERY_API_ENDPOINT", +) + +_ENV_HELP = """\ +No dataset to introspect. Set the environment variables this example reads: + + BIGQUERY_PROJECT the Google Cloud project, e.g. my-project + (falls back to GOOGLE_CLOUD_PROJECT) + BIGQUERY_CREDENTIALS_PATH a service-account JSON key file, e.g. + /home/me/key.json -- or GOOGLE_APPLICATION_CREDENTIALS + BIGQUERY_DATASET the dataset to introspect, e.g. my_dataset + +Optional: + + BIGQUERY_LOCATION dataset location, e.g. US or eu + BIGQUERY_CREDENTIALS_JSON the key file's contents as JSON + BIGQUERY_API_ENDPOINT a BigQuery endpoint override, e.g. + http://localhost:9050 for the emulator + +Example: + + export BIGQUERY_PROJECT=my-project + export BIGQUERY_DATASET=my_dataset + export BIGQUERY_CREDENTIALS_PATH=$HOME/bigquery-key.json + python -m rhosocial.activerecord.backend.impl.bigquery.examples.introspection_datasets +""" + + +def _env(name: str) -> Any: + value = os.getenv(name) + return value.strip() if value else None + + +def _resolve_credentials() -> Tuple[Any, Any]: + """The credentials source the config fields carry, from the environment. + + Returns: + ``(path, parsed_json)``: exactly one is not ``None`` when a source + is configured, and neither is when there is none. + """ + path = _env("BIGQUERY_CREDENTIALS_PATH") or _env("GOOGLE_APPLICATION_CREDENTIALS") + raw_json = _env("BIGQUERY_CREDENTIALS_JSON") + if raw_json: + import json + + try: + return None, json.loads(raw_json) + except json.JSONDecodeError as exc: + raise SystemExit(f"BIGQUERY_CREDENTIALS_JSON is not valid JSON: {exc}") from exc + return path, None + + +class CountingExecutor: + """The introspector's executor, counting the questions it is asked. + + A delegating wrapper rather than a subclass, because what this example + demonstrates is a property of the *executor* boundary: the introspector + asks once per question when nothing is cached, and never again + afterwards. Counting at that boundary is the measurement. + """ + + def __init__(self, inner) -> None: + self._inner = inner + self.questions: List[Tuple[str, tuple]] = [] + + def execute(self, sql: str, params: Tuple = ()) -> List[Dict[str, Any]]: + """Run one question through the wrapped executor, counting it.""" + self.questions.append((sql, params)) + return self._inner.execute(sql, params) + + +def _print_rows(rows: List[Any], columns: List[str]) -> None: + """Print dict rows as aligned text, with no third-party dependency.""" + if not rows: + print(" (none)") + return + widths = [ + max(len(column), *(len(str(row.get(column, ""))) for row in rows)) for column in columns + ] + header = " ".join(column.ljust(widths[i]) for i, column in enumerate(columns)) + print(f" {header}") + print(f" {' '.join('-' * width for width in widths)}") + for row in rows: + cells = [str(row.get(column, "")).ljust(widths[i]) for i, column in enumerate(columns)] + print(f" {' '.join(cells)}") + + +def main() -> int: + """Run the introspection walk; return the process exit code.""" + dataset = _env(REQUIRED_DATASET_ENV) + project = _env("BIGQUERY_PROJECT") or _env("GOOGLE_CLOUD_PROJECT") + credentials_path, credentials_json = _resolve_credentials() + location = _env("BIGQUERY_LOCATION") + api_endpoint = _env("BIGQUERY_API_ENDPOINT") + + if not dataset or (not project and not credentials_path and not credentials_json): + # Exactly which variable to set, and nothing invented. Exits 2, the + # conventional "misconfigured" status. + print(_ENV_HELP, end="", file=sys.stderr) + return 2 + + from rhosocial.activerecord.backend.impl.bigquery.backend.backend import ( + BigQueryBackend, + ) + from rhosocial.activerecord.backend.impl.bigquery.config import ( + BigQueryConnectionConfig, + ) + from rhosocial.activerecord.backend.impl.bigquery.introspection import ( + SyncBigQueryIntrospector, + SyncBigQueryIntrospectorExecutor, + ) + + backend = BigQueryBackend( + connection_config=BigQueryConnectionConfig( + project=project, + dataset=dataset, + location=location, + credentials_path=credentials_path, + credentials_json=credentials_json, + api_endpoint=api_endpoint, + ) + ) + + # The introspector with the counting executor, set on the backend so + # every question this run asks is measured. + counting = CountingExecutor(SyncBigQueryIntrospectorExecutor(backend)) + backend.set_introspector(SyncBigQueryIntrospector(backend, counting)) + executor = counting + + print( + "BigQuery introspection for project " + f"{project or '(from credentials)'}, dataset {dataset}" + ) + print("SQL for each question comes from the BigQuery dialect's format_* " "entries;") + print("the executor counts the questions asked.\n") + + backend.connect() + try: + # -- Cold pass: each question is asked once ---------------------- # + print("== Cold pass: every question issues one query ==") + datasets = backend.introspector.list_datasets() + _print_rows( + [ + { + "dataset": d.schema_name, + "project": d.catalog_name, + "location": d.location, + } + for d in datasets + ], + ["dataset", "project", "location"], + ) + + tables = backend.introspector.list_tables(dataset) + _print_rows( + [{"table": t.name, "type": t.table_type.value} for t in tables], + ["table", "type"], + ) + + columns = backend.introspector.list_columns(tables[0].name, dataset) if tables else [] + _print_rows( + [ + { + "column": c.name, + "type": c.data_type, + "nullable": c.nullable.value, + "position": c.ordinal_position, + } + for c in columns + ], + ["column", "type", "nullable", "position"], + ) + cold = len(executor.questions) + print(f"\ncold pass: {cold} question(s) asked") + + # -- Warm pass: the same questions, all cached ------------------- # + print("\n== Warm pass: the same questions, answered from the cache ==") + backend.introspector.list_datasets() + backend.introspector.list_tables(dataset) + if tables: + backend.introspector.list_columns(tables[0].name, dataset) + storage = backend.introspector.get_table_storage_info(tables[0].name, dataset) + print( + f"storage of {storage.table_name}: " + f"{len(storage.partitions)} partition row(s), " + f"partitioning={storage.partitioning_columns}, " + f"clustering={storage.clustering_columns}" + ) + warm = len(executor.questions) - cold + print(f"warm pass: {warm} question(s) asked (the answers were cached)") + finally: + if getattr(backend, "_client", None): + backend.disconnect() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/bigquery/expression/__init__.py index 54098b3..e4276c6 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/expression/__init__.py @@ -1,10 +1,21 @@ # src/rhosocial/activerecord/backend/impl/bigquery/expression/__init__.py -"""BigQuery-specific DDL expressions.""" +"""BigQuery-specific DDL and introspection expressions.""" +from .introspection import ( + BigQueryColumnInfoQueryStatement, + BigQueryDatabaseInfoQueryStatement, + BigQueryDatasetListQueryStatement, + BigQueryIndexInfoQueryStatement, + BigQueryPartitionListQueryStatement, + BigQueryTableListQueryStatement, + BigQueryTableOptionsQueryStatement, + BigQueryViewInfoQueryStatement, + BigQueryViewListQueryStatement, +) from .materialized_view import ( BigQueryAlterMaterializedViewSetOptionsStatement, - BigQueryCreateMaterializedViewStatement, BigQueryCreateMaterializedViewReplicaStatement, + BigQueryCreateMaterializedViewStatement, BigQueryDropMaterializedViewStatement, ) @@ -13,6 +24,15 @@ "BigQueryCreateMaterializedViewStatement", "BigQueryCreateMaterializedViewReplicaStatement", "BigQueryDropMaterializedViewStatement", + "BigQueryDatabaseInfoQueryStatement", + "BigQueryTableListQueryStatement", + "BigQueryColumnInfoQueryStatement", + "BigQueryIndexInfoQueryStatement", + "BigQueryViewListQueryStatement", + "BigQueryViewInfoQueryStatement", + "BigQueryDatasetListQueryStatement", + "BigQueryTableOptionsQueryStatement", + "BigQueryPartitionListQueryStatement", ] # ``BigQuery`` is one word. The general derivation -- a run of capitals counts as @@ -30,9 +50,7 @@ import importlib as _importlib import pkgutil as _pkgutil -from rhosocial.activerecord.backend.expression.core import ( - BaseExpression as _BaseExpression, -) +from rhosocial.activerecord.backend.expression.core import BaseExpression as _BaseExpression for _module_info in _pkgutil.walk_packages( __path__, prefix=__name__ + ".", onerror=lambda _error: None diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/expression/introspection.py b/src/rhosocial/activerecord/backend/impl/bigquery/expression/introspection.py new file mode 100644 index 0000000..a34e464 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/bigquery/expression/introspection.py @@ -0,0 +1,405 @@ +# src/rhosocial/activerecord/backend/impl/bigquery/expression/introspection.py +"""BigQuery introspection statements, as expression nodes. + +BigQuery answers metadata questions from its ``INFORMATION_SCHEMA`` views +rather than from a DB-API catalog, so the questions are ordinary ``SELECT`` +statements over views the project owns. Each class below is one question: it +collects the parameters (which dataset, which project, which table) and +declares the dialect entry that renders it. The dialect mixin +(:class:`~...mixins.introspection.BigQueryIntrospectionMixin`) owns the +spelling -- the backtick quoting, the ``project.dataset`` qualifier, the +bind markers -- and assembles the statement from core expression nodes +(:class:`~rhosocial.activerecord.backend.expression.core.Column`, +:class:`~rhosocial.activerecord.backend.expression.predicates.InPredicate`, +:class:`~rhosocial.activerecord.backend.expression.predicates.LogicalPredicate`, +:class:`~rhosocial.activerecord.backend.expression.query_parts.OrderByClause` +...). Nothing in this module writes SQL text. + +**Dataset and schema are the same level here.** BigQuery names relations +``project.dataset.table`` and has no schema under a dataset, so: + +* ``schema`` carries the **dataset** (or a ``project.dataset`` pair, when the + project is not the connection's default); +* ``catalog`` carries the **project** to qualify the dataset with, or + ``None`` for "the connection's default project", which the docs spell as + ``dataset.INFORMATION_SCHEMA.view``. + +The documented spellings, all read 2026-10-24 (UTC): + +* ``mydataset.INFORMATION_SCHEMA.TABLES`` -- dataset qualifier only +* ``myproject.mydataset.INFORMATION_SCHEMA.TABLES`` -- project and dataset +* ``myproject.\\`region-us\\`.INFORMATION_SCHEMA.TABLES`` -- region qualifier, + which only ``SCHEMATA`` accepts +* ``\\`project_id\\`.dataset.INFORMATION_SCHEMA.view`` -- the escape form for a + project ID that needs quoting, for example + ``\\`bigquery-public-data\\`.census_bureau_usa.INFORMATION_SCHEMA.TABLES`` + +https://cloud.google.com/bigquery/docs/information-schema-intro +https://cloud.google.com/bigquery/docs/information-schema-tables + +Which view answers which question: + +====================== ========================================== +Question View +====================== ========================================== +database info ``@@project_id`` (documented system variable) +tables ``INFORMATION_SCHEMA.TABLES`` +columns ``INFORMATION_SCHEMA.COLUMNS`` +primary keys ``INFORMATION_SCHEMA.TABLE_CONSTRAINTS`` + joined to ``KEY_COLUMN_USAGE`` +views ``INFORMATION_SCHEMA.VIEWS`` +datasets ``INFORMATION_SCHEMA.SCHEMATA`` +table options ``INFORMATION_SCHEMA.TABLE_OPTIONS`` +partitions ``INFORMATION_SCHEMA.PARTITIONS`` +====================== ========================================== + +No BigQuery instance was available while writing this module. The statement +shapes follow the pages cited beside each class below. +""" + +from typing import TYPE_CHECKING, Optional + +from rhosocial.activerecord.backend.expression.introspection import ( + IntrospectionExpression, +) + +if TYPE_CHECKING: # pragma: no cover + from ..dialect import BigQueryDialect + + +__all__ = [ + "BigQueryDatabaseInfoQueryStatement", + "BigQueryTableListQueryStatement", + "BigQueryColumnInfoQueryStatement", + "BigQueryIndexInfoQueryStatement", + "BigQueryViewListQueryStatement", + "BigQueryViewInfoQueryStatement", + "BigQueryDatasetListQueryStatement", + "BigQueryTableOptionsQueryStatement", + "BigQueryPartitionListQueryStatement", +] + + +class BigQueryDatabaseInfoQueryStatement(IntrospectionExpression): + """``SELECT @@project_id AS catalog_name``. + + The only database fact BigQuery answers over SQL is which project the + query runs in: ``@@project_id`` is a documented read-only system + variable, and there is no server version to read. The backend's own + feature level is reported separately by the introspector, which parses + the row. + + https://cloud.google.com/bigquery/docs/reference/system-variables + """ + + def __init__(self, dialect: "BigQueryDialect", schema: Optional[str] = None): + """Initialize the statement. + + Args: + dialect: The BigQuery dialect that renders this statement. + schema: Unused -- the question is project-level. Present so the + class matches the shape of the other introspection + statements and a caller can pass one harmlessly. + """ + super().__init__(dialect, schema) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_bigquery_database_info_query_statement" + + +class BigQueryTableListQueryStatement(IntrospectionExpression): + """The ``INFORMATION_SCHEMA.TABLES`` question, parameterised. + + Args: + dialect: The BigQuery dialect that renders this statement. + schema: The dataset, or a ``project.dataset`` pair. Required: every + ``TABLES`` query carries a dataset qualifier (the region + qualifier is not accepted for this view). + catalog: The project to qualify the dataset with, or ``None`` for + the connection's default project. + include_views: Whether views and materialized views are returned. + Documented ``table_type`` spellings are ``BASE TABLE``, + ``CLONE``, ``SNAPSHOT``, ``VIEW``, ``MATERIALIZED VIEW`` and + ``EXTERNAL``. + include_system: Ignored. BigQuery's ``TABLES`` view does not list + system tables, so there is nothing for the flag to include; it + is accepted so the core signature keeps working. + table_type: One core ``TableType`` spelling (``BASE_TABLE``, + ``VIEW``, ``EXTERNAL`` ...), or one catalog spelling. The + dialect expands the core spellings onto the catalog ones. + + https://cloud.google.com/bigquery/docs/information-schema-tables + """ + + def __init__( + self, + dialect: "BigQueryDialect", + schema: str, + catalog: Optional[str] = None, + include_views: bool = True, + include_system: bool = False, + table_type: Optional[str] = None, + ): + """Initialize the table list statement.""" + super().__init__(dialect, schema) + self.catalog = catalog + self.include_views = include_views + self.include_system = include_system + self.table_type = table_type + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_bigquery_table_list_query_statement" + + +class BigQueryColumnInfoQueryStatement(IntrospectionExpression): + """The ``INFORMATION_SCHEMA.COLUMNS`` question for one table. + + Pseudo columns (``_PARTITIONTIME``, ``_PARTITIONDATE``) are answered by + the same view with ``is_hidden = 'YES'`` and a ``NULL`` + ``ordinal_position``. They are dropped by the parser rather than by a + ``WHERE`` filter, because they are not part of the declared schema and + the parser is where a row becomes an object. + + Args: + dialect: The BigQuery dialect that renders this statement. + table_name: The table whose columns are wanted. + schema: The dataset, or a ``project.dataset`` pair. Required. + catalog: The project to qualify the dataset with, or ``None``. + + https://cloud.google.com/bigquery/docs/information-schema-columns + """ + + def __init__( + self, + dialect: "BigQueryDialect", + table_name: str, + schema: str, + catalog: Optional[str] = None, + ): + """Initialize the column list statement.""" + super().__init__(dialect, schema) + self.table_name = table_name + self.catalog = catalog + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_bigquery_column_info_query_statement" + + +class BigQueryIndexInfoQueryStatement(IntrospectionExpression): + """The primary-key question, over ``TABLE_CONSTRAINTS``. + + BigQuery has no user-created indexes. ``TABLE_CONSTRAINTS`` documents + ``constraint_type`` as either ``PRIMARY KEY`` or ``FOREIGN KEY``, so the + primary-key rows are the ones that answer "what is the key of this + table"; the join to ``KEY_COLUMN_USAGE`` supplies the constrained + columns in order. Foreign keys exist but neither view names the + referenced relation, so the foreign-key question refuses instead. + + Args: + dialect: The BigQuery dialect that renders this statement. + table_name: The table whose primary key is wanted. + schema: The dataset, or a ``project.dataset`` pair. Required. + catalog: The project to qualify the dataset with, or ``None``. + + https://cloud.google.com/bigquery/docs/information-schema-table-constraints + https://cloud.google.com/bigquery/docs/information-schema-key-column-usage + """ + + def __init__( + self, + dialect: "BigQueryDialect", + table_name: str, + schema: str, + catalog: Optional[str] = None, + ): + """Initialize the index (primary key) statement.""" + super().__init__(dialect, schema) + self.table_name = table_name + self.catalog = catalog + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_bigquery_index_info_query_statement" + + +class BigQueryViewListQueryStatement(IntrospectionExpression): + """The ``INFORMATION_SCHEMA.VIEWS`` question. + + Args: + dialect: The BigQuery dialect that renders this statement. + schema: The dataset, or a ``project.dataset`` pair. Required. + catalog: The project to qualify the dataset with, or ``None``. + include_system: Ignored -- every row of this view is a user view. + + ``check_option`` is documented as always ``NULL`` for BigQuery; it is + selected so the parsed object can say so rather than assume it. + + https://cloud.google.com/bigquery/docs/information-schema-views + """ + + def __init__( + self, + dialect: "BigQueryDialect", + schema: str, + catalog: Optional[str] = None, + include_system: bool = False, + ): + """Initialize the view list statement.""" + super().__init__(dialect, schema) + self.catalog = catalog + self.include_system = include_system + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_bigquery_view_list_query_statement" + + +class BigQueryViewInfoQueryStatement(IntrospectionExpression): + """One view's ``INFORMATION_SCHEMA.VIEWS`` question. + + Args: + dialect: The BigQuery dialect that renders this statement. + view_name: The view being asked about. + schema: The dataset, or a ``project.dataset`` pair. Required. + catalog: The project to qualify the dataset with, or ``None``. + + https://cloud.google.com/bigquery/docs/information-schema-views + """ + + def __init__( + self, + dialect: "BigQueryDialect", + view_name: str, + schema: str, + catalog: Optional[str] = None, + ): + """Initialize the single view statement.""" + super().__init__(dialect, schema) + self.view_name = view_name + self.catalog = catalog + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_bigquery_view_info_query_statement" + + +class BigQueryDatasetListQueryStatement(IntrospectionExpression): + """The ``INFORMATION_SCHEMA.SCHEMATA`` question (datasets). + + This is the one view that answers at project level rather than dataset + level, and the only one that accepts a region qualifier. The documented + scope table: + + * ``[PROJECT_ID.]INFORMATION_SCHEMA.SCHEMATA`` -- the US region; + * ``[PROJECT_ID.]`region-REGION`.INFORMATION_SCHEMA.SCHEMATA`` -- one + region, spelled ``region-us``, ``region-europe-north1``, ... + + Args: + dialect: The BigQuery dialect that renders this statement. + catalog: The project whose datasets are listed, or ``None`` for + "the query-executing project". + location: A dataset location (``US``, ``eu``, ``us-central1`` ...). + The dialect prefixes ``region-`` when the value is not already + a region qualifier; ``None`` reads the US multi-region, which + is the documented default. + + https://cloud.google.com/bigquery/docs/information-schema-datasets-schemata + """ + + def __init__( + self, + dialect: "BigQueryDialect", + catalog: Optional[str] = None, + location: Optional[str] = None, + ): + """Initialize the dataset list statement.""" + super().__init__(dialect, None) + self.catalog = catalog + self.location = location + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_bigquery_dataset_list_query_statement" + + +class BigQueryTableOptionsQueryStatement(IntrospectionExpression): + """The ``INFORMATION_SCHEMA.TABLE_OPTIONS`` question for one table. + + Options arrive as one row per option -- ``description``, + ``expiration_timestamp``, ``labels``, ``partition_expiration_days``, + ``require_partition_filter``, ``refresh_interval_minutes``, and the + external-table ones -- so the same option name can appear more than + once with a different ``option_type`` (``labels`` is an + ``ARRAY>``). They are kept as rows rather than + folded into a dict for that reason. + + Args: + dialect: The BigQuery dialect that renders this statement. + table_name: The table whose options are wanted. + schema: The dataset, or a ``project.dataset`` pair. Required. + catalog: The project to qualify the dataset with, or ``None``. + + https://cloud.google.com/bigquery/docs/information-schema-table-options + """ + + def __init__( + self, + dialect: "BigQueryDialect", + table_name: str, + schema: str, + catalog: Optional[str] = None, + ): + """Initialize the table options statement.""" + super().__init__(dialect, schema) + self.table_name = table_name + self.catalog = catalog + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_bigquery_table_options_query_statement" + + +class BigQueryPartitionListQueryStatement(IntrospectionExpression): + """The ``INFORMATION_SCHEMA.PARTITIONS`` question for one table. + + ``partition_id`` is ``NULL`` for an unpartitioned table and + ``__NULL__`` for the partition holding rows whose partitioning key is + ``NULL``. Unlike ``SCHEMATA`` this view takes a dataset qualifier only, + never a region qualifier. + + Args: + dialect: The BigQuery dialect that renders this statement. + table_name: The table whose partitions are wanted. + schema: The dataset, or a ``project.dataset`` pair. Required. + catalog: The project to qualify the dataset with, or ``None``. + + https://cloud.google.com/bigquery/docs/information-schema-partitions + """ + + def __init__( + self, + dialect: "BigQueryDialect", + table_name: str, + schema: str, + catalog: Optional[str] = None, + ): + """Initialize the partition list statement.""" + super().__init__(dialect, schema) + self.table_name = table_name + self.catalog = catalog + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_bigquery_partition_list_query_statement" diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/introspection/__init__.py b/src/rhosocial/activerecord/backend/impl/bigquery/introspection/__init__.py new file mode 100644 index 0000000..3a157ef --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/bigquery/introspection/__init__.py @@ -0,0 +1,31 @@ +# src/rhosocial/activerecord/backend/impl/bigquery/introspection/__init__.py +"""BigQuery introspection: statements are built in the expression layer, +executed by the executors here, and parsed by the introspectors here.""" + +from .executor import ( + AsyncBigQueryIntrospectorExecutor, + SyncBigQueryIntrospectorExecutor, +) +from .introspector import ( + AsyncBigQueryIntrospector, + BigQueryIntrospectorMixin, + SyncBigQueryIntrospector, +) +from .types import ( + BigQueryDatasetInfo, + BigQueryPartitionInfo, + BigQueryTableOptionInfo, + BigQueryTableStorageInfo, +) + +__all__ = [ + "AsyncBigQueryIntrospectorExecutor", + "SyncBigQueryIntrospectorExecutor", + "AsyncBigQueryIntrospector", + "BigQueryIntrospectorMixin", + "SyncBigQueryIntrospector", + "BigQueryDatasetInfo", + "BigQueryPartitionInfo", + "BigQueryTableOptionInfo", + "BigQueryTableStorageInfo", +] diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/introspection/executor.py b/src/rhosocial/activerecord/backend/impl/bigquery/introspection/executor.py new file mode 100644 index 0000000..716d435 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/bigquery/introspection/executor.py @@ -0,0 +1,76 @@ +# src/rhosocial/activerecord/backend/impl/bigquery/introspection/executor.py +"""Introspector executors for BigQuery. + +The core executors run SQL through ``backend._get_cursor()`` because every +DB-API backend has a cursor. BigQuery has none: the backend talks to the +``google-cloud-bigquery`` client (or, for the emulator, a single REST call) +and returns a :class:`~rhosocial.activerecord.backend.result.QueryResult` +whose ``data`` is already a list of column-name → value dicts. + +So these two subclasses exist for one reason: to run the statement through +``backend.execute()`` instead of inventing a cursor. Everything else — SQL +building, row parsing, caching — is the shared introspection code. +""" + +from typing import Any, Dict, List, Tuple + +from rhosocial.activerecord.backend.introspection.executor import ( + AsyncIntrospectorExecutor, + SyncIntrospectorExecutor, +) + + +def _rows_from_result(result: Any) -> List[Dict[str, Any]]: + """Return the rows of a ``QueryResult`` as a list of dicts. + + ``QueryResult.data`` is ``None`` for a statement that returned no result + set. An introspection statement always selects, so that should not + happen; returning an empty list keeps a surprising result from turning + into an ``AttributeError`` deep inside a ``_parse_*`` method. + """ + data = getattr(result, "data", None) + if not data: + return [] + return list(data) + + +class SyncBigQueryIntrospectorExecutor(SyncIntrospectorExecutor): + """Synchronous BigQuery executor: SQL in, list-of-dict rows out.""" + + def execute(self, sql: str, params: Tuple = ()) -> List[Dict[str, Any]]: + """Execute one introspection query through the backend. + + Args: + sql: The statement built by the introspector. + params: Positional parameters, using the dialect's ``?`` + placeholder. + + Returns: + The result rows as column-name → value dicts. + """ + result = self._backend.execute(sql, params) + return _rows_from_result(result) + + +class AsyncBigQueryIntrospectorExecutor(AsyncIntrospectorExecutor): + """Asynchronous BigQuery executor: SQL in, list-of-dict rows out.""" + + async def execute(self, sql: str, params: Tuple = ()) -> List[Dict[str, Any]]: + """Execute one introspection query through the async backend. + + Args: + sql: The statement built by the introspector. + params: Positional parameters, using the dialect's ``?`` + placeholder. + + Returns: + The result rows as column-name → value dicts. + """ + result = await self._backend.execute(sql, params) + return _rows_from_result(result) + + +__all__ = [ + "SyncBigQueryIntrospectorExecutor", + "AsyncBigQueryIntrospectorExecutor", +] diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/introspection/introspector.py b/src/rhosocial/activerecord/backend/impl/bigquery/introspection/introspector.py new file mode 100644 index 0000000..4c77d67 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/bigquery/introspection/introspector.py @@ -0,0 +1,967 @@ +# src/rhosocial/activerecord/backend/impl/bigquery/introspection/introspector.py +"""BigQuery concrete introspectors. + +Both classes answer the core introspection questions from BigQuery's +``INFORMATION_SCHEMA`` views rather than from a DB-API catalog: + +====================== ========================================== +Question Catalog view +====================== ========================================== +database info ``SELECT @@project_id`` (system variable) +tables ``INFORMATION_SCHEMA.TABLES`` +columns ``INFORMATION_SCHEMA.COLUMNS`` +indexes (primary keys) ``TABLE_CONSTRAINTS`` + ``KEY_COLUMN_USAGE`` +views ``INFORMATION_SCHEMA.VIEWS`` +datasets ``INFORMATION_SCHEMA.SCHEMATA`` +table options ``INFORMATION_SCHEMA.TABLE_OPTIONS`` +partitions ``INFORMATION_SCHEMA.PARTITIONS`` +====================== ========================================== + +**No SQL is written here.** Every question is a statement object from +:mod:`...expression.introspection`, rendered by the dialect mixin +(:mod:`...mixins.introspection`) into SQL text through ``to_sql()``. This +module resolves *which* dataset and project a question is about, runs the +rendered text through the executor, and parses the rows. That split is the +project rule, and it is what makes the backtick quoting of a +``project.dataset`` path the dialect's business rather than something +restated by hand here -- where a name containing a backtick would produce +SQL the server refuses. + +**Dataset ↔ schema.** BigQuery has ``project.dataset.table`` and no schema +level below a dataset, so the core ``schema`` parameter carries a dataset +ID here. Pass the dataset alone and the configured project is used, or pass +``project.dataset`` to read a dataset in another project. Builders that +need a dataset raise :class:`ValueError` with that instruction when neither +the argument nor the connection config supplies one; nothing is guessed. + +**What BigQuery's catalog cannot answer.** + +*Foreign keys.* ``TABLE_CONSTRAINTS`` reports that a foreign key exists and +``KEY_COLUMN_USAGE`` reports its columns, but neither view names the +referenced table or its columns, and BigQuery has no +``REFERENTIAL_CONSTRAINTS`` view. A :class:`IntrospectionForeignKeyInfo` +whose ``referenced_table`` is empty would be a worse answer than no answer, +so :meth:`list_foreign_keys` returns an empty list and the builder refuses +rather than fabricating the other side. + +*Triggers.* BigQuery has no triggers, so :meth:`list_triggers` returns an +empty list and the builder refuses instead of inventing a catalog view. + +*Indexes.* BigQuery has no user-created indexes. Primary-key constraints +are reported as :class:`IndexInfo` values so ``get_primary_key()`` works; +``IndexType`` is ``UNKNOWN`` because there is no index structure behind it. + +Documentation: + +* ``INFORMATION_SCHEMA`` overview (dataset/region/project qualifiers) + https://cloud.google.com/bigquery/docs/information-schema-intro +* ``TABLES`` https://cloud.google.com/bigquery/docs/information-schema-tables +* ``COLUMNS`` https://cloud.google.com/bigquery/docs/information-schema-columns +* ``VIEWS`` https://cloud.google.com/bigquery/docs/information-schema-views +* ``TABLE_CONSTRAINTS`` + https://cloud.google.com/bigquery/docs/information-schema-table-constraints +* ``KEY_COLUMN_USAGE`` + https://cloud.google.com/bigquery/docs/information-schema-key-column-usage +* ``SCHEMATA`` + https://cloud.google.com/bigquery/docs/information-schema-datasets-schemata +* ``TABLE_OPTIONS`` + https://cloud.google.com/bigquery/docs/information-schema-table-options +* ``PARTITIONS`` + https://cloud.google.com/bigquery/docs/information-schema-partitions +* System variables (``@@project_id``) + https://cloud.google.com/bigquery/docs/reference/system-variables + +No BigQuery instance was available while writing this module. Statement +shapes follow the pages above; the parsing tests feed the documented column +names and value spellings directly. +""" + +import copy +import warnings +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.introspection.base import ( + AsyncAbstractIntrospector, + IntrospectorMixin, + SyncAbstractIntrospector, +) +from rhosocial.activerecord.backend.introspection.cache import IntrospectionCache +from rhosocial.activerecord.backend.introspection.executor import ( + AsyncIntrospectorExecutor, + SyncIntrospectorExecutor, +) +from rhosocial.activerecord.backend.introspection.types import ( + ColumnNullable, + IndexColumnInfo, + IndexInfo, + IndexType, + IntrospectionColumnInfo, + IntrospectionDatabaseInfo, + IntrospectionForeignKeyInfo, + IntrospectionScope, + IntrospectionTableInfo, + IntrospectionTriggerInfo, + IntrospectionViewInfo, + TableType, +) + +from ..expression.introspection import ( + BigQueryColumnInfoQueryStatement, + BigQueryDatabaseInfoQueryStatement, + BigQueryDatasetListQueryStatement, + BigQueryIndexInfoQueryStatement, + BigQueryPartitionListQueryStatement, + BigQueryTableListQueryStatement, + BigQueryTableOptionsQueryStatement, + BigQueryViewInfoQueryStatement, + BigQueryViewListQueryStatement, +) +from .types import ( + BigQueryDatasetInfo, + BigQueryPartitionInfo, + BigQueryTableOptionInfo, + BigQueryTableStorageInfo, +) + +#: BigQuery's documented ``TABLES.table_type`` spellings, mapped onto the +#: core enum. ``CLONE`` and ``SNAPSHOT`` are standard tables for the purpose +#: of "is this something I can select from"; the original spelling is kept in +#: ``IntrospectionTableInfo.extra["bigquery_table_type"]``. +#: https://cloud.google.com/bigquery/docs/information-schema-tables +_TABLE_TYPE_MAP = { + "BASE TABLE": TableType.BASE_TABLE, + "CLONE": TableType.BASE_TABLE, + "SNAPSHOT": TableType.BASE_TABLE, + "EXTERNAL": TableType.EXTERNAL, + "VIEW": TableType.VIEW, + "MATERIALIZED VIEW": TableType.VIEW, +} + +#: The views that are not tables, used when ``include_views=False``. +_VIEW_TYPES = ("VIEW", "MATERIALIZED VIEW") + + +def _row_get(row: Dict[str, Any], key: str, default: Any = None) -> Any: + """Read one catalog column, tolerating the case the driver used. + + BigQuery returns ``INFORMATION_SCHEMA`` column names in lowercase, but + a row may come from a test fixture, an emulator or a future driver that + preserves the uppercase spelling. The catalog's spelling does not change + what the value means, so all three are accepted. + """ + if key in row: + return row[key] + if key.lower() in row: + return row[key.lower()] + if key.upper() in row: + return row[key.upper()] + return default + + +def _isoformat(value: Any) -> Optional[str]: + """Render a catalog time as ISO-8601 text, leaving absent values absent.""" + if value is None: + return None + if isinstance(value, datetime): + return value.isoformat() + return str(value) + + +def _as_int(value: Any) -> Optional[int]: + """Read a catalog count as an ``int``, or ``None`` when it is absent.""" + if value is None or isinstance(value, bool): + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _as_bool(value: Any) -> Optional[bool]: + """Read a ``YES``/``NO`` catalog flag as a bool, or ``None``.""" + if value is None: + return None + text = str(value).strip().upper() + if text == "YES": + return True + if text == "NO": + return False + return None + + +class BigQueryIntrospectorMixin(IntrospectorMixin): + """SQL building and row parsing shared by the sync and async managers. + + The ``_build_*_sql`` overrides resolve *which* dataset and project a + question is about and construct the matching statement object from + :mod:`...expression.introspection`; rendering is the dialect's job. The + ``_parse_*`` methods are pure Python: rows in, core introspection + dataclasses out, shared by both managers. + """ + + # ------------------------------------------------------------------ # + # Names and qualifiers + # ------------------------------------------------------------------ # + + def _get_default_schema(self) -> str: + """Return the configured dataset ID, or ``""`` when there is none. + + The core ``schema`` parameter is BigQuery's dataset. A connection + may name a default dataset; when it does not, callers have to say + which dataset they mean rather than have one inferred. + """ + config = getattr(self._backend, "config", None) + if config is None: + return "" + return getattr(config, "dataset", None) or "" + + def _get_version(self) -> Tuple[int, ...]: + """Return the feature-level version tuple carried by the backend. + + BigQuery does not report a server version over SQL; the backend's + version is the emulated feature level (``3.0.0`` by default) and is + reported as such rather than passed off as a server release. + """ + return getattr(self._backend, "_version", (3, 0, 0)) + + def _project(self) -> Optional[str]: + """The configured project, or ``None`` for the query-executing one.""" + config = getattr(self._backend, "config", None) + if config is None: + return None + return getattr(config, "project", None) or None + + def _resolve_dataset(self, schema: Optional[str]) -> Tuple[Optional[str], str]: + """Split a schema argument into ``(project, dataset)``. + + Args: + schema: A dataset ID, a ``project.dataset`` pair, or ``None`` + to use the connection's default dataset. + + Returns: + The project (possibly ``None``, meaning the configured project, + which the dialect then leaves unqualified) and the dataset ID. + + Raises: + ValueError: When no dataset is available, or when the argument + has more than one dot. + """ + raw = schema if schema is not None else self._get_default_schema() + raw = (raw or "").strip() + if raw.startswith("`") and raw.endswith("`") and len(raw) >= 2: + raw = raw[1:-1].strip() + if not raw: + raise ValueError( + "BigQuery introspection needs a dataset: pass schema= (the " + "dataset ID) or configure BigQueryConnectionConfig(dataset=...)." + ) + if "." not in raw: + return self._project(), raw + parts = raw.split(".") + if len(parts) != 2 or not all(parts): + raise ValueError(f"Expected a dataset ID or a project.dataset pair, got {raw!r}.") + return parts[0], parts[1] + + def _schema_label(self, schema: Optional[str]) -> str: + """Return the dataset ID to record on a parsed object. + + Parsing never raises for a missing dataset: the object being built + still carries whatever the query already selected, and the caller + that asked for it supplied the qualifier in the first place. + """ + raw = schema if schema is not None else self._get_default_schema() + raw = (raw or "").strip().strip("`") + if not raw: + return "" + return raw.split(".")[-1] if "." in raw else raw + + def _dataset_scoped(self, schema: Optional[str]) -> Tuple[Optional[str], str]: + """The ``(catalog, dataset)`` pair every dataset-scoped view needs. + + Raises: + ValueError: No dataset is reachable -- see :meth:`_resolve_dataset`. + """ + return self._resolve_dataset(schema) + + # ------------------------------------------------------------------ # + # SQL building — statement objects, rendered by the dialect + # ------------------------------------------------------------------ # + + def _build_database_info_sql(self): + """``SELECT @@project_id AS catalog_name``, as a statement object.""" + return BigQueryDatabaseInfoQueryStatement(self.dialect).to_sql() + + def _build_table_list_sql( + self, + schema: Optional[str], + include_system: bool, + include_views: bool = True, + table_type: Optional[str] = None, + ): + """Build the ``INFORMATION_SCHEMA.TABLES`` statement. + + ``include_system`` has no effect: BigQuery does not list system + tables in this view. It is accepted so the core signature keeps + working, and documented here rather than silently ignored. + + Args: + schema: Dataset ID or ``project.dataset``; ``None`` for the + connection default. + include_system: Ignored (see above). + include_views: When false and no ``table_type`` is given, + exclude views and materialized views. + table_type: A core ``TableType`` value or a catalog spelling. + ``BASE_TABLE`` expands to base tables, clones and snapshots; + ``VIEW`` expands to views and materialized views. + """ + catalog, dataset = self._dataset_scoped(schema) + del include_system # BigQuery's catalog has no system tables here. + return BigQueryTableListQueryStatement( + self.dialect, + schema=dataset, + catalog=catalog, + include_views=include_views, + table_type=table_type, + ).to_sql() + + def _build_column_info_sql(self, table_name: str, schema: Optional[str]): + """Build the ``INFORMATION_SCHEMA.COLUMNS`` statement. + + ``ordinal_position`` is selected because it orders the result; the + other columns are the documented ones this backend maps onto + :class:`IntrospectionColumnInfo` or keeps in ``extra``. Pseudo + columns (``is_hidden = 'YES'``) are filtered in the parser, because + their ``ordinal_position`` is documented as ``NULL`` and they are not + part of the table's declared schema. + https://cloud.google.com/bigquery/docs/information-schema-columns + """ + catalog, dataset = self._dataset_scoped(schema) + return BigQueryColumnInfoQueryStatement( + self.dialect, + table_name=table_name, + schema=dataset, + catalog=catalog, + ).to_sql() + + def _build_index_info_sql(self, table_name: str, schema: Optional[str]): + """Build the primary-key statement over constraints. + + ``TABLE_CONSTRAINTS`` documents ``constraint_type`` as either + ``PRIMARY KEY`` or ``FOREIGN KEY``; only the former maps onto an + index. The join to ``KEY_COLUMN_USAGE`` supplies the constrained + columns in order. + https://cloud.google.com/bigquery/docs/information-schema-table-constraints + """ + catalog, dataset = self._dataset_scoped(schema) + return BigQueryIndexInfoQueryStatement( + self.dialect, + table_name=table_name, + schema=dataset, + catalog=catalog, + ).to_sql() + + def _build_foreign_key_sql(self, table_name: str, schema: Optional[str]): + """Refuse: the catalog does not expose the referenced relation. + + See the module docstring. The public ``list_foreign_keys`` returns + an empty list instead of calling this; the builder still refuses so + a future caller cannot mistake it for an unanswered query. + """ + del table_name, schema + raise UnsupportedFeatureError( + self.dialect.name, + "foreign key introspection", + "BigQuery's INFORMATION_SCHEMA reports that a foreign key exists " + "but does not name the referenced table or columns, and it has no " + "REFERENTIAL_CONSTRAINTS view. list_foreign_keys() therefore " + "returns an empty list.", + ) + + def _build_view_list_sql(self, schema: Optional[str], include_system: bool): + """Build the ``INFORMATION_SCHEMA.VIEWS`` statement. + + ``include_system`` is ignored: every row of this view is a user + view. ``VIEWS`` is dataset-qualified, unlike ``SCHEMATA``. + https://cloud.google.com/bigquery/docs/information-schema-views + """ + del include_system + catalog, dataset = self._dataset_scoped(schema) + return BigQueryViewListQueryStatement( + self.dialect, schema=dataset, catalog=catalog + ).to_sql() + + def _build_view_info_sql(self, view_name: str, schema: Optional[str]): + """Build one view's ``INFORMATION_SCHEMA.VIEWS`` statement.""" + catalog, dataset = self._dataset_scoped(schema) + return BigQueryViewInfoQueryStatement( + self.dialect, view_name=view_name, schema=dataset, catalog=catalog + ).to_sql() + + def _build_trigger_list_sql(self, table_name: Optional[str], schema: Optional[str]): + """Refuse: BigQuery has no triggers and no trigger catalog view.""" + del table_name, schema + raise UnsupportedFeatureError( + self.dialect.name, + "trigger introspection", + "BigQuery has no triggers, so there is no catalog view to query. " + "list_triggers() returns an empty list.", + ) + + def _build_dataset_list_sql(self): + """Build the ``INFORMATION_SCHEMA.SCHEMATA`` statement (project/region).""" + config = getattr(self._backend, "config", None) + location = getattr(config, "location", None) if config is not None else None + return BigQueryDatasetListQueryStatement( + self.dialect, catalog=self._project(), location=location + ).to_sql() + + def _build_table_options_sql(self, table_name: str, schema: Optional[str]): + """Build the ``INFORMATION_SCHEMA.TABLE_OPTIONS`` statement.""" + catalog, dataset = self._dataset_scoped(schema) + return BigQueryTableOptionsQueryStatement( + self.dialect, table_name=table_name, schema=dataset, catalog=catalog + ).to_sql() + + def _build_partition_list_sql(self, table_name: str, schema: Optional[str]): + """Build the ``INFORMATION_SCHEMA.PARTITIONS`` statement. + + Unlike ``SCHEMATA`` this view takes a dataset qualifier only, never + a region qualifier. + https://cloud.google.com/bigquery/docs/information-schema-partitions + """ + catalog, dataset = self._dataset_scoped(schema) + return BigQueryPartitionListQueryStatement( + self.dialect, table_name=table_name, schema=dataset, catalog=catalog + ).to_sql() + + # ------------------------------------------------------------------ # + # Parsing + # ------------------------------------------------------------------ # + + def _parse_database_info(self, rows: List[Dict[str, Any]]) -> IntrospectionDatabaseInfo: + row = rows[0] if rows else {} + version = self._get_version() + config = getattr(self._backend, "config", None) + return IntrospectionDatabaseInfo( + name=_row_get(row, "catalog_name") or self._project() or "", + version=".".join(str(part) for part in version), + version_tuple=tuple(version), + vendor="BigQuery", + collation=None, + extra={ + "project": self._project(), + "dataset": getattr(config, "dataset", None) if config else None, + "location": getattr(config, "location", None) if config else None, + "version_source": "backend feature level, not a server version", + }, + ) + + def _parse_tables( + self, rows: List[Dict[str, Any]], schema: Optional[str] + ) -> List[IntrospectionTableInfo]: + label = self._schema_label(schema) + tables: List[IntrospectionTableInfo] = [] + for row in rows: + raw_type = str(_row_get(row, "table_type") or "BASE TABLE").upper() + tables.append( + IntrospectionTableInfo( + name=_row_get(row, "table_name") or "", + schema=label, + table_type=_TABLE_TYPE_MAP.get(raw_type, TableType.BASE_TABLE), + create_time=_isoformat(_row_get(row, "creation_time")), + extra={ + "bigquery_table_type": raw_type, + "is_insertable_into": _as_bool(_row_get(row, "is_insertable_into")), + "is_typed": _as_bool(_row_get(row, "is_typed")), + "ddl": _row_get(row, "ddl"), + "default_collation_name": _row_get(row, "default_collation_name"), + }, + ) + ) + return tables + + def _parse_data_type(self, raw: str, column_name: str) -> Optional[Any]: + """Read one catalog type string, degrading one column on failure. + + ``parsed_data_type`` is ``None`` for exactly one reason: the catalog + string could not be read. A ``STRUCT<...>`` or ``RANGE<...>`` type is + not a type name this dialect's parser accepts, so that column keeps + its ``data_type`` string and the schema differ falls back to + comparing strings; the rest of the table is unaffected. + """ + from rhosocial.activerecord.backend.expression.types._base import DataType + + try: + return DataType.parse_data_type_str(self.dialect, raw) + except Exception as exc: # noqa: BLE001 -- see the docstring + warnings.warn( + f"BigQuery reported column {column_name!r} as {raw!r}, which " + f"this backend's parse_type could not read " + f"({type(exc).__name__}: {exc}). That column's " + f"parsed_data_type is left unset; the rest of the table is " + f"unaffected.", + RuntimeWarning, + stacklevel=3, + ) + return None + + def _parse_columns( + self, rows: List[Dict[str, Any]], table_name: str, schema: str + ) -> List[IntrospectionColumnInfo]: + label = self._schema_label(schema) + columns: List[IntrospectionColumnInfo] = [] + for row in rows: + if str(_row_get(row, "is_hidden") or "").strip().upper() == "YES": + # Pseudo columns have a NULL ordinal_position and are not + # declared schema; the catalog documents this explicitly. + continue + raw_type = _row_get(row, "data_type") or "" + nullable_raw = str(_row_get(row, "is_nullable") or "").strip().upper() + if nullable_raw == "YES": + nullable = ColumnNullable.NULLABLE + elif nullable_raw == "NO": + nullable = ColumnNullable.NOT_NULL + else: + nullable = ColumnNullable.UNKNOWN + identity = str(_row_get(row, "is_identity") or "").strip().upper() == "YES" + column_name = _row_get(row, "column_name") or "" + columns.append( + IntrospectionColumnInfo( + name=column_name, + table_name=table_name, + schema=label, + ordinal_position=_as_int(_row_get(row, "ordinal_position")) or 0, + data_type=str(raw_type).lower(), + data_type_full=str(raw_type) or None, + parsed_data_type=( + self._parse_data_type(str(raw_type), column_name) if raw_type else None + ), + nullable=nullable, + default_value=_row_get(row, "column_default"), + # BigQuery has no AUTO_INCREMENT/IDENTITY column flag in + # the core sense; identity columns are reported through + # the extra fields instead of claiming auto-increment. + is_auto_increment=False, + is_generated=( + str(_row_get(row, "is_generated") or "").strip().upper() == "ALWAYS" + ), + generated_expression=_row_get(row, "generation_expression"), + collation=_row_get(row, "collation_name"), + extra={ + "is_stored": _as_bool(_row_get(row, "is_stored")), + "is_hidden": _as_bool(_row_get(row, "is_hidden")), + "is_system_defined": _as_bool(_row_get(row, "is_system_defined")), + "is_partitioning_column": _as_bool(_row_get(row, "is_partitioning_column")), + "clustering_ordinal_position": _as_int( + _row_get(row, "clustering_ordinal_position") + ), + "rounding_mode": _row_get(row, "rounding_mode"), + "is_identity": identity, + "identity_generation": _row_get(row, "identity_generation"), + "identity_start": _as_int(_row_get(row, "identity_start")), + "identity_increment": _as_int(_row_get(row, "identity_increment")), + # The documented spellings are identity_maximum / + # identity_minimum; a row carrying neither means an + # older or emulated catalog, which yields None. + "identity_maximum": _as_int(_row_get(row, "identity_maximum")), + "identity_minimum": _as_int(_row_get(row, "identity_minimum")), + "identity_cycle": _row_get(row, "identity_cycle"), + }, + ) + ) + return columns + + def _parse_indexes( + self, rows: List[Dict[str, Any]], table_name: str, schema: str + ) -> List[IndexInfo]: + """Parse primary-key constraint rows into ``IndexInfo`` objects. + + BigQuery has no physical indexes. A primary key is reported the way + the rest of the codebase understands keys, with the original + ``constraint_type`` kept in ``extra``. + """ + indexes: Dict[str, IndexInfo] = {} + for row in rows: + name = _row_get(row, "constraint_name") or "" + if not name: + continue + if name not in indexes: + constraint_type = str(_row_get(row, "constraint_type") or "PRIMARY KEY").upper() + indexes[name] = IndexInfo( + name=name, + table_name=table_name, + schema=self._schema_label(schema), + is_unique=True, + is_primary=constraint_type == "PRIMARY KEY", + index_type=IndexType.UNKNOWN, + columns=[], + extra={"constraint_type": constraint_type}, + ) + column_name = _row_get(row, "column_name") + if column_name: + indexes[name].columns.append( + IndexColumnInfo( + name=column_name, + ordinal_position=_as_int(_row_get(row, "ordinal_position")) or 0, + is_descending=False, + ) + ) + return list(indexes.values()) + + def _parse_foreign_keys( + self, rows: List[Dict[str, Any]], table_name: str, schema: str + ) -> List[IntrospectionForeignKeyInfo]: + """Always empty: the catalog cannot name the referenced relation.""" + del rows, table_name, schema + return [] + + def _parse_views(self, rows: List[Dict[str, Any]], schema: str) -> List[IntrospectionViewInfo]: + label = self._schema_label(schema) + return [ + IntrospectionViewInfo( + name=_row_get(row, "table_name") or "", + schema=label, + definition=_row_get(row, "view_definition"), + check_option=_row_get(row, "check_option"), + extra={ + "use_standard_sql": _as_bool(_row_get(row, "use_standard_sql")), + }, + ) + for row in rows + ] + + def _parse_view_info( + self, rows: List[Dict[str, Any]], view_name: str, schema: str + ) -> Optional[IntrospectionViewInfo]: + if not rows: + return None + return self._parse_views(rows[:1], schema)[0] + + def _parse_triggers( + self, rows: List[Dict[str, Any]], schema: str + ) -> List[IntrospectionTriggerInfo]: + """Always empty: BigQuery has no triggers.""" + del rows, schema + return [] + + def _parse_datasets(self, rows: List[Dict[str, Any]]) -> List[BigQueryDatasetInfo]: + return [ + BigQueryDatasetInfo( + schema_name=_row_get(row, "schema_name") or "", + catalog_name=_row_get(row, "catalog_name"), + schema_owner=_row_get(row, "schema_owner"), + creation_time=_row_get(row, "creation_time"), + last_modified_time=_row_get(row, "last_modified_time"), + location=_row_get(row, "location"), + ddl=_row_get(row, "ddl"), + default_collation_name=_row_get(row, "default_collation_name"), + sync_status=_row_get(row, "sync_status"), + ) + for row in rows + ] + + def _parse_table_options(self, rows: List[Dict[str, Any]]) -> List[BigQueryTableOptionInfo]: + return [ + BigQueryTableOptionInfo( + table_name=_row_get(row, "table_name") or "", + option_name=_row_get(row, "option_name") or "", + option_value=_row_get(row, "option_value"), + option_type=_row_get(row, "option_type"), + table_catalog=_row_get(row, "table_catalog"), + table_schema=_row_get(row, "table_schema"), + ) + for row in rows + ] + + def _parse_partitions(self, rows: List[Dict[str, Any]]) -> List[BigQueryPartitionInfo]: + return [ + BigQueryPartitionInfo( + table_name=_row_get(row, "table_name") or "", + partition_id=_row_get(row, "partition_id"), + table_catalog=_row_get(row, "table_catalog"), + table_schema=_row_get(row, "table_schema"), + total_rows=_as_int(_row_get(row, "total_rows")), + total_logical_bytes=_as_int(_row_get(row, "total_logical_bytes")), + total_billable_bytes=_as_int(_row_get(row, "total_billable_bytes")), + last_modified_time=_row_get(row, "last_modified_time"), + storage_tier=_row_get(row, "storage_tier"), + ) + for row in rows + ] + + # ------------------------------------------------------------------ # + # Column extraction shared by the storage-shape question + # ------------------------------------------------------------------ # + + def _partitioning_columns(self, columns: List[IntrospectionColumnInfo]) -> List[str]: + """The columns the catalog marks as partitioning columns. + + ``COLUMNS.is_partitioning_column`` answers ``YES`` for them; there + is no ordering to respect, so column order is the row order. + """ + return [ + column.name for column in columns if column.extra.get("is_partitioning_column") is True + ] + + def _clustering_columns(self, columns: List[IntrospectionColumnInfo]) -> List[str]: + """The clustering columns, in clustering order. + + ``COLUMNS.clustering_ordinal_position`` is the 1-indexed offset of + the column within the table's clustering columns, so the list is + built by position rather than by the order the rows arrived in. + """ + positioned = [ + (column.extra["clustering_ordinal_position"], column.name) + for column in columns + if _as_int(column.extra.get("clustering_ordinal_position")) is not None + ] + return [name for _position, name in sorted(positioned, key=lambda item: item[0])] + + +class SyncBigQueryIntrospector(BigQueryIntrospectorMixin, SyncAbstractIntrospector): + """Synchronous BigQuery introspector. + + Access it through ``backend.introspector``:: + + tables = backend.introspector.list_tables() + columns = backend.introspector.list_columns("orders") + datasets = backend.introspector.list_datasets() + """ + + def __init__( + self, + backend: Any, + executor: SyncIntrospectorExecutor, + cache: Optional[IntrospectionCache] = None, + ) -> None: + super().__init__(backend, executor, cache) + + def get_table_info( + self, table_name: str, schema: Optional[str] = None + ) -> Optional[IntrospectionTableInfo]: + """Return a table with its columns and primary key. + + The table object is copied before it is populated: the instance in + the ``list_tables()`` cache must not gain columns because one caller + asked for details about it. + """ + key = self._cache.key(IntrospectionScope.TABLE, table_name, schema=schema) + cached = self._cache.get(key) + if cached is not None: + return cached + table = next((t for t in self.list_tables(schema) if t.name == table_name), None) + if table is None: + return None + table = copy.copy(table) + 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._cache.set(key, table) + return table + + def list_foreign_keys( + self, table_name: str, schema: Optional[str] = None + ) -> List[IntrospectionForeignKeyInfo]: + """Return an empty list; see the module docstring.""" + del table_name, schema + return [] + + def list_triggers( + self, table_name: Optional[str] = None, schema: Optional[str] = None + ) -> List[IntrospectionTriggerInfo]: + """Return an empty list; BigQuery has no triggers.""" + del table_name, schema + return [] + + # ------------------------------------------------------------------ # + # BigQuery-specific questions + # ------------------------------------------------------------------ # + + def list_datasets(self) -> List[BigQueryDatasetInfo]: + """List the project's datasets from ``INFORMATION_SCHEMA.SCHEMATA``.""" + key = self._cache.key(IntrospectionScope.SCHEMA, "*", extra="datasets") + cached = self._cache.get(key) + if cached is not None: + return cached + sql, params = self._build_dataset_list_sql() + result = self._parse_datasets(self._executor.execute(sql, params)) + self._cache.set(key, result) + return result + + def list_table_options( + self, table_name: str, schema: Optional[str] = None + ) -> List[BigQueryTableOptionInfo]: + """List one table's options from ``INFORMATION_SCHEMA.TABLE_OPTIONS``.""" + key = self._cache.key( + IntrospectionScope.TABLE, table_name, schema=schema, extra="table_options" + ) + cached = self._cache.get(key) + if cached is not None: + return cached + sql, params = self._build_table_options_sql(table_name, schema) + result = self._parse_table_options(self._executor.execute(sql, params)) + self._cache.set(key, result) + return result + + def list_partitions( + self, table_name: str, schema: Optional[str] = None + ) -> List[BigQueryPartitionInfo]: + """List one table's partitions from ``INFORMATION_SCHEMA.PARTITIONS``.""" + key = self._cache.key( + IntrospectionScope.TABLE, table_name, schema=schema, extra="partitions" + ) + cached = self._cache.get(key) + if cached is not None: + return cached + sql, params = self._build_partition_list_sql(table_name, schema) + result = self._parse_partitions(self._executor.execute(sql, params)) + self._cache.set(key, result) + return result + + def get_table_storage_info( + self, table_name: str, schema: Optional[str] = None + ) -> BigQueryTableStorageInfo: + """Return how one table is stored, from the three views that know. + + Composed from :meth:`list_table_options`, :meth:`list_partitions` + and :meth:`list_columns`; each of those is cached on its own, so a + repeated call for the same table asks the database nothing new. + + An unpartitioned table answers one ``PARTITIONS`` row whose + ``partition_id`` is ``NULL``, and no partitioning or clustering + columns; a partitioned table answers one row per partition and the + ``COLUMNS`` view names its partitioning column and any clustering + columns. + """ + options = self.list_table_options(table_name, schema) + partitions = self.list_partitions(table_name, schema) + columns = self.list_columns(table_name, schema) + return BigQueryTableStorageInfo( + table_name=table_name, + schema=self._schema_label(schema), + options=options, + partitions=partitions, + partitioning_columns=self._partitioning_columns(columns), + clustering_columns=self._clustering_columns(columns), + ) + + +class AsyncBigQueryIntrospector(BigQueryIntrospectorMixin, AsyncAbstractIntrospector): + """Asynchronous BigQuery introspector. + + Method names match the sync class; every I/O method is ``async``:: + + tables = await backend.introspector.list_tables() + columns = await backend.introspector.list_columns("orders") + datasets = await backend.introspector.list_datasets() + """ + + def __init__( + self, + backend: Any, + executor: AsyncIntrospectorExecutor, + cache: Optional[IntrospectionCache] = None, + ) -> None: + super().__init__(backend, executor, cache) + + async def get_table_info( + self, table_name: str, schema: Optional[str] = None + ) -> Optional[IntrospectionTableInfo]: + """Return a table with its columns and primary key (async).""" + key = self._cache.key(IntrospectionScope.TABLE, table_name, schema=schema) + cached = self._cache.get(key) + if cached is not None: + return cached + table = next((t for t in await self.list_tables(schema) if t.name == table_name), None) + if table is None: + return None + table = copy.copy(table) + 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._cache.set(key, table) + return table + + async def list_foreign_keys( + self, table_name: str, schema: Optional[str] = None + ) -> List[IntrospectionForeignKeyInfo]: + """Return an empty list; see the module docstring.""" + del table_name, schema + return [] + + async def list_triggers( + self, table_name: Optional[str] = None, schema: Optional[str] = None + ) -> List[IntrospectionTriggerInfo]: + """Return an empty list; BigQuery has no triggers.""" + del table_name, schema + return [] + + # ------------------------------------------------------------------ # + # BigQuery-specific questions + # ------------------------------------------------------------------ # + + async def list_datasets(self) -> List[BigQueryDatasetInfo]: + """List the project's datasets (async).""" + key = self._cache.key(IntrospectionScope.SCHEMA, "*", extra="datasets") + cached = self._cache.get(key) + if cached is not None: + return cached + sql, params = self._build_dataset_list_sql() + result = self._parse_datasets(await self._executor.execute(sql, params)) + self._cache.set(key, result) + return result + + async def list_table_options( + self, table_name: str, schema: Optional[str] = None + ) -> List[BigQueryTableOptionInfo]: + """List one table's options (async).""" + key = self._cache.key( + IntrospectionScope.TABLE, table_name, schema=schema, extra="table_options" + ) + cached = self._cache.get(key) + if cached is not None: + return cached + sql, params = self._build_table_options_sql(table_name, schema) + result = self._parse_table_options(await self._executor.execute(sql, params)) + self._cache.set(key, result) + return result + + async def list_partitions( + self, table_name: str, schema: Optional[str] = None + ) -> List[BigQueryPartitionInfo]: + """List one table's partitions (async).""" + key = self._cache.key( + IntrospectionScope.TABLE, table_name, schema=schema, extra="partitions" + ) + cached = self._cache.get(key) + if cached is not None: + return cached + sql, params = self._build_partition_list_sql(table_name, schema) + result = self._parse_partitions(await self._executor.execute(sql, params)) + self._cache.set(key, result) + return result + + async def get_table_storage_info( + self, table_name: str, schema: Optional[str] = None + ) -> BigQueryTableStorageInfo: + """Return how one table is stored, from the three views that know (async).""" + options = await self.list_table_options(table_name, schema) + partitions = await self.list_partitions(table_name, schema) + columns = await self.list_columns(table_name, schema) + return BigQueryTableStorageInfo( + table_name=table_name, + schema=self._schema_label(schema), + options=options, + partitions=partitions, + partitioning_columns=self._partitioning_columns(columns), + clustering_columns=self._clustering_columns(columns), + ) + + +__all__ = [ + "BigQueryIntrospectorMixin", + "SyncBigQueryIntrospector", + "AsyncBigQueryIntrospector", +] diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/introspection/types.py b/src/rhosocial/activerecord/backend/impl/bigquery/introspection/types.py new file mode 100644 index 0000000..b1e6d01 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/bigquery/introspection/types.py @@ -0,0 +1,143 @@ +# src/rhosocial/activerecord/backend/impl/bigquery/introspection/types.py +"""BigQuery-only introspection result types. + +The core introspection dataclasses describe objects every backend has: +tables, columns, indexes, views and triggers. BigQuery's catalog carries a +few things those shapes have no room for, and this module names them rather +than smuggling them into ``extra`` on an unrelated object: + +``BigQueryDatasetInfo`` + A dataset, from ``INFORMATION_SCHEMA.SCHEMATA``. +``BigQueryTableOptionInfo`` + One table option, from ``INFORMATION_SCHEMA.TABLE_OPTIONS``. +``BigQueryPartitionInfo`` + One partition, from ``INFORMATION_SCHEMA.PARTITIONS``. +``BigQueryTableStorageInfo`` + The storage shape of one table: its options, its partitions, and the + partitioning and clustering columns the ``COLUMNS`` view reports. + +**Dataset and schema are the same level here.** BigQuery names relations +``project.dataset.table`` and has no schema under a dataset, so throughout +this backend the core ``schema`` argument *is* the dataset ID. A caller can +pass the dataset alone (the configured project supplies the project) or a +``project.dataset`` pair when the two differ. + +Documentation (all read 2026-10-24 UTC): + +* ``INFORMATION_SCHEMA.SCHEMATA`` + https://cloud.google.com/bigquery/docs/information-schema-datasets-schemata +* ``INFORMATION_SCHEMA.TABLE_OPTIONS`` + https://cloud.google.com/bigquery/docs/information-schema-table-options +* ``INFORMATION_SCHEMA.PARTITIONS`` + https://cloud.google.com/bigquery/docs/information-schema-partitions +* ``INFORMATION_SCHEMA.COLUMNS`` + https://cloud.google.com/bigquery/docs/information-schema-columns +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import List, Optional + + +@dataclass +class BigQueryDatasetInfo: + """One row of ``INFORMATION_SCHEMA.SCHEMATA``. + + Field names follow the catalog, lowercased, so a reader can trace each + one back to the documented column without a mapping table. + """ + + schema_name: str + catalog_name: Optional[str] = None + #: Documented as always ``NULL`` for BigQuery. + schema_owner: Optional[str] = None + creation_time: Optional[datetime] = None + last_modified_time: Optional[datetime] = None + location: Optional[str] = None + ddl: Optional[str] = None + default_collation_name: Optional[str] = None + #: Present for cross-region replicated datasets; ``NULL`` otherwise. + sync_status: Optional[str] = None + + +@dataclass +class BigQueryTableOptionInfo: + """One row of ``INFORMATION_SCHEMA.TABLE_OPTIONS``. + + Options are returned as rows rather than folded into a dict on the table + because the same option name can carry a different ``option_type`` + (``labels`` is an ``ARRAY>``), and collapsing the + rows would lose that. + """ + + table_name: str + option_name: str + option_value: Optional[str] = None + option_type: Optional[str] = None + table_catalog: Optional[str] = None + table_schema: Optional[str] = None + + +@dataclass +class BigQueryPartitionInfo: + """One row of ``INFORMATION_SCHEMA.PARTITIONS``. + + ``partition_id`` is ``NULL`` for an unpartitioned table and + ``__NULL__`` for the partition that holds rows with a ``NULL`` + partition key, as documented on the catalog page. + """ + + table_name: str + partition_id: Optional[str] = None + table_catalog: Optional[str] = None + table_schema: Optional[str] = None + total_rows: Optional[int] = None + total_logical_bytes: Optional[int] = None + total_billable_bytes: Optional[int] = None + last_modified_time: Optional[datetime] = None + storage_tier: Optional[str] = None + + +@dataclass +class BigQueryTableStorageInfo: + """One table's storage shape, from three catalog answers. + + BigQuery has no single view that says how a table is stored, so this + composes the three that each carry part of the answer: + + * ``options`` -- ``TABLE_OPTIONS`` rows: ``partition_expiration_days``, + ``require_partition_filter``, ``expiration_timestamp``, ``labels`` + and the rest. Which columns partition or cluster the table is *not* + here; the options table documents no such option. + * ``partitions`` -- ``PARTITIONS`` rows, one per partition, with the + per-partition row counts and storage tiers. An unpartitioned table + answers one row whose ``partition_id`` is ``NULL``. + * ``partitioning_columns`` / ``clustering_columns`` -- the ``COLUMNS`` + view's ``is_partitioning_column`` and ``clustering_ordinal_position`` + answers, which is where the partitioning and clustering columns are + actually named. Clustering order is the ordinal position, so the list + is ordered by it rather than by column name. + + All three pieces are the cached answers of the questions + :meth:`~...introspection.SyncBigQueryIntrospector.list_table_options`, + :meth:`~...introspection.SyncBigQueryIntrospector.list_partitions` and + :meth:`~...introspection.SyncBigQueryIntrospector.list_columns` already + keep, so a second call for the same table issues no new questions. + """ + + table_name: str + schema: Optional[str] = None + options: List[BigQueryTableOptionInfo] = field(default_factory=list) + partitions: List[BigQueryPartitionInfo] = field(default_factory=list) + partitioning_columns: List[str] = field(default_factory=list) + clustering_columns: List[str] = field(default_factory=list) + + +__all__ = [ + "BigQueryDatasetInfo", + "BigQueryTableOptionInfo", + "BigQueryPartitionInfo", + "BigQueryTableStorageInfo", +] diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/__init__.py index 841c3e6..1bf2b6f 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/__init__.py @@ -1,20 +1,21 @@ # src/rhosocial/activerecord/backend/impl/bigquery/mixins/__init__.py """BigQuery dialect-specific mixin implementations.""" -from .types import BigQueryTypeSupportMixin -from .namespace import BigQueryNamespaceMixin -from .dql import BigQueryDQLMixin -from .schema import BigQuerySchemaMixin -from .ddl import BigQueryDDLColumnMixin -from .constraint import BigQueryConstraintMixin from .capabilities import BigQueryCapabilityMixin from .column_type import BigQueryColumnTypeMixin -from .identity_column import BigQueryIdentityColumnMixin +from .constraint import BigQueryConstraintMixin +from .ddl import BigQueryDDLColumnMixin +from .dql import BigQueryDQLMixin from .identifier import BigQueryIdentifierMixin +from .identity_column import BigQueryIdentityColumnMixin +from .introspection import BigQueryIntrospectionMixin +from .literal import BigQueryLiteralValueMixin from .materialized_view import BigQueryMaterializedViewMixin +from .namespace import BigQueryNamespaceMixin +from .schema import BigQuerySchemaMixin from .transaction import BigQueryTransactionMixin from .trim import BigQueryTrimMixin -from .literal import BigQueryLiteralValueMixin +from .types import BigQueryTypeSupportMixin class BigQueryStructMixin: @@ -38,22 +39,23 @@ def supports_geography(self) -> bool: __all__ = [ - 'BigQueryTypeSupportMixin', - 'BigQueryNamespaceMixin', - 'BigQueryDQLMixin', - 'BigQuerySchemaMixin', - 'BigQueryDDLColumnMixin', - 'BigQueryConstraintMixin', - 'BigQueryCapabilityMixin', - 'BigQueryColumnTypeMixin', - 'BigQueryIdentityColumnMixin', - 'BigQueryIdentifierMixin', - 'BigQueryMaterializedViewMixin', - 'BigQueryTransactionMixin', - 'BigQueryTrimMixin', - 'BigQueryLiteralValueMixin', - 'BigQueryStructMixin', - 'BigQueryArrayMixin', - 'BigQueryJSONMixin', - 'BigQueryGeographyMixin', + "BigQueryTypeSupportMixin", + "BigQueryNamespaceMixin", + "BigQueryDQLMixin", + "BigQuerySchemaMixin", + "BigQueryDDLColumnMixin", + "BigQueryConstraintMixin", + "BigQueryCapabilityMixin", + "BigQueryColumnTypeMixin", + "BigQueryIdentityColumnMixin", + "BigQueryIdentifierMixin", + "BigQueryMaterializedViewMixin", + "BigQueryTransactionMixin", + "BigQueryTrimMixin", + "BigQueryLiteralValueMixin", + "BigQueryIntrospectionMixin", + "BigQueryStructMixin", + "BigQueryArrayMixin", + "BigQueryJSONMixin", + "BigQueryGeographyMixin", ] diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/introspection.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/introspection.py new file mode 100644 index 0000000..5ed62e0 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/introspection.py @@ -0,0 +1,837 @@ +# src/rhosocial/activerecord/backend/impl/bigquery/mixins/introspection.py +"""BigQueryIntrospectionMixin -- how the dialect spells metadata questions. + +This mixin is the dialect half of introspection: it declares what BigQuery +can answer (``supports_*``) and renders the statement objects from +:mod:`...expression.introspection` into +``project.dataset.INFORMATION_SCHEMA.`` queries. The backend half -- +executing them and parsing the rows -- lives in +:mod:`...introspection.introspector`. + +Nothing here writes SQL text. Every ``format_*`` assembles a core +:class:`~rhosocial.activerecord.backend.expression.statements.dql.QueryStatement` +from expression nodes -- a +:class:`~rhosocial.activerecord.backend.expression.sources.NamedRelationRef` +over a +:class:`~rhosocial.activerecord.backend.expression.objects.Table` for the +``FROM``, :class:`~rhosocial.activerecord.backend.expression.core.Column` +for the projection and the predicates, and renders it through core's own +``format_query_statement``. The backtick quoting, the qualifier order and +the bind marker are therefore this dialect's answer and nobody else's: a +project ID with a hyphen is quoted the same way a table name is, by +:meth:`~rhosocial.activerecord.backend.dialect.base.SQLDialectBase.format_identifier`. + +Position in ``BigQueryDialect``'s base list is load-bearing: this class +subclasses core's ``IntrospectionMixin``, so C3 linearisation gives the +first name priority and it must sit *before* the core mixin it overrides. +""" + +from typing import TYPE_CHECKING, Any, List, Optional, Tuple + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.dialect.mixins import IntrospectionMixin +from rhosocial.activerecord.backend.expression.core import Column, Literal +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.predicates import ( + ComparisonPredicate, + InPredicate, + LogicalPredicate, +) +from rhosocial.activerecord.backend.expression.query_parts import ( + JoinClause, + OrderByClause, +) +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef +from rhosocial.activerecord.backend.expression.statements.dql import QueryStatement +from rhosocial.activerecord.backend.introspection.types import IntrospectionScope + +from ..expression.introspection import ( + BigQueryColumnInfoQueryStatement, + BigQueryDatabaseInfoQueryStatement, + BigQueryIndexInfoQueryStatement, + BigQueryTableListQueryStatement, + BigQueryViewInfoQueryStatement, + BigQueryViewListQueryStatement, +) + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.expression.introspection import ( + ColumnInfoQueryStatement, + DatabaseInfoQueryStatement, + ForeignKeyQueryStatement, + IndexInfoQueryStatement, + TableInfoQueryStatement, + TableListQueryStatement, + TriggerInfoQueryStatement, + TriggerListQueryStatement, + ViewInfoQueryStatement, + ViewListQueryStatement, + ) + + from ..expression.introspection import ( + BigQueryColumnInfoQueryStatement, + BigQueryDatabaseInfoQueryStatement, + BigQueryDatasetListQueryStatement, + BigQueryIndexInfoQueryStatement, + BigQueryPartitionListQueryStatement, + BigQueryTableListQueryStatement, + BigQueryTableOptionsQueryStatement, + BigQueryViewInfoQueryStatement, + BigQueryViewListQueryStatement, + ) + +#: The ``table_type`` spellings core's ``TableType`` expands onto. The catalog +#: documents six: ``BASE TABLE``, ``CLONE``, ``SNAPSHOT``, ``VIEW``, +#: ``MATERIALIZED VIEW`` and ``EXTERNAL`` -- no system tables and no temporary +#: tables, which is why those two keys are absent here rather than mapped. +#: https://cloud.google.com/bigquery/docs/information-schema-tables +_BASE_TABLE_TYPES = ("BASE TABLE", "CLONE", "SNAPSHOT") +_VIEW_TYPES = ("VIEW", "MATERIALIZED VIEW") + + +class BigQueryIntrospectionMixin(IntrospectionMixin): + """BigQuery's answers to the core introspection questions. + + ``supports_*`` declares them, ``format_*`` renders them. Two questions + have no answer at all and say so instead of guessing: foreign keys + (the catalog reports *that* one exists without naming the referenced + relation) and triggers (BigQuery has none). + """ + + # ------------------------------------------------------------------ # + # Capability declaration + # ------------------------------------------------------------------ # + + def supports_introspection(self) -> bool: + """BigQuery answers metadata questions from INFORMATION_SCHEMA.""" + return True + + def supports_database_info(self) -> bool: + """``@@project_id`` is the documented system variable.""" + return True + + def supports_table_introspection(self) -> bool: + """``INFORMATION_SCHEMA.TABLES`` is dataset-scoped.""" + return True + + def supports_column_introspection(self) -> bool: + """``INFORMATION_SCHEMA.COLUMNS`` is dataset-scoped.""" + return True + + def supports_index_introspection(self) -> bool: + """No physical indexes exist; PRIMARY KEY constraints stand in.""" + return True + + def supports_foreign_key_introspection(self) -> bool: + """Declined: the catalog never names the referenced relation. + + ``TABLE_CONSTRAINTS`` reports that a foreign key exists and + ``KEY_COLUMN_USAGE`` reports its columns, but neither view names + the referenced table or its columns, and BigQuery has no + ``REFERENTIAL_CONSTRAINTS`` view. Reporting a foreign key whose + ``referenced_table`` is empty would be a worse answer than none. + """ + return False + + def supports_view_introspection(self) -> bool: + """``INFORMATION_SCHEMA.VIEWS`` is dataset-scoped.""" + return True + + def supports_trigger_introspection(self) -> bool: + """BigQuery has no triggers, so there is no trigger catalog view.""" + return False + + def supports_partition_info(self) -> bool: + """``INFORMATION_SCHEMA.PARTITIONS`` and ``TABLE_OPTIONS`` answer.""" + return True + + def get_supported_introspection_scopes(self) -> List[IntrospectionScope]: + """The scopes this dialect can answer, less the two it declines.""" + return [ + IntrospectionScope.DATABASE, + IntrospectionScope.SCHEMA, + IntrospectionScope.TABLE, + IntrospectionScope.COLUMN, + IntrospectionScope.INDEX, + IntrospectionScope.VIEW, + ] + + # ------------------------------------------------------------------ # + # Spelling helpers + # ------------------------------------------------------------------ # + + def _information_schema_relation( + self, + view: str, + catalog: Optional[str], + schema: Optional[str], + ) -> Table: + """Name ``[project.]dataset.INFORMATION_SCHEMA.`` as a table. + + The three slots map onto BigQuery's four-level path: the project in + the object's ``catalog_name``, the dataset in its ``schema_name``, + and ``INFORMATION_SCHEMA.`` in the name slot rendered + unquoted -- ``INFORMATION_SCHEMA`` is a keyword path element rather + than an identifier a caller chose. A ``None`` project leaves the + qualifier out, which the documents spell as the default project. + + Args: + view: The view name, e.g. ``"TABLES"``. + catalog: The project, or ``None`` for the default. + schema: The dataset, or ``None`` for the region/project scope + that ``SCHEMATA`` uses. + + Returns: + The ``Table`` object. Render it with ``to_sql()``. + """ + return Table( + self, + f"INFORMATION_SCHEMA.{view}", + catalog_name=catalog, + schema_name=schema, + catalog_need_quote=True, + schema_need_quote=True, + name_need_quote=False, + ) + + def _information_schema_source( + self, + view: str, + catalog: Optional[str], + schema: Optional[str], + alias: Optional[str] = None, + ) -> NamedRelationRef: + """The ``FROM`` source over :meth:`_information_schema_relation`.""" + return NamedRelationRef( + self, + self._information_schema_relation(view, catalog, schema), + alias=alias, + ) + + def _region_qualifier(self, location: Optional[str]) -> Optional[str]: + """The ``region-REGION`` qualifier a configured location spells. + + The documented spelling takes the region name with a ``region-`` + prefix (``region-us``, ``region-europe-north1``). A location that + already carries the prefix is used as it stands, and no location at + all means no qualifier, which reads the US multi-region. + """ + if not location: + return None + raw = str(location).strip() + if not raw: + return None + return raw if raw.startswith("region-") else f"region-{raw}" + + def _columns(self, *names: str) -> List[Column]: + """One bare column reference per catalog column name.""" + return [Column(self, name) for name in names] + + def _order_by_first(self, column_name: str) -> OrderByClause: + """``ORDER BY`` the one column every one of these orders by.""" + return OrderByClause(self, expressions=[Column(self, column_name)]) + + def _no_catalog_spelling_predicate(self): + """A predicate that matches nothing, for a filter with no values. + + Asked for a table type the catalog never lists (system tables, + temporary tables), the honest answer is an empty result rather than + an unfiltered one, and the way to say it in SQL is a comparison + that cannot hold. The two constants are inlined literals, not bind + parameters: they belong to the statement rather than to the caller. + """ + return ComparisonPredicate( + self, + ComparisonPredicate.coerce_operator("="), + Literal(self, 1, inline=True), + Literal(self, 2, inline=True), + ) + + def _expand_table_type(self, table_type: Optional[str]) -> Tuple[str, ...]: + """The catalog spellings one requested table type means. + + A core ``TableType`` spelling is expanded onto the catalog's: + ``BASE_TABLE`` covers base tables, clones and snapshots, and + ``VIEW`` covers views and materialized views. A catalog spelling + is passed through unchanged, and a spelling this backend does not + know resolves to nothing -- which the caller renders as the + no-match predicate rather than guessing. + """ + if not table_type: + return () + raw = str(table_type).strip().upper().replace("_", " ") + if raw in ("BASE TABLE", "BASE"): + return _BASE_TABLE_TYPES + if raw == "VIEW": + return _VIEW_TYPES + if raw in ("SYSTEM TABLE", "TEMPORARY"): + return () + return (raw,) + + # ------------------------------------------------------------------ # + # Query formatting + # ------------------------------------------------------------------ # + + def format_bigquery_database_info_query_statement( + self, expr: "BigQueryDatabaseInfoQueryStatement" + ) -> Tuple[str, tuple]: + """``SELECT @@project_id AS catalog_name``. + + The projection is a column reference written unquoted -- the system + variable is a keyword, not an identifier -- aliased so the parser + reads one column name whatever case the driver reports. + """ + del expr # The question carries no parameters. + query = QueryStatement( + self, + select=[ + Column( + self, + "@@project_id", + alias="catalog_name", + name_need_quote=False, + alias_need_quote=False, + ) + ], + ) + return self.format_query_statement(query) + + def format_bigquery_table_list_query_statement( + self, expr: "BigQueryTableListQueryStatement" + ) -> Tuple[str, tuple]: + """Render the ``TABLES`` question. + + One of three filters, in this order of authority: an explicit + ``table_type`` (an ``IN`` over the spellings that type means), then + ``include_views=False`` (``NOT IN`` the two view spellings), then + nothing. The ``table_type`` values are bind parameters, like every + other value in the tree. + """ + condition = None + if expr.table_type: + values = self._expand_table_type(expr.table_type) + condition = ( + InPredicate( + self, + Column(self, "table_type"), + Literal(self, list(values)), + ) + if values + else self._no_catalog_spelling_predicate() + ) + elif not expr.include_views: + condition = LogicalPredicate( + self, + "NOT", + InPredicate( + self, + Column(self, "table_type"), + Literal(self, list(_VIEW_TYPES)), + ), + ) + query = QueryStatement( + self, + select=self._columns( + "table_catalog", + "table_schema", + "table_name", + "table_type", + "is_insertable_into", + "is_typed", + "creation_time", + "ddl", + "default_collation_name", + ), + from_=self._information_schema_source("TABLES", expr.catalog, expr.schema), + where=condition, + order_by=self._order_by_first("table_name"), + ) + return self.format_query_statement(query) + + def format_bigquery_column_info_query_statement( + self, expr: "BigQueryColumnInfoQueryStatement" + ) -> Tuple[str, tuple]: + """Render the ``COLUMNS`` question for one table. + + The table name is a bind parameter. ``is_hidden`` is selected so + the parser can drop pseudo columns, whose ``ordinal_position`` the + documents say is ``NULL``. + """ + query = QueryStatement( + self, + select=self._columns( + "table_catalog", + "table_schema", + "table_name", + "column_name", + "ordinal_position", + "is_nullable", + "data_type", + "is_generated", + "generation_expression", + "is_stored", + "is_hidden", + "is_system_defined", + "is_partitioning_column", + "clustering_ordinal_position", + "collation_name", + "column_default", + "rounding_mode", + "is_identity", + "identity_generation", + "identity_start", + "identity_increment", + "identity_maximum", + "identity_minimum", + "identity_cycle", + ), + from_=self._information_schema_source("COLUMNS", expr.catalog, expr.schema), + where=ComparisonPredicate( + self, + ComparisonPredicate.coerce_operator("="), + Column(self, "table_name"), + Literal(self, expr.table_name), + ), + order_by=self._order_by_first("ordinal_position"), + ) + return self.format_query_statement(query) + + def format_bigquery_index_info_query_statement( + self, expr: "BigQueryIndexInfoQueryStatement" + ) -> Tuple[str, tuple]: + """Render the primary-key question as a join over two views. + + ``TABLE_CONSTRAINTS`` filtered to ``PRIMARY KEY`` is joined to + ``KEY_COLUMN_USAGE`` on the constraint name and the table name -- + the two columns both views carry -- so the constrained columns come + back in ``ordinal_position`` order. The constraint type is a bind + parameter filtering a documented value, not an identifier. + """ + left = self._information_schema_source( + "TABLE_CONSTRAINTS", expr.catalog, expr.schema, alias="tc" + ) + right = self._information_schema_source( + "KEY_COLUMN_USAGE", expr.catalog, expr.schema, alias="kcu" + ) + same_constraint = ComparisonPredicate( + self, + ComparisonPredicate.coerce_operator("="), + Column(self, "constraint_name", table="tc"), + Column(self, "constraint_name", table="kcu"), + ) + same_table = ComparisonPredicate( + self, + ComparisonPredicate.coerce_operator("="), + Column(self, "table_name", table="tc"), + Column(self, "table_name", table="kcu"), + ) + query = QueryStatement( + self, + select=[ + # Qualified so the two views' same-named columns stay apart. + # No alias: the catalog column name is already the result + # name the parser reads. + Column(self, "constraint_name", table="tc"), + Column(self, "constraint_type", table="tc"), + Column(self, "column_name", table="kcu"), + Column(self, "ordinal_position", table="kcu"), + ], + from_=JoinClause( + self, + left_table=left, + right_table=right, + join_type="JOIN", + condition=LogicalPredicate(self, "AND", same_constraint, same_table), + ), + where=LogicalPredicate( + self, + "AND", + ComparisonPredicate( + self, + ComparisonPredicate.coerce_operator("="), + Column(self, "table_name", table="tc"), + Literal(self, expr.table_name), + ), + ComparisonPredicate( + self, + ComparisonPredicate.coerce_operator("="), + Column(self, "constraint_type", table="tc"), + Literal(self, "PRIMARY KEY"), + ), + ), + order_by=OrderByClause( + self, + expressions=[ + (Column(self, "constraint_name", table="tc"), "ASC"), + (Column(self, "ordinal_position", table="kcu"), "ASC"), + ], + ), + ) + return self.format_query_statement(query) + + def format_bigquery_foreign_key_query_statement(self, expr: Any) -> Tuple[str, tuple]: + """Refuse: the catalog never names the referenced relation. + + Kept as a formatter that raises rather than removed, so a caller + that reaches for it gets the reason instead of a silent empty + result. The public ``list_foreign_keys()`` returns an empty list and + never calls this. + """ + del expr + raise UnsupportedFeatureError( + self.name, + "foreign key introspection", + "BigQuery's INFORMATION_SCHEMA reports that a foreign key " + "exists (TABLE_CONSTRAINTS.constraint_type = 'FOREIGN KEY') and " + "reports its columns (KEY_COLUMN_USAGE), but neither view names " + "the referenced table or its columns, and there is no " + "REFERENTIAL_CONSTRAINTS view. list_foreign_keys() therefore " + "returns an empty list rather than inventing the other side.", + ) + + def format_bigquery_view_list_query_statement( + self, expr: "BigQueryViewListQueryStatement" + ) -> Tuple[str, tuple]: + """Render the ``VIEWS`` question. + + ``include_system`` is ignored: every row of this view is a user + view, so there is no system row for the flag to add or remove. It + stays on the statement (the artifact keeps it) rather than being + deleted from it. + """ + _ = expr.include_system + query = QueryStatement( + self, + select=self._columns( + "table_catalog", + "table_schema", + "table_name", + "view_definition", + "check_option", + "use_standard_sql", + ), + from_=self._information_schema_source("VIEWS", expr.catalog, expr.schema), + order_by=self._order_by_first("table_name"), + ) + return self.format_query_statement(query) + + def format_bigquery_view_info_query_statement( + self, expr: "BigQueryViewInfoQueryStatement" + ) -> Tuple[str, tuple]: + """Render one view's ``VIEWS`` question.""" + query = QueryStatement( + self, + select=self._columns( + "table_catalog", + "table_schema", + "table_name", + "view_definition", + "check_option", + "use_standard_sql", + ), + from_=self._information_schema_source("VIEWS", expr.catalog, expr.schema), + where=ComparisonPredicate( + self, + ComparisonPredicate.coerce_operator("="), + Column(self, "table_name"), + Literal(self, expr.view_name), + ), + ) + return self.format_query_statement(query) + + def format_bigquery_dataset_list_query_statement( + self, expr: "BigQueryDatasetListQueryStatement" + ) -> Tuple[str, tuple]: + """Render the ``SCHEMATA`` question. + + The only view answered at project level: the project goes in the + catalog slot and the region qualifier -- when one is configured -- + in the schema slot, which is why this is the one statement whose + ``schema`` is a region rather than a dataset. + + https://cloud.google.com/bigquery/docs/information-schema-datasets-schemata + """ + query = QueryStatement( + self, + select=self._columns( + "catalog_name", + "schema_name", + "schema_owner", + "creation_time", + "last_modified_time", + "location", + "ddl", + "default_collation_name", + "sync_status", + ), + from_=self._information_schema_source( + "SCHEMATA", + expr.catalog, + self._region_qualifier(expr.location), + ), + order_by=self._order_by_first("schema_name"), + ) + return self.format_query_statement(query) + + def format_bigquery_table_options_query_statement( + self, expr: "BigQueryTableOptionsQueryStatement" + ) -> Tuple[str, tuple]: + """Render the ``TABLE_OPTIONS`` question. + + One row per option, so the projection carries ``option_type`` + beside ``option_value``: ``labels`` is an ``ARRAY`` and + collapsing the rows would lose which option carried which shape. + + https://cloud.google.com/bigquery/docs/information-schema-table-options + """ + query = QueryStatement( + self, + select=self._columns( + "table_catalog", + "table_schema", + "table_name", + "option_name", + "option_type", + "option_value", + ), + from_=self._information_schema_source("TABLE_OPTIONS", expr.catalog, expr.schema), + where=ComparisonPredicate( + self, + ComparisonPredicate.coerce_operator("="), + Column(self, "table_name"), + Literal(self, expr.table_name), + ), + order_by=self._order_by_first("option_name"), + ) + return self.format_query_statement(query) + + def format_bigquery_partition_list_query_statement( + self, expr: "BigQueryPartitionListQueryStatement" + ) -> Tuple[str, tuple]: + """Render the ``PARTITIONS`` question. + + A dataset qualifier only -- this view takes no region qualifier, + unlike ``SCHEMATA``. + + https://cloud.google.com/bigquery/docs/information-schema-partitions + """ + query = QueryStatement( + self, + select=self._columns( + "table_catalog", + "table_schema", + "table_name", + "partition_id", + "total_rows", + "total_logical_bytes", + "total_billable_bytes", + "last_modified_time", + "storage_tier", + ), + from_=self._information_schema_source("PARTITIONS", expr.catalog, expr.schema), + where=ComparisonPredicate( + self, + ComparisonPredicate.coerce_operator("="), + Column(self, "table_name"), + Literal(self, expr.table_name), + ), + order_by=self._order_by_first("partition_id"), + ) + return self.format_query_statement(query) + + # ------------------------------------------------------------------ # + # The core statement names, answered the BigQuery way + # ------------------------------------------------------------------ # + # + # Core's ``IntrospectorMixin`` builds a core-named statement per question + # and renders it through ``format__query_statement``. This dialect + # declares every question it can answer (the ``supports_*`` probes above), + # so the core entries have to reach a BigQuery answer rather than core's + # ``NotImplementedError`` -- a capability declared and not answerable is + # worse than a capability declined. Each entry below is an adapter: it + # reads the core statement's parameters and renders through the + # BigQuery-named entry above, so the spelling is written once. + # + # A core statement carries a dataset in its ``schema`` slot and no way to + # say which *project* qualifies it. Every dataset-scoped BigQuery view + # needs one, so an adapter with no schema refuses -- by name -- instead of + # rendering a query the server would reject. The BigQuery statements add + # the ``catalog`` slot for exactly this, and the introspector resolves + # both. + + def _core_schema_or_refuse(self, schema: Optional[str], view: str) -> str: + """The dataset a core statement's schema slot carries. + + Args: + schema: The core statement's ``schema`` parameter. + view: The view the question reads, named in the refusal. + + Returns: + The dataset ID. + + Raises: + UnsupportedFeatureError: The core statement carries no dataset. + """ + dataset = (schema or "").strip().strip("`") + if not dataset: + raise UnsupportedFeatureError( + self.name, + f"a dataset-less INFORMATION_SCHEMA.{view} query", + "BigQuery's INFORMATION_SCHEMA views are dataset-scoped (or " + "project-and-region scoped, for SCHEMATA), so the generic " + "statement needs a schema. Construct the BigQuery statement " + "from rhosocial.activerecord.backend.impl.bigquery.expression." + ".introspection -- it carries the project too -- or let the " + "backend's introspector resolve it.", + ) + return dataset + + def format_database_info_query_statement( + self, expr: "DatabaseInfoQueryStatement" + ) -> Tuple[str, tuple]: + """The core spelling of the database-info question. + + The question is project-level, so it needs no dataset: the core + statement's parameters are none and the BigQuery answer is the + documented system variable. + """ + del expr + return self.format_bigquery_database_info_query_statement( + BigQueryDatabaseInfoQueryStatement(self) + ) + + def format_table_list_query_statement( + self, expr: "TableListQueryStatement" + ) -> Tuple[str, tuple]: + """The core spelling of the table-list question.""" + bigquery_expr = BigQueryTableListQueryStatement( + self, + schema=self._core_schema_or_refuse(expr.schema, "TABLES"), + include_views=expr.include_views, + include_system=expr.include_system, + table_type=expr.table_type, + ) + return self.format_bigquery_table_list_query_statement(bigquery_expr) + + def format_table_info_query_statement( + self, expr: "TableInfoQueryStatement" + ) -> Tuple[str, tuple]: + """The core spelling of the single-table question. + + Answered as the ``TABLES`` query filtered to the one name, which is + the metadata BigQuery has about a table. The columns, the primary + key and the foreign keys of that table are separate questions the + introspector composes -- no single view carries all of them. + """ + query = QueryStatement( + self, + select=self._columns( + "table_catalog", + "table_schema", + "table_name", + "table_type", + "is_insertable_into", + "is_typed", + "creation_time", + "ddl", + "default_collation_name", + ), + from_=self._information_schema_source( + "TABLES", None, self._core_schema_or_refuse(expr.schema, "TABLES") + ), + where=ComparisonPredicate( + self, + ComparisonPredicate.coerce_operator("="), + Column(self, "table_name"), + Literal(self, expr.table_name), + ), + ) + return self.format_query_statement(query) + + def format_column_info_query_statement( + self, expr: "ColumnInfoQueryStatement" + ) -> Tuple[str, tuple]: + """The core spelling of the column question.""" + bigquery_expr = BigQueryColumnInfoQueryStatement( + self, + table_name=expr.table_name, + schema=self._core_schema_or_refuse(expr.schema, "COLUMNS"), + ) + return self.format_bigquery_column_info_query_statement(bigquery_expr) + + def format_index_info_query_statement( + self, expr: "IndexInfoQueryStatement" + ) -> Tuple[str, tuple]: + """The core spelling of the index (primary key) question.""" + bigquery_expr = BigQueryIndexInfoQueryStatement( + self, + table_name=expr.table_name, + schema=self._core_schema_or_refuse(expr.schema, "TABLE_CONSTRAINTS"), + ) + return self.format_bigquery_index_info_query_statement(bigquery_expr) + + def format_foreign_key_query_statement( + self, expr: "ForeignKeyQueryStatement" + ) -> Tuple[str, tuple]: + """Refuse: the catalog never names the referenced relation. + + The ``supports_*`` probe above already declines, so core's own entry + would refuse; it is implemented here to say *why*, and to say it the + same way whether the caller reached core's entry or the BigQuery one. + """ + del expr + raise UnsupportedFeatureError( + self.name, + "foreign key introspection", + "BigQuery's INFORMATION_SCHEMA reports that a foreign key " + "exists (TABLE_CONSTRAINTS.constraint_type = 'FOREIGN KEY') and " + "reports its columns (KEY_COLUMN_USAGE), but neither view names " + "the referenced table or its columns, and there is no " + "REFERENTIAL_CONSTRAINTS view. list_foreign_keys() therefore " + "returns an empty list rather than inventing the other side.", + ) + + def format_view_list_query_statement(self, expr: "ViewListQueryStatement") -> Tuple[str, tuple]: + """The core spelling of the view-list question.""" + bigquery_expr = BigQueryViewListQueryStatement( + self, + schema=self._core_schema_or_refuse(expr.schema, "VIEWS"), + include_system=expr.include_system, + ) + return self.format_bigquery_view_list_query_statement(bigquery_expr) + + def format_view_info_query_statement(self, expr: "ViewInfoQueryStatement") -> Tuple[str, tuple]: + """The core spelling of the single-view question.""" + bigquery_expr = BigQueryViewInfoQueryStatement( + self, + view_name=expr.view_name, + schema=self._core_schema_or_refuse(expr.schema, "VIEWS"), + ) + return self.format_bigquery_view_info_query_statement(bigquery_expr) + + def format_trigger_list_query_statement( + self, expr: "TriggerListQueryStatement" + ) -> Tuple[str, tuple]: + """Refuse: BigQuery has no triggers, so there is no catalog view.""" + del expr + raise UnsupportedFeatureError( + self.name, + "trigger introspection", + "BigQuery has no triggers, so there is no catalog view to query. " + "list_triggers() returns an empty list.", + ) + + def format_trigger_info_query_statement( + self, expr: "TriggerInfoQueryStatement" + ) -> Tuple[str, tuple]: + """Refuse: BigQuery has no triggers, so there is no catalog view.""" + del expr + raise UnsupportedFeatureError( + self.name, + "trigger introspection", + "BigQuery has no triggers, so there is no catalog view to query. " + "get_trigger_info() answers None.", + ) + + +__all__ = ["BigQueryIntrospectionMixin"] diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/namespace.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/namespace.py index c93c06d..f112d5f 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/namespace.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/namespace.py @@ -94,6 +94,14 @@ def validate_catalog_name(self, expr: Any) -> None: resolve. A project with no dataset is therefore refused here rather than rendered into SQL the server would reject. + The one documented exception is the project-level + ``INFORMATION_SCHEMA.SCHEMATA`` view, whose scope table reads + ``[PROJECT_ID.]INFORMATION_SCHEMA.SCHEMATA`` -- a project with no + inner namespace, reading the US multi-region. The other + ``INFORMATION_SCHEMA`` views all require a dataset or a region + qualifier, so they keep the refusal. + https://cloud.google.com/bigquery/docs/information-schema-datasets-schemata + The check runs while rendering, not while constructing, because only then is the dialect -- and therefore what this dialect can express -- known. It is reached from core's ``validate_namespace``, which calls it @@ -106,6 +114,8 @@ def validate_catalog_name(self, expr: Any) -> None: ValueError: *expr* carries a project but no dataset. """ if expr.catalog_name and not expr.schema_name: + if expr.name == "INFORMATION_SCHEMA.SCHEMATA": + return raise ValueError( f"BigQuery renders a project only together with a dataset; " f"{type(expr).__name__} {expr.name!r} carries " @@ -113,4 +123,4 @@ def validate_catalog_name(self, expr: Any) -> None: ) -__all__ = ["BigQueryNamespaceMixin"] \ No newline at end of file +__all__ = ["BigQueryNamespaceMixin"] diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_kind_identity.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_kind_identity.py index bcd98d2..3459955 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_kind_identity.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/dialect/test_bigquery_kind_identity.py @@ -20,8 +20,8 @@ import pkgutil import pytest - from rhosocial.activerecord.backend.expression.core import BaseExpression + from rhosocial.activerecord.backend.impl.bigquery.dialect import BigQueryDialect BIGQUERY_EXPR_PKG = "rhosocial.activerecord.backend.impl.bigquery.expression" @@ -59,13 +59,30 @@ def _declared_entry(cls): class TestThreeSourcesAgree: - def test_the_package_defines_the_four_materialized_view_statements(self): - """A guard on the sweep below: an empty package would pass it silently.""" + def test_the_package_defines_the_materialized_view_and_introspection_statements( + self, + ): + """A guard on the sweep below: an empty package would pass it silently. + + The set grew once, when the introspection statements arrived: the + four materialized-view statements and the nine INFORMATION_SCHEMA + questions. A class added without updating this guard is caught here + rather than passing the parametrised tests silently. + """ assert set(NODES) == { f"{BIGQUERY_EXPR_PKG}.materialized_view.BigQueryAlterMaterializedViewSetOptionsStatement", f"{BIGQUERY_EXPR_PKG}.materialized_view.BigQueryCreateMaterializedViewReplicaStatement", f"{BIGQUERY_EXPR_PKG}.materialized_view.BigQueryCreateMaterializedViewStatement", f"{BIGQUERY_EXPR_PKG}.materialized_view.BigQueryDropMaterializedViewStatement", + f"{BIGQUERY_EXPR_PKG}.introspection.BigQueryDatabaseInfoQueryStatement", + f"{BIGQUERY_EXPR_PKG}.introspection.BigQueryTableListQueryStatement", + f"{BIGQUERY_EXPR_PKG}.introspection.BigQueryColumnInfoQueryStatement", + f"{BIGQUERY_EXPR_PKG}.introspection.BigQueryIndexInfoQueryStatement", + f"{BIGQUERY_EXPR_PKG}.introspection.BigQueryViewListQueryStatement", + f"{BIGQUERY_EXPR_PKG}.introspection.BigQueryViewInfoQueryStatement", + f"{BIGQUERY_EXPR_PKG}.introspection.BigQueryDatasetListQueryStatement", + f"{BIGQUERY_EXPR_PKG}.introspection.BigQueryTableOptionsQueryStatement", + f"{BIGQUERY_EXPR_PKG}.introspection.BigQueryPartitionListQueryStatement", } @pytest.mark.parametrize("fqn", sorted(NODES)) @@ -75,8 +92,7 @@ def test_the_entry_is_format_plus_the_kind(self, fqn): entry = _declared_entry(cls) assert entry is not None, f"{fqn} declares no format_method" assert entry == "format_" + cls.kind(), ( - f"{fqn}: class name gives kind {cls.kind()!r}, but the class " - f"declares {entry!r}" + f"{fqn}: class name gives kind {cls.kind()!r}, but the class " f"declares {entry!r}" ) @pytest.mark.parametrize("fqn", sorted(NODES)) @@ -87,7 +103,7 @@ def test_the_kind_starts_with_the_backend_name(self, fqn): @pytest.mark.parametrize("fqn", sorted(NODES)) def test_the_class_name_ends_with_its_nature(self, fqn): - """§3: these four are statements, and their names say so.""" + """§3: these are statements, and their names say so.""" cls = NODES[fqn] assert cls.__name__.endswith("Statement"), cls.__name__ assert cls.kind().endswith("_statement"), cls.kind() @@ -106,10 +122,8 @@ class TestTheEntryResolvesAheadOfCore: ], ) def test_the_bigquery_mixin_owns_the_entry(self, entry): - assert ( - getattr(BigQueryDialect, entry).__qualname__.startswith( - "BigQueryMaterializedViewMixin." - ) + assert getattr(BigQueryDialect, entry).__qualname__.startswith( + "BigQueryMaterializedViewMixin." ) @pytest.mark.parametrize( @@ -121,8 +135,6 @@ def test_the_bigquery_mixin_owns_the_entry(self, entry): ) def test_the_core_statements_still_reach_the_bigquery_mixin(self, entry): """Core's own entry names are BigQuery's answers too, not core's.""" - assert ( - getattr(BigQueryDialect, entry).__qualname__.startswith( - "BigQueryMaterializedViewMixin." - ) + assert getattr(BigQueryDialect, entry).__qualname__.startswith( + "BigQueryMaterializedViewMixin." ) diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_cli_introspect.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_cli_introspect.py new file mode 100644 index 0000000..6dc4aeb --- /dev/null +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_cli_introspect.py @@ -0,0 +1,407 @@ +# tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_cli_introspect.py +"""CLI dispatch with a mocked backend: every subcommand, every format. + +No connection. ``_build_backend`` -- the one place the CLI constructs a +real backend -- is replaced with a stand-in whose introspector answers +canned objects, so what these tests measure is the CLI's own work: parsing +the subcommand and its options, wiring them onto the introspector call, +serializing the result into each output format, and exiting non-zero on the +two ways a run can fail (a missing positional name, a table that does not +exist). + +The dataset flag reaches the introspector as the ``schema`` argument -- +BigQuery's dataset *is* the schema level, which is what the CLI says in its +help and what the dispatch must carry. +""" + +import json + +import pytest +from rhosocial.activerecord.backend.introspection.types import ( + ColumnNullable, + IndexColumnInfo, + IndexInfo, + IndexType, + IntrospectionColumnInfo, + IntrospectionTableInfo, + IntrospectionViewInfo, + TableType, +) + +from rhosocial.activerecord.backend.impl.bigquery import cli as cli_package +from rhosocial.activerecord.backend.impl.bigquery.__main__ import main +from rhosocial.activerecord.backend.impl.bigquery.cli import introspect as cli_introspect + + +class _FakeIntrospector: + """Answers canned introspection results.""" + + def __init__(self): + self.calls = [] + + def list_tables(self, schema=None, include_system=False): + self.calls.append(("list_tables", schema, include_system)) + return self._tables_payload(schema) + + def _tables_payload(self, schema): + """The two canned tables, so a subclass records the call itself.""" + return [ + IntrospectionTableInfo( + name="orders", + schema=schema, + table_type=TableType.BASE_TABLE, + ), + IntrospectionTableInfo( + name="recent_orders", + schema=schema, + table_type=TableType.VIEW, + ), + ] + + def list_views(self, schema=None): + self.calls.append(("list_views", schema)) + return [ + IntrospectionViewInfo( + name="recent_orders", + schema=schema, + definition="SELECT * FROM orders", + check_option=None, + extra={"use_standard_sql": True}, + ) + ] + + def get_table_info(self, table_name, schema=None): + self.calls.append(("get_table_info", table_name, schema)) + if table_name != "orders": + return None + return IntrospectionTableInfo( + name="orders", + schema=schema, + table_type=TableType.BASE_TABLE, + columns=[ + IntrospectionColumnInfo( + name="id", + table_name="orders", + schema=schema, + ordinal_position=1, + data_type="INT64", + nullable=ColumnNullable.NOT_NULL, + ) + ], + indexes=[ + IndexInfo( + name="pk_orders", + table_name="orders", + schema=schema, + is_unique=True, + is_primary=True, + index_type=IndexType.UNKNOWN, + columns=[IndexColumnInfo(name="id", ordinal_position=1)], + extra={"constraint_type": "PRIMARY KEY"}, + ) + ], + ) + + def list_columns(self, table_name, schema=None): + self.calls.append(("list_columns", table_name, schema)) + return [ + IntrospectionColumnInfo( + name="id", + table_name=table_name, + schema=schema, + ordinal_position=1, + data_type="INT64", + nullable=ColumnNullable.NOT_NULL, + extra={"is_partitioning_column": False}, + ) + ] + + def list_datasets(self): + self.calls.append(("list_datasets",)) + return [ + { + "schema_name": "my_dataset", + "catalog_name": "my-project", + "location": "US", + } + ] + + +class _FakeBackend: + """A backend whose introspector is canned and whose client is a sentinel.""" + + def __init__(self, connection_config=None): + self.introspector = _FakeIntrospector() + self._client = object() + self.connected = False + self.disconnected = False + + def connect(self): + self.connected = True + + def disconnect(self): + self.disconnected = True + + +@pytest.fixture +def built(monkeypatch): + """Patch the CLI's one construction site to the fake backend.""" + constructed = [] + + def _fake_build_backend(args): + backend = _FakeBackend() + constructed.append(backend) + return backend + + monkeypatch.setattr(cli_introspect, "_build_backend", _fake_build_backend) + return constructed + + +def _run(argv): + """Run the CLI entry point with *argv*, as a subprocess would.""" + import sys + + old_argv = sys.argv + sys.argv = ["rhosocial-activerecord-bigquery"] + argv + try: + main() + finally: + sys.argv = old_argv + + +# --------------------------------------------------------------------------- +# Registration and dispatch +# --------------------------------------------------------------------------- + + +def test_every_subcommand_is_registered(): + from rhosocial.activerecord.backend.impl.bigquery.cli import COMMAND_NAMES + + assert set(COMMAND_NAMES) == { + "tables", + "views", + "table", + "columns", + "datasets", + } + + +def test_get_handler_routes_each_introspection_command(): + for name in cli_package.COMMAND_NAMES: + assert cli_package.get_handler(name) is cli_introspect.handle + + +def test_get_handler_refuses_an_unknown_command(): + with pytest.raises(SystemExit) as exc_info: + cli_package.get_handler("nope") + assert exc_info.value.code == 1 + + +def test_no_command_exits_non_zero(): + import sys + + old_argv = sys.argv + sys.argv = ["rhosocial-activerecord-bigquery"] + try: + with pytest.raises(SystemExit) as exc_info: + main() + finally: + sys.argv = old_argv + assert exc_info.value.code == 1 + + +def test_an_unknown_command_exits_non_zero(): + with pytest.raises(SystemExit) as exc_info: + _run(["nope"]) + # argparse refuses an unregistered subcommand before the handler runs. + assert exc_info.value.code == 2 + + +# --------------------------------------------------------------------------- +# The five subcommands, in each format +# --------------------------------------------------------------------------- + + +def test_tables_lists_tables_and_views_as_json(built, capsys): + _run(["tables", "-o", "json"]) + payload = json.loads(capsys.readouterr().out) + assert [row["name"] for row in payload] == ["orders", "recent_orders"] + assert payload[0]["table_type"] == "BASE_TABLE" + assert payload[1]["table_type"] == "VIEW" + backend = built[0] + assert backend.connected is True + assert backend.disconnected is True + assert backend.introspector.calls == [("list_tables", None, False)] + + +def test_tables_scopes_to_the_dataset_given(built, capsys): + _run(["tables", "--dataset", "my_dataset", "-o", "json"]) + payload = json.loads(capsys.readouterr().out) + assert payload[0]["schema"] == "my_dataset" + # The dataset reaches the introspector as the schema argument. + assert built[0].introspector.calls == [("list_tables", "my_dataset", False)] + + +def test_schema_flag_overrides_the_dataset_flag(built, capsys): + _run( + [ + "tables", + "--dataset", + "my_dataset", + "--schema", + "other_dataset", + "-o", + "json", + ] + ) + capsys.readouterr() + assert built[0].introspector.calls == [("list_tables", "other_dataset", False)] + + +def test_tables_as_csv(built, capsys): + _run(["tables", "--dataset", "my_dataset", "-o", "csv"]) + out = capsys.readouterr().out + assert "name,schema,table_type" in out + assert "orders,my_dataset,BASE_TABLE" in out + + +def test_tables_as_tsv(built, capsys): + _run(["tables", "--dataset", "my_dataset", "-o", "tsv"]) + out = capsys.readouterr().out + assert "name\tschema\ttable_type" in out + assert "orders\tmy_dataset\tBASE_TABLE" in out + + +def test_tables_as_table(built, capsys): + """``table`` is the default format; without rich it degrades to json.""" + _run(["tables"]) + out = capsys.readouterr().out + assert "orders" in out + + +def test_views_lists_views_as_json(built, capsys): + _run(["views", "--dataset", "my_dataset", "-o", "json"]) + payload = json.loads(capsys.readouterr().out) + assert [row["name"] for row in payload] == ["recent_orders"] + assert payload[0]["definition"] == "SELECT * FROM orders" + assert payload[0]["extra"] == {"use_standard_sql": True} + assert built[0].introspector.calls == [("list_views", "my_dataset")] + + +def test_datasets_lists_datasets_as_json(built, capsys): + _run(["datasets", "-o", "json"]) + payload = json.loads(capsys.readouterr().out) + assert [row["schema_name"] for row in payload] == ["my_dataset"] + assert payload[0]["catalog_name"] == "my-project" + assert built[0].introspector.calls == [("list_datasets",)] + + +def test_table_prints_columns_and_the_primary_key(built, capsys): + _run(["table", "orders", "--dataset", "my_dataset", "-o", "json"]) + out = capsys.readouterr().out + # Two displays: the columns, then the primary key. Each is a + # pretty-printed JSON array, back to back, so decode them in order. + decoder = json.JSONDecoder() + columns, index = decoder.raw_decode(out.lstrip()) + rest = out.lstrip()[decoder.raw_decode(out.lstrip())[1] :].lstrip() + indexes, _end = decoder.raw_decode(rest) + assert [row["name"] for row in columns] == ["id"] + assert [row["name"] for row in indexes] == ["pk_orders"] + assert indexes[0]["is_primary"] is True + assert indexes[0]["extra"] == {"constraint_type": "PRIMARY KEY"} + assert built[0].introspector.calls == [("get_table_info", "orders", "my_dataset")] + + +def test_columns_lists_one_tables_columns_as_json(built, capsys): + _run(["columns", "orders", "--dataset", "my_dataset", "-o", "json"]) + payload = json.loads(capsys.readouterr().out) + assert [row["name"] for row in payload] == ["id"] + assert payload[0]["nullable"] == "NOT_NULL" + assert built[0].introspector.calls == [("list_columns", "orders", "my_dataset")] + + +# --------------------------------------------------------------------------- +# Failure paths: each one a non-zero exit naming the reason +# --------------------------------------------------------------------------- + + +def test_table_without_a_name_exits_non_zero(capsys): + with pytest.raises(SystemExit) as exc_info: + _run(["table", "-o", "json"]) + # The positional name is required, so argparse refuses before the + # handler runs -- a different refusal from a table that does not exist. + assert exc_info.value.code == 2 + assert "required" in capsys.readouterr().err + + +def test_columns_without_a_name_exits_non_zero(capsys): + with pytest.raises(SystemExit) as exc_info: + _run(["columns", "-o", "json"]) + assert exc_info.value.code == 2 + assert "required" in capsys.readouterr().err + + +def test_an_unknown_table_exits_non_zero(built, capsys): + with pytest.raises(SystemExit) as exc_info: + _run(["table", "missing", "-o", "json"]) + assert exc_info.value.code == 1 + assert "not found" in capsys.readouterr().err + + +def test_a_value_error_from_the_introspector_exits_non_zero(built, capsys, monkeypatch): + """A missing dataset: the introspector raises with the instruction.""" + backend = _FakeBackend() + + def _exploding_list_tables(schema=None, include_system=False): + raise ValueError( + "BigQuery introspection needs a dataset: pass schema= (the " + "dataset ID) or configure BigQueryConnectionConfig(dataset=...)." + ) + + backend.introspector.list_tables = _exploding_list_tables + monkeypatch.setattr(cli_introspect, "_build_backend", lambda args: backend) + + with pytest.raises(SystemExit) as exc_info: + _run(["tables", "-o", "json"]) + assert exc_info.value.code == 1 + assert "pass schema=" in capsys.readouterr().err + + +def test_an_unknown_output_format_is_refused_by_argparse(): + with pytest.raises(SystemExit) as exc_info: + _run(["tables", "-o", "yaml"]) + assert exc_info.value.code == 2 + + +# --------------------------------------------------------------------------- +# The async path runs the same questions through the same CLI +# --------------------------------------------------------------------------- + + +def test_tables_as_json_async(built, capsys, monkeypatch): + """The async backend answers the same CLI through awaited questions.""" + + class _AsyncIntrospector(_FakeIntrospector): + async def list_tables(self, schema=None, include_system=False): + self.calls.append(("list_tables", schema, include_system)) + return self._tables_payload(schema) + + class _AsyncFakeBackend(_FakeBackend): + async def connect(self): + self.connected = True + + async def disconnect(self): + self.disconnected = True + + backend = _AsyncFakeBackend() + backend.introspector = _AsyncIntrospector() + monkeypatch.setattr(cli_introspect, "_build_backend", lambda args: backend) + + _run(["tables", "--use-async", "--dataset", "my_dataset", "-o", "json"]) + payload = json.loads(capsys.readouterr().out) + assert [row["name"] for row in payload] == ["orders", "recent_orders"] + assert backend.connected is True + assert backend.disconnected is True + # One question, recorded once: the async method records its own call and + # builds the same list without re-recording. + assert backend.introspector.calls == [("list_tables", "my_dataset", False)] diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_cache.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_cache.py new file mode 100644 index 0000000..f368138 --- /dev/null +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_cache.py @@ -0,0 +1,284 @@ +# tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection\test_introspection_cache.py +"""The introspection cache: one question per answer, on both backends. + +The executor counts the questions it is asked. A cold pass asks once per +distinct question; a warm pass asks none, because every answer is cached +under the key that question answered. Nothing here touches a server: the +executor is a fake that answers canned rows and counts. + +The BigQuery-only questions are covered too, because they are the ones with +the most interesting keys -- ``list_table_options`` and +``list_partitions`` share the table scope but answer different things, so +they must not answer each other. +""" + +import pytest + +from rhosocial.activerecord.backend.impl.bigquery.config import ( + BigQueryConnectionConfig, +) +from rhosocial.activerecord.backend.impl.bigquery.dialect import BigQueryDialect +from rhosocial.activerecord.backend.impl.bigquery.introspection import ( + AsyncBigQueryIntrospector, + SyncBigQueryIntrospector, +) + +TABLES_ROWS = [ + { + "table_catalog": "my-project", + "table_schema": "my_dataset", + "table_name": "orders", + "table_type": "BASE TABLE", + }, + {"table_name": "payments", "table_type": "BASE TABLE"}, +] + +COLUMNS_ROWS = [ + { + "column_name": "id", + "data_type": "INT64", + "is_nullable": "NO", + "ordinal_position": 1, + "is_hidden": "NO", + }, + { + "column_name": "amount", + "data_type": "NUMERIC", + "is_nullable": "YES", + "ordinal_position": 2, + "is_hidden": "NO", + "is_partitioning_column": "NO", + }, +] + +DATASETS_ROWS = [{"schema_name": "my_dataset", "catalog_name": "my-project"}] + +OPTIONS_ROWS = [ + { + "table_name": "orders", + "option_name": "require_partition_filter", + "option_value": "true", + "option_type": "BOOL", + } +] + +PARTITIONS_ROWS = [{"table_name": "orders", "partition_id": "20260101", "total_rows": 5}] + +CONSTRAINTS_ROWS = [ + { + "constraint_name": "pk_orders", + "constraint_type": "PRIMARY KEY", + "column_name": "id", + "ordinal_position": 1, + } +] + + +class _BackendStandIn: + def __init__(self, project="my-project", dataset="my_dataset"): + self.dialect = BigQueryDialect() + self.config = BigQueryConnectionConfig(project=project, dataset=dataset) + self._version = (3, 0, 0) + + +def _rows_for(sql): + """The canned rows one question's SQL selects.""" + if "SCHEMATA" in sql: + return DATASETS_ROWS + if "TABLE_OPTIONS" in sql: + return OPTIONS_ROWS + if "PARTITIONS" in sql: + return PARTITIONS_ROWS + if "TABLE_CONSTRAINTS" in sql: + return CONSTRAINTS_ROWS + if "COLUMNS" in sql: + return COLUMNS_ROWS + if "TABLES" in sql: + return TABLES_ROWS + return [] + + +class _CountingExecutor: + """Answers from :func:`_rows_for`, counting every question.""" + + def __init__(self): + self.questions = [] + + def execute(self, sql, params=()): + self.questions.append(sql) + return _rows_for(sql) + + +class _CountingAsyncExecutor(_CountingExecutor): + async def execute(self, sql, params=()): + self.questions.append(sql) + return _rows_for(sql) + + +@pytest.fixture +def sync_case(): + executor = _CountingExecutor() + introspector = SyncBigQueryIntrospector(_BackendStandIn(), executor) + return introspector, executor + + +@pytest.fixture +def async_case(): + executor = _CountingAsyncExecutor() + introspector = AsyncBigQueryIntrospector(_BackendStandIn(), executor) + return introspector, executor + + +def _walk_sync(introspector): + """Every question once, the order a caller would ask them in.""" + datasets = introspector.list_datasets() + tables = introspector.list_tables("my_dataset") + columns = introspector.list_columns("orders", "my_dataset") + primary_key = introspector.get_primary_key("orders", "my_dataset") + table_info = introspector.get_table_info("orders", "my_dataset") + storage = introspector.get_table_storage_info("orders", "my_dataset") + database_info = introspector.get_database_info() + return datasets, tables, columns, primary_key, table_info, storage, database_info + + +async def _walk_async(introspector): + datasets = await introspector.list_datasets() + tables = await introspector.list_tables("my_dataset") + columns = await introspector.list_columns("orders", "my_dataset") + primary_key = await introspector.get_primary_key("orders", "my_dataset") + table_info = await introspector.get_table_info("orders", "my_dataset") + storage = await introspector.get_table_storage_info("orders", "my_dataset") + database_info = await introspector.get_database_info() + return datasets, tables, columns, primary_key, table_info, storage, database_info + + +# --------------------------------------------------------------------------- +# Sync +# --------------------------------------------------------------------------- + + +def test_cold_pass_asks_each_question_once(sync_case): + introspector, executor = sync_case + datasets, tables, columns, primary_key, table_info, storage, info = _walk_sync(introspector) + assert [d.schema_name for d in datasets] == ["my_dataset"] + assert [t.name for t in tables] == ["orders", "payments"] + assert [c.name for c in columns] == ["id", "amount"] + assert primary_key.name == "pk_orders" + assert table_info.name == "orders" + assert storage.partitions[0].partition_id == "20260101" + assert info.name == "my-project" + # datasets + tables + columns + primary key + table info (one table + # question: list_tables is cached, list_columns/list_indexes/ + # list_foreign_keys each add one) + storage's three + database info. + # The exact number is less load-bearing than the shape: every question + # appears once and none twice. + assert len(executor.questions) == len( + set(executor.questions) + ), "the same SQL was issued twice on the cold pass" + cold = len(executor.questions) + assert cold > 0 + + +def test_warm_pass_asks_nothing(sync_case): + introspector, executor = sync_case + _walk_sync(introspector) + cold = len(executor.questions) + _walk_sync(introspector) + assert ( + len(executor.questions) == cold + ), "the warm pass re-issued questions the cache should have answered" + + +def test_the_answers_are_the_same_objects_not_new_ones(sync_case): + introspector, _executor = sync_case + first = introspector.list_tables("my_dataset") + second = introspector.list_tables("my_dataset") + assert first is second + + +def test_options_and_partitions_do_not_answer_each_other(sync_case): + introspector, executor = sync_case + options = introspector.list_table_options("orders", "my_dataset") + partitions = introspector.list_partitions("orders", "my_dataset") + storage = introspector.get_table_storage_info("orders", "my_dataset") + # Three questions, cached separately under their own keys: the options, + # the partitions, and the columns the storage shape reads for the + # partitioning and clustering columns. + assert len(executor.questions) == 3 + assert [o.option_name for o in options] == ["require_partition_filter"] + assert [p.partition_id for p in partitions] == ["20260101"] + # The storage shape reuses those two cached answers rather than + # re-asking them. + assert storage.options is options + assert storage.partitions is partitions + + +def test_a_different_dataset_is_a_different_answer(sync_case): + introspector, executor = sync_case + introspector.list_tables("my_dataset") + introspector.list_tables("other_dataset") + assert len(executor.questions) == 2 + + +def test_a_different_table_type_is_a_different_table_answer(sync_case): + introspector, executor = sync_case + introspector.list_tables("my_dataset") + introspector.list_tables("my_dataset", table_type="VIEW") + assert len(executor.questions) == 2 + + +def test_invalidating_drops_what_it_names(sync_case): + introspector, executor = sync_case + introspector.list_columns("orders", "my_dataset") + introspector.invalidate_cache(name="orders") + introspector.list_columns("orders", "my_dataset") + assert len(executor.questions) == 2 + + +def test_clearing_drops_everything(sync_case): + introspector, executor = sync_case + introspector.list_datasets() + introspector.list_tables("my_dataset") + introspector.clear_cache() + introspector.list_datasets() + introspector.list_tables("my_dataset") + assert len(executor.questions) == 4 + + +# --------------------------------------------------------------------------- +# Async +# --------------------------------------------------------------------------- + + +async def test_cold_and_warm_pass_async(async_case): + introspector, executor = async_case + datasets, tables, columns, primary_key, table_info, storage, info = await _walk_async( + introspector + ) + assert [d.schema_name for d in datasets] == ["my_dataset"] + assert [t.name for t in tables] == ["orders", "payments"] + assert [c.name for c in columns] == ["id", "amount"] + assert primary_key.name == "pk_orders" + assert table_info.name == "orders" + assert storage.partitions[0].partition_id == "20260101" + assert info.name == "my-project" + cold = len(executor.questions) + assert cold > 0 + assert len(executor.questions) == len(set(executor.questions)) + + await _walk_async(introspector) + assert ( + len(executor.questions) == cold + ), "the warm pass re-issued questions the cache should have answered" + + +async def test_storage_shape_async_reuses_the_cached_answers(async_case): + introspector, executor = async_case + options = await introspector.list_table_options("orders", "my_dataset") + partitions = await introspector.list_partitions("orders", "my_dataset") + storage = await introspector.get_table_storage_info("orders", "my_dataset") + # options + partitions, then the columns question inside the shape. + assert len(executor.questions) == 3 + assert storage.options is options + assert storage.partitions is partitions + assert storage.clustering_columns == [] diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_parsing.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_parsing.py new file mode 100644 index 0000000..b500730 --- /dev/null +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_parsing.py @@ -0,0 +1,582 @@ +# tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_parsing.py +"""Row -> dataclass parsing, fed the documented column names and values. + +No connection and no SQL: these tests call the ``_parse_*`` methods with +rows spelled exactly as the documentation spells them (lowercase column +names, ``YES``/``NO`` flags, the documented ``table_type`` words), which is +what BigQuery returns. The parsing rules pinned here are the ones a server +answer would enforce: pseudo columns dropped, ``is_nullable`` mapped, +identity fields under their documented names, and one unreadable type +degrading one column rather than the table. + +Docs: + +* TABLES https://cloud.google.com/bigquery/docs/information-schema-tables +* COLUMNS https://cloud.google.com/bigquery/docs/information-schema-columns +* TABLE_CONSTRAINTS https://cloud.google.com/bigquery/docs/information-schema-table-constraints +* KEY_COLUMN_USAGE https://cloud.google.com/bigquery/docs/information-schema-key-column-usage +* VIEWS https://cloud.google.com/bigquery/docs/information-schema-views +* SCHEMATA https://cloud.google.com/bigquery/docs/information-schema-datasets-schemata +* TABLE_OPTIONS https://cloud.google.com/bigquery/docs/information-schema-table-options +* PARTITIONS https://cloud.google.com/bigquery/docs/information-schema-partitions +""" + +from datetime import datetime + +import pytest +from rhosocial.activerecord.backend.introspection.types import ( + ColumnNullable, + IndexType, + TableType, +) + +from rhosocial.activerecord.backend.impl.bigquery.config import ( + BigQueryConnectionConfig, +) +from rhosocial.activerecord.backend.impl.bigquery.dialect import BigQueryDialect +from rhosocial.activerecord.backend.impl.bigquery.introspection import ( + AsyncBigQueryIntrospector, + SyncBigQueryIntrospector, +) +from rhosocial.activerecord.backend.impl.bigquery.introspection.types import ( + BigQueryTableStorageInfo, +) + + +class _BackendStandIn: + def __init__(self, project="my-project", dataset="my_dataset"): + self.dialect = BigQueryDialect() + self.config = BigQueryConnectionConfig(project=project, dataset=dataset) + self._version = (3, 0, 0) + + +class _RecordingExecutor: + def __init__(self): + self.calls = [] + + def execute(self, sql, params=()): + self.calls.append((sql, params)) + return [] + + +class _RecordingAsyncExecutor: + def __init__(self): + self.calls = [] + + async def execute(self, sql, params=()): + self.calls.append((sql, params)) + return [] + + +@pytest.fixture +def introspector(): + return SyncBigQueryIntrospector(_BackendStandIn(), _RecordingExecutor()) + + +@pytest.fixture +def async_introspector(): + return AsyncBigQueryIntrospector(_BackendStandIn(), _RecordingAsyncExecutor()) + + +# --------------------------------------------------------------------------- +# Tables +# --------------------------------------------------------------------------- + + +def test_tables_parse_the_documented_type_words(introspector): + rows = [ + { + "table_catalog": "my-project", + "table_schema": "my_dataset", + "table_name": "orders", + "table_type": "BASE TABLE", + "is_insertable_into": "YES", + "is_typed": "NO", + "creation_time": datetime(2026, 10, 24, 12, 0, 0), + "ddl": "CREATE TABLE `my-project.my_dataset.orders` (id INT64)", + "default_collation_name": None, + }, + { + "table_name": "orders_clone", + "table_type": "CLONE", + "is_insertable_into": "YES", + }, + { + "table_name": "orders_snapshot", + "table_type": "SNAPSHOT", + }, + { + "table_name": "my_view", + "table_type": "VIEW", + "is_insertable_into": "NO", + }, + { + "table_name": "my_mv", + "table_type": "MATERIALIZED VIEW", + }, + { + "table_name": "external_table", + "table_type": "EXTERNAL", + }, + ] + tables = introspector._parse_tables(rows, "my_dataset") + assert [t.name for t in tables] == [ + "orders", + "orders_clone", + "orders_snapshot", + "my_view", + "my_mv", + "external_table", + ] + assert [t.table_type for t in tables] == [ + TableType.BASE_TABLE, + TableType.BASE_TABLE, # a clone is a table for "can I select from it" + TableType.BASE_TABLE, # a snapshot too + TableType.VIEW, + TableType.VIEW, # a materialized view is reported as a view + TableType.EXTERNAL, + ] + orders = tables[0] + assert orders.schema == "my_dataset" + assert orders.create_time == "2026-10-24T12:00:00" + assert orders.extra["bigquery_table_type"] == "BASE TABLE" + assert orders.extra["is_insertable_into"] is True + assert orders.extra["ddl"].startswith("CREATE TABLE") + # The original spelling survives, so a clone is not mistaken for a + # plain table downstream. + assert tables[1].extra["bigquery_table_type"] == "CLONE" + + +def test_tables_default_unknown_spellings_to_base_table(introspector): + tables = introspector._parse_tables([{"table_name": "t", "table_type": "SOMETHING NEW"}], "ds") + assert tables[0].table_type is TableType.BASE_TABLE + assert tables[0].extra["bigquery_table_type"] == "SOMETHING NEW" + + +def test_tables_accept_uppercase_row_keys(introspector): + """A driver or fixture that preserves the catalog's own spelling.""" + tables = introspector._parse_tables( + [{"TABLE_NAME": "orders", "TABLE_TYPE": "BASE TABLE"}], "my_dataset" + ) + assert tables[0].name == "orders" + assert tables[0].table_type is TableType.BASE_TABLE + + +# --------------------------------------------------------------------------- +# Columns +# --------------------------------------------------------------------------- + + +def _column_row(**overrides): + row = { + "table_name": "orders", + "column_name": "id", + "ordinal_position": 1, + "is_nullable": "NO", + "data_type": "INT64", + "is_generated": "NEVER", + "generation_expression": None, + "is_stored": None, + "is_hidden": "NO", + "is_system_defined": "NO", + "is_partitioning_column": "NO", + "clustering_ordinal_position": None, + "collation_name": None, + "column_default": None, + "rounding_mode": None, + "is_identity": "NO", + "identity_generation": None, + "identity_start": None, + "identity_increment": None, + "identity_maximum": None, + "identity_minimum": None, + "identity_cycle": None, + } + row.update(overrides) + return row + + +def test_columns_parse_nullability_type_and_position(introspector): + columns = introspector._parse_columns( + [ + _column_row(), + _column_row( + column_name="note", + ordinal_position=2, + data_type="STRING", + is_nullable="YES", + ), + _column_row( + column_name="unmarked", + ordinal_position=3, + data_type="STRING", + is_nullable="no", + ), + ], + "orders", + "my_dataset", + ) + assert [(c.name, c.nullable, c.data_type, c.ordinal_position) for c in columns] == [ + ("id", ColumnNullable.NOT_NULL, "int64", 1), + ("note", ColumnNullable.NULLABLE, "string", 2), + # The catalog spells YES/NO uppercase; a lowercase answer the + # driver passed through means the same thing. + ("unmarked", ColumnNullable.NOT_NULL, "string", 3), + ] + assert columns[0].table_name == "orders" + assert columns[0].schema == "my_dataset" + # A readable type is parsed to a DataType for the schema differ. + assert columns[0].parsed_data_type is not None + assert columns[0].data_type_full == "INT64" + # BigQuery has no core auto-increment flag; identity is in ``extra``. + assert columns[0].is_auto_increment is False + + +def test_columns_drop_pseudo_columns(introspector): + columns = introspector._parse_columns( + [ + _column_row(), + _column_row( + column_name="_PARTITIONTIME", + data_type="TIMESTAMP", + ordinal_position=None, + is_hidden="YES", + is_system_defined="YES", + ), + ], + "orders", + "my_dataset", + ) + assert [c.name for c in columns] == ["id"] + + +def test_columns_parse_identity_columns_under_the_documented_names(introspector): + columns = introspector._parse_columns( + [ + _column_row( + column_name="order_id", + is_identity="YES", + identity_generation="ALWAYS", + identity_start="1", + identity_increment="1", + identity_maximum="1000", + identity_minimum="1", + identity_cycle="NEVER", + ) + ], + "orders", + "my_dataset", + ) + extra = columns[0].extra + assert extra["is_identity"] is True + assert extra["identity_generation"] == "ALWAYS" + assert extra["identity_start"] == 1 + assert extra["identity_increment"] == 1 + assert extra["identity_maximum"] == 1000 + assert extra["identity_minimum"] == 1 + assert extra["identity_cycle"] == "NEVER" + + +def test_columns_parse_partitioning_and_clustering_flags(introspector): + columns = introspector._parse_columns( + [ + _column_row( + column_name="created", + data_type="DATE", + ordinal_position=2, + is_partitioning_column="YES", + ), + _column_row( + column_name="region", + ordinal_position=3, + is_partitioning_column="NO", + clustering_ordinal_position="2", + ), + _column_row( + column_name="tier", + ordinal_position=4, + is_partitioning_column="NO", + clustering_ordinal_position="1", + ), + ], + "orders", + "my_dataset", + ) + partitioning = [c.name for c in columns if c.extra["is_partitioning_column"] is True] + assert partitioning == ["created"] + clustering = sorted( + ( + (c.extra["clustering_ordinal_position"], c.name) + for c in columns + if c.extra["clustering_ordinal_position"] is not None + ) + ) + assert [name for _position, name in clustering] == ["tier", "region"] + + +def test_an_unreadable_type_degrades_one_column_with_a_warning(introspector): + with pytest.warns(RuntimeWarning, match="STRUCT"): + columns = introspector._parse_columns( + [ + _column_row(), + _column_row(column_name="payload", data_type="STRUCT"), + ], + "orders", + "my_dataset", + ) + assert columns[0].parsed_data_type is not None + # The unreadable column keeps its string and leaves the parsed object + # unset, which is the differ's documented fallback. + assert columns[1].parsed_data_type is None + assert columns[1].data_type == "struct" + + +# --------------------------------------------------------------------------- +# Indexes, views, datasets, options, partitions +# --------------------------------------------------------------------------- + + +def test_indexes_group_constraint_rows_by_name(introspector): + indexes = introspector._parse_indexes( + [ + { + "constraint_name": "pk_orders", + "constraint_type": "PRIMARY KEY", + "column_name": "id", + "ordinal_position": 1, + }, + { + "constraint_name": "pk_orders", + "constraint_type": "PRIMARY KEY", + "column_name": "tenant_id", + "ordinal_position": 2, + }, + ], + "orders", + "my_dataset", + ) + assert len(indexes) == 1 + index = indexes[0] + assert index.name == "pk_orders" + assert index.is_primary is True + assert index.is_unique is True + assert index.index_type is IndexType.UNKNOWN + assert [(c.name, c.ordinal_position) for c in index.columns] == [ + ("id", 1), + ("tenant_id", 2), + ] + assert index.extra["constraint_type"] == "PRIMARY KEY" + + +def test_a_foreign_key_constraint_row_parses_as_not_primary(introspector): + indexes = introspector._parse_indexes( + [ + { + "constraint_name": "fk_orders", + "constraint_type": "FOREIGN KEY", + "column_name": "user_id", + "ordinal_position": 1, + } + ], + "orders", + "my_dataset", + ) + assert indexes[0].is_primary is False + + +def test_views_parse_the_definition_and_standard_sql_flag(introspector): + views = introspector._parse_views( + [ + { + "table_catalog": "my-project", + "table_schema": "my_dataset", + "table_name": "recent_orders", + "view_definition": "SELECT * FROM orders", + "check_option": None, + "use_standard_sql": "YES", + } + ], + "my_dataset", + ) + view = views[0] + assert view.name == "recent_orders" + assert view.schema == "my_dataset" + assert view.definition == "SELECT * FROM orders" + assert view.check_option is None + assert view.extra["use_standard_sql"] is True + + +def test_single_view_info_returns_none_for_no_rows(introspector): + assert introspector._parse_view_info([], "missing", "my_dataset") is None + + +def test_single_view_info_parses_the_first_row(introspector): + info = introspector._parse_view_info( + [ + { + "table_name": "v", + "view_definition": "SELECT 1", + "check_option": None, + "use_standard_sql": "NO", + } + ], + "v", + "my_dataset", + ) + assert info.name == "v" + assert info.extra["use_standard_sql"] is False + + +def test_datasets_parse_the_schemata_columns(introspector): + created = datetime(2026, 10, 24, 12, 0, 0) + datasets = introspector._parse_datasets( + [ + { + "catalog_name": "my-project", + "schema_name": "my_dataset", + "schema_owner": None, + "creation_time": created, + "last_modified_time": created, + "location": "US", + "ddl": "CREATE SCHEMA my_dataset", + "default_collation_name": None, + "sync_status": None, + } + ] + ) + dataset = datasets[0] + assert dataset.schema_name == "my_dataset" + assert dataset.catalog_name == "my-project" + assert dataset.location == "US" + assert dataset.creation_time == created + assert dataset.ddl == "CREATE SCHEMA my_dataset" + assert dataset.schema_owner is None + + +def test_table_options_parse_one_row_per_option(introspector): + options = introspector._parse_table_options( + [ + { + "table_name": "orders", + "option_name": "partition_expiration_days", + "option_value": "90", + "option_type": "FLOAT64", + "table_catalog": "my-project", + "table_schema": "my_dataset", + }, + { + "table_name": "orders", + "option_name": "labels", + "option_value": '[("env", "prod")]', + "option_type": "ARRAY>", + }, + ] + ) + assert [(o.option_name, o.option_type, o.option_value) for o in options] == [ + ("partition_expiration_days", "FLOAT64", "90"), + ("labels", "ARRAY>", '[("env", "prod")]'), + ] + assert options[0].table_catalog == "my-project" + + +def test_partitions_parse_counts_and_tiers(introspector): + modified = datetime(2026, 10, 24, 12, 0, 0) + partitions = introspector._parse_partitions( + [ + { + "table_name": "orders", + "partition_id": "20260101", + "total_rows": "5", + "total_logical_bytes": "1024", + "total_billable_bytes": "512", + "last_modified_time": modified, + "storage_tier": "ACTIVE", + }, + { + "table_name": "orders", + "partition_id": "__NULL__", + "total_rows": 0, + "storage_tier": "LONG_TERM", + }, + { + # An unpartitioned table answers one row with a NULL id. + "table_name": "plain", + "partition_id": None, + "total_rows": 3, + }, + ] + ) + assert [p.partition_id for p in partitions] == [ + "20260101", + "__NULL__", + None, + ] + assert partitions[0].total_rows == 5 + assert partitions[0].total_logical_bytes == 1024 + assert partitions[0].storage_tier == "ACTIVE" + assert partitions[1].storage_tier == "LONG_TERM" + assert partitions[0].last_modified_time == modified + + +def test_database_info_reports_the_project_and_the_backend_level(introspector): + info = introspector._parse_database_info([{"catalog_name": "my-project"}]) + assert info.name == "my-project" + assert info.vendor == "BigQuery" + assert info.version_tuple == (3, 0, 0) + assert info.extra["project"] == "my-project" + assert info.extra["dataset"] == "my_dataset" + assert info.extra["version_source"] == ("backend feature level, not a server version") + + +def test_database_info_falls_back_to_the_configured_project(introspector): + info = introspector._parse_database_info([]) + assert info.name == "my-project" + + +# --------------------------------------------------------------------------- +# Storage shape: the three answers composed +# --------------------------------------------------------------------------- + + +def test_storage_shape_composes_options_partitions_and_columns(introspector): + columns = introspector._parse_columns( + [ + _column_row( + column_name="created", + data_type="DATE", + ordinal_position=2, + is_partitioning_column="YES", + ), + _column_row( + column_name="tier", + ordinal_position=3, + clustering_ordinal_position=1, + ), + ], + "orders", + "my_dataset", + ) + storage = BigQueryTableStorageInfo( + table_name="orders", + schema="my_dataset", + options=introspector._parse_table_options( + [ + { + "table_name": "orders", + "option_name": "require_partition_filter", + "option_value": "true", + "option_type": "BOOL", + } + ] + ), + partitions=introspector._parse_partitions( + [{"table_name": "orders", "partition_id": "20260101", "total_rows": 5}] + ), + partitioning_columns=introspector._partitioning_columns(columns), + clustering_columns=introspector._clustering_columns(columns), + ) + assert storage.table_name == "orders" + assert [o.option_name for o in storage.options] == ["require_partition_filter"] + assert [p.partition_id for p in storage.partitions] == ["20260101"] + assert storage.partitioning_columns == ["created"] + assert storage.clustering_columns == ["tier"] diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_sql.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_sql.py new file mode 100644 index 0000000..27701fe --- /dev/null +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_sql.py @@ -0,0 +1,342 @@ +# tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_sql.py +"""The SQL shape each introspection question renders to. + +No connection: the statements render through ``BigQueryDialect`` on their +own, and the ``_build_*_sql`` overrides run against a backend stand-in that +carries the three attributes the introspector reads (``dialect``, +``config``, ``_version``). + +What is pinned here is the part of the contract a server would enforce: +the backtick-quoted ``project.dataset`` qualifier in front of the +``INFORMATION_SCHEMA`` view, the projection list documented per view, the +bind marker where a caller's value goes (never interpolated), and the +``ORDER BY`` each question ends with. The two questions BigQuery cannot +answer refuse rather than render. + +Statement shapes are read from the documentation, not executed -- no +BigQuery instance was reachable while writing them: + +* TABLES https://cloud.google.com/bigquery/docs/information-schema-tables +* COLUMNS https://cloud.google.com/bigquery/docs/information-schema-columns +* TABLE_CONSTRAINTS https://cloud.google.com/bigquery/docs/information-schema-table-constraints +* KEY_COLUMN_USAGE https://cloud.google.com/bigquery/docs/information-schema-key-column-usage +* VIEWS https://cloud.google.com/bigquery/docs/information-schema-views +* SCHEMATA https://cloud.google.com/bigquery/docs/information-schema-datasets-schemata +* TABLE_OPTIONS https://cloud.google.com/bigquery/docs/information-schema-table-options +* PARTITIONS https://cloud.google.com/bigquery/docs/information-schema-partitions +""" + +import pytest +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +from rhosocial.activerecord.backend.impl.bigquery.config import ( + BigQueryConnectionConfig, +) +from rhosocial.activerecord.backend.impl.bigquery.dialect import BigQueryDialect +from rhosocial.activerecord.backend.impl.bigquery.expression.introspection import ( + BigQueryColumnInfoQueryStatement, + BigQueryDatabaseInfoQueryStatement, + BigQueryDatasetListQueryStatement, + BigQueryIndexInfoQueryStatement, + BigQueryPartitionListQueryStatement, + BigQueryTableListQueryStatement, + BigQueryTableOptionsQueryStatement, + BigQueryViewInfoQueryStatement, + BigQueryViewListQueryStatement, +) +from rhosocial.activerecord.backend.impl.bigquery.introspection import ( + SyncBigQueryIntrospector, +) + + +class _BackendStandIn: + """The three attributes the introspector reads off a backend.""" + + def __init__( + self, + project: str = "my-project", + dataset: str = "my_dataset", + location: str = None, + ): + self.dialect = BigQueryDialect() + self.config = BigQueryConnectionConfig(project=project, dataset=dataset, location=location) + self._version = (3, 0, 0) + + +class _RecordingExecutor: + """Records the questions, answers nothing.""" + + def __init__(self): + self.calls = [] + + def execute(self, sql, params=()): + self.calls.append((sql, params)) + return [] + + +@pytest.fixture +def dialect(): + return BigQueryDialect() + + +@pytest.fixture +def introspector(): + executor = _RecordingExecutor() + return SyncBigQueryIntrospector(_BackendStandIn(), executor), executor + + +# --------------------------------------------------------------------------- +# The dataset qualifier is the dialect's spelling, quoted as documented +# --------------------------------------------------------------------------- + + +def test_database_info_renders_the_documented_system_variable(dialect): + sql, params = BigQueryDatabaseInfoQueryStatement(dialect).to_sql() + assert sql == "SELECT @@project_id AS catalog_name" + assert params == () + + +def test_tables_query_qualifies_the_dataset_with_the_project(dialect): + sql, params = BigQueryTableListQueryStatement( + dialect, schema="my_dataset", catalog="my-project" + ).to_sql() + assert "FROM `my-project`.`my_dataset`.INFORMATION_SCHEMA.TABLES" in sql + # The projection is the documented one, columns listed for stability. + for column in ( + "table_catalog", + "table_schema", + "table_name", + "table_type", + "is_insertable_into", + "is_typed", + "creation_time", + "ddl", + "default_collation_name", + ): + assert f"`{column}`" in sql + assert sql.rstrip().endswith("ORDER BY `table_name`") + assert params == () + + +def test_tables_query_without_project_leaves_the_project_unqualified(dialect): + sql, _params = BigQueryTableListQueryStatement(dialect, schema="my_dataset").to_sql() + assert "FROM `my_dataset`.INFORMATION_SCHEMA.TABLES" in sql + assert "my-project" not in sql + + +def test_include_views_false_renders_not_in_over_the_view_types(dialect): + sql, params = BigQueryTableListQueryStatement( + dialect, schema="ds", include_views=False + ).to_sql() + assert "NOT (`table_type` IN (?, ?))" in sql + # View spellings are bind parameters, like every other value. + assert params == ("VIEW", "MATERIALIZED VIEW") + + +def test_base_table_type_expands_onto_the_catalog_spellings(dialect): + sql, params = BigQueryTableListQueryStatement( + dialect, schema="ds", table_type="BASE_TABLE" + ).to_sql() + assert "`table_type` IN (?, ?, ?)" in sql + assert params == ("BASE TABLE", "CLONE", "SNAPSHOT") + + +def test_view_type_expands_onto_both_view_spellings(dialect): + _sql, params = BigQueryTableListQueryStatement(dialect, schema="ds", table_type="VIEW").to_sql() + assert params == ("VIEW", "MATERIALIZED VIEW") + + +def test_external_type_filters_on_the_catalog_spelling(dialect): + _sql, params = BigQueryTableListQueryStatement( + dialect, schema="ds", table_type="EXTERNAL" + ).to_sql() + assert params == ("EXTERNAL",) + + +def test_a_type_the_catalog_never_lists_matches_nothing(dialect): + sql, params = BigQueryTableListQueryStatement( + dialect, schema="ds", table_type="SYSTEM TABLE" + ).to_sql() + # No catalog spelling exists, so the filter is a contradiction rather + # than an unfiltered listing. + assert "WHERE 1 = 2" in sql + assert params == () + + +def test_columns_query_binds_the_table_name(dialect): + sql, params = BigQueryColumnInfoQueryStatement( + dialect, "orders", "my_dataset", "my-project" + ).to_sql() + assert "FROM `my-project`.`my_dataset`.INFORMATION_SCHEMA.COLUMNS" in sql + assert "WHERE `table_name` = ?" in sql + assert params == ("orders",) + assert "ORDER BY `ordinal_position`" in sql + # identity_maximum / identity_minimum are the documented spellings. + assert "`identity_maximum`" in sql + assert "`identity_minimum`" in sql + + +def test_index_query_joins_the_two_constraint_views(dialect): + sql, params = BigQueryIndexInfoQueryStatement( + dialect, "orders", "my_dataset", "my-project" + ).to_sql() + assert "FROM `my-project`.`my_dataset`.INFORMATION_SCHEMA.TABLE_CONSTRAINTS AS `tc`" in sql + assert "JOIN `my-project`.`my_dataset`.INFORMATION_SCHEMA.KEY_COLUMN_USAGE AS `kcu`" in sql + assert "ON `tc`.`constraint_name` = `kcu`.`constraint_name`" in sql + assert "AND `tc`.`table_name` = `kcu`.`table_name`" in sql + assert "WHERE `tc`.`table_name` = ?" in sql + assert "`tc`.`constraint_type` = ?" in sql + assert params == ("orders", "PRIMARY KEY") + assert "ORDER BY `tc`.`constraint_name` ASC" in sql + + +def test_views_query_binds_nothing_and_orders_by_name(dialect): + sql, params = BigQueryViewListQueryStatement( + dialect, schema="my_dataset", catalog="my-project" + ).to_sql() + assert "FROM `my-project`.`my_dataset`.INFORMATION_SCHEMA.VIEWS" in sql + assert "`view_definition`" in sql + assert "`check_option`" in sql + assert "`use_standard_sql`" in sql + assert "ORDER BY `table_name`" in sql + assert params == () + + +def test_single_view_query_binds_the_view_name(dialect): + sql, params = BigQueryViewInfoQueryStatement( + dialect, "my_view", "my_dataset", "my-project" + ).to_sql() + assert "WHERE `table_name` = ?" in sql + assert params == ("my_view",) + + +def test_dataset_query_renders_the_project_and_region_qualifiers(dialect): + sql, params = BigQueryDatasetListQueryStatement( + dialect, catalog="my-project", location="US" + ).to_sql() + assert "FROM `my-project`.`region-US`.INFORMATION_SCHEMA.SCHEMATA" in sql + assert "`schema_name`" in sql + assert "`location`" in sql + assert "ORDER BY `schema_name`" in sql + assert params == () + + +def test_dataset_query_without_location_reads_the_us_multiregion(dialect): + sql, _params = BigQueryDatasetListQueryStatement(dialect, catalog="my-project").to_sql() + assert "FROM `my-project`.INFORMATION_SCHEMA.SCHEMATA" in sql + + +def test_dataset_query_with_nothing_configured_is_unqualified(dialect): + sql, _params = BigQueryDatasetListQueryStatement(dialect).to_sql() + assert "FROM INFORMATION_SCHEMA.SCHEMATA" in sql + + +def test_table_options_query_binds_the_table_name(dialect): + sql, params = BigQueryTableOptionsQueryStatement( + dialect, "orders", "my_dataset", "my-project" + ).to_sql() + assert "FROM `my-project`.`my_dataset`.INFORMATION_SCHEMA.TABLE_OPTIONS" in sql + assert "`option_name`" in sql + assert "`option_type`" in sql + assert "`option_value`" in sql + assert "WHERE `table_name` = ?" in sql + assert "ORDER BY `option_name`" in sql + assert params == ("orders",) + + +def test_partitions_query_binds_the_table_name(dialect): + sql, params = BigQueryPartitionListQueryStatement( + dialect, "orders", "my_dataset", "my-project" + ).to_sql() + assert "FROM `my-project`.`my_dataset`.INFORMATION_SCHEMA.PARTITIONS" in sql + assert "`partition_id`" in sql + assert "`total_rows`" in sql + assert "`storage_tier`" in sql + assert "WHERE `table_name` = ?" in sql + assert "ORDER BY `partition_id`" in sql + assert params == ("orders",) + + +def test_no_statement_writes_sql_text_of_its_own(dialect): + """A statement's render is the dialect's: swap the dialect and the + quoted spellings move with it. + + ``Table``/``Column``/``InPredicate`` nodes carry no SQL; if a statement + concatenated text, nothing downstream would notice and this dialect's + quoting would not be the thing deciding it. The one check that matters + is that the project path is quoted by the dialect's own identifier rule. + """ + statement = BigQueryTableListQueryStatement(dialect, schema="my_dataset", catalog="my-project") + sql, _params = statement.to_sql() + # The dialect quotes with doubled backticks for an inner one; a project + # ID containing a backtick is escaped rather than passed through. + backtick_project = BigQueryTableListQueryStatement( + dialect, schema="ds", catalog="my`project" + ).to_sql()[0] + assert "FROM `my``project`.`ds`.INFORMATION_SCHEMA.TABLES" in backtick_project + assert sql.count("`") > 0 + + +# --------------------------------------------------------------------------- +# The introspector's builders use the statements, and refuse what they must +# --------------------------------------------------------------------------- + + +def test_builders_use_the_configured_project_and_dataset(introspector): + introspector, executor = introspector + sql, params = introspector._build_table_list_sql("other_ds", False) + assert "FROM `my-project`.`other_ds`.INFORMATION_SCHEMA.TABLES" in sql + assert params == () + + # No schema argument: the connection's default dataset is used. + sql, _params = introspector._build_table_list_sql(None, False) + assert "FROM `my-project`.`my_dataset`.INFORMATION_SCHEMA.TABLES" in sql + + # A project.dataset pair overrides the configured project. + sql, _params = introspector._build_table_list_sql("other-project.other_ds", False) + assert "FROM `other-project`.`other_ds`.INFORMATION_SCHEMA.TABLES" in sql + + +def test_every_builder_binds_caller_data_only(introspector): + introspector, _executor = introspector + _sql, params = introspector._build_column_info_sql("orders", "my_dataset") + assert params == ("orders",) + _sql, params = introspector._build_view_info_sql("a view", "my_dataset") + assert params == ("a view",) + _sql, params = introspector._build_table_options_sql("orders", "my_dataset") + assert params == ("orders",) + _sql, params = introspector._build_partition_list_sql("orders", "my_dataset") + assert params == ("orders",) + + +def test_the_foreign_key_builder_refuses(introspector): + introspector, _executor = introspector + with pytest.raises(UnsupportedFeatureError) as exc_info: + introspector._build_foreign_key_sql("orders", "my_dataset") + assert "REFERENTIAL_CONSTRAINTS" in str(exc_info.value) + + +def test_the_trigger_builder_refuses(introspector): + introspector, _executor = introspector + with pytest.raises(UnsupportedFeatureError): + introspector._build_trigger_list_sql("orders", "my_dataset") + + +def test_no_dataset_anywhere_is_refused_with_the_instruction(introspector): + from rhosocial.activerecord.backend.impl.bigquery.introspection import ( + SyncBigQueryIntrospector as _Sync, + ) + + project_only = _Sync(_BackendStandIn(project="my-project", dataset=None), _RecordingExecutor()) + with pytest.raises(ValueError) as exc_info: + project_only._build_table_list_sql(None, False) + assert "pass schema=" in str(exc_info.value) + + with pytest.raises(ValueError): + project_only._build_column_info_sql("orders", None) + + +def test_a_malformed_schema_argument_is_refused(introspector): + introspector, _executor = introspector + with pytest.raises(ValueError): + introspector._build_table_list_sql("a.b.c", False) From 6ffe66dd333ad1b8ab476f98aaf3f5cac48e3644 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 24 Oct 2026 13:27:35 +0800 Subject: [PATCH 27/35] docs(cli): say what introspect can ask about The introspect subcommand accepted a list of types and nothing in the tree said what each one reads or where its answer comes from. This adds a README to each backend's cli package: the questions with the catalogue each reads, the options, the notes that keep an answer from being misread (estimate lengths, upper-casing, version gates, capabilities that are refused rather than empty), and examples. The tables are generated from the code, not from memory -- every entry was checked against the statement it dispatches to. --- .../backend/impl/bigquery/cli/README.md | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/rhosocial/activerecord/backend/impl/bigquery/cli/README.md diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/cli/README.md b/src/rhosocial/activerecord/backend/impl/bigquery/cli/README.md new file mode 100644 index 0000000..29c9192 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/bigquery/cli/README.md @@ -0,0 +1,68 @@ +# `introspect` — BigQuery metadata, from the command line + +The `introspect` subcommand asks a BigQuery project 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.bigquery introspect [NAME] [OPTIONS] +``` + +## What it can ask about + +| Type | Reads | Notes | +| :--- | :--- | :--- | +| `tables` | `INFORMATION_SCHEMA.TABLES` | Tables and views, with `table_type` distinguishing `BASE TABLE`, `VIEW`, `MATERIALIZED VIEW` and `EXTERNAL`. | +| `views` | `INFORMATION_SCHEMA.VIEWS` | Views, with their definitions. | +| `table` | several | One table by name, with its columns and primary key filled in. | +| `columns` | `INFORMATION_SCHEMA.COLUMNS` | One table's columns: name, type, nullability, ordinal position, default. | +| `datasets` | `INFORMATION_SCHEMA.SCHEMATA` | The project's datasets. A dataset is BigQuery's namespace level above a table, so it stands where a schema would elsewhere. | + +`NAME` is required for `table` and `columns`. The list types take no name. + +## Options + +| Option | Default | Description | +| :--- | :--- | :--- | +| `-o, --output` | `table` | `table` (rich), `json`, `csv`, `tsv`. | +| `--project` | `BIGQUERY_PROJECT`, or `GOOGLE_CLOUD_PROJECT` | The project to read. | +| `--dataset` | `BIGQUERY_DATASET` | The dataset to read. Stands where `--schema` would in another backend. | +| `--location` | `BIGQUERY_LOCATION` | Region qualifier for the `INFORMATION_SCHEMA` views. | +| `--credentials-path` | `BIGQUERY_CREDENTIALS_PATH`, or `GOOGLE_APPLICATION_CREDENTIALS` | Path to a service-account key file. | +| `--credentials-json` | `BIGQUERY_CREDENTIALS_JSON` | The key's JSON, inline. | +| `--api-endpoint` | `BIGQUERY_API_ENDPOINT` | Endpoint override, for an emulator. | +| `--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 a project whose schema + matters. +* **A dataset, not a schema.** BigQuery has no schemas. `--dataset` maps + onto the `schema` argument the core introspector takes, and the CLI says + *dataset* so a user is not sent looking for a thing that does not exist. +* **Nothing here was run against a live project.** The statements are + written from the official documentation (cited beside each one in the + source). Verify the first run against your project before relying on the + shapes. +* **The `version` is not a server version.** BigQuery has no server release + to ask about; the backend reports a feature level, and says so rather than + inventing a number. +* **One question per invocation.** A CLI process is one connection that + opens, answers, and closes — there is no cache carried between runs. + +## Examples + +```bash +# Datasets, and one dataset's tables +python -m rhosocial.activerecord.backend.impl.bigquery introspect datasets --project my-project +python -m rhosocial.activerecord.backend.impl.bigquery introspect tables --dataset events -o json + +# One table's columns +python -m rhosocial.activerecord.backend.impl.bigquery introspect columns orders --dataset events + +# Against a local emulator +python -m rhosocial.activerecord.backend.impl.bigquery introspect tables \ + --project test --dataset events --api-endpoint http://localhost:9050 +``` From 6c870b184fee00050a93385a00d485118a195b5e Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 24 Oct 2026 15:19:45 +0800 Subject: [PATCH 28/35] refactor(cli): name the namespace after the engine, not after the slot The introspection CLI took a --schema flag on backends that have no schema. MySQL, MariaDB and ClickHouse have exactly one namespace level and call it a*database; Firebird has a single unnamed namespace and qualifies nothing above the*table. Asking those engines for a --schema named a level they do not have, and*the flag's help text then had to explain that the value meant something else. The flag is gone from those four. What is left is the flag the connection*already takes, which for MySQL, MariaDB and ClickHouse is --database -- so the*run connects to a database and asks about that database, which is the whole*answer when there is only one level. list_datasets() is the way to enumerate*databases; the run does not need a flag to ask about a different one. BigQuery had both --schema and --dataset naming the same level, with a*fallback order between them. One flag now, --dataset, which the connection*takes. The flags are removed rather than aliased: an alias lets a caller keep naming a*level that does not exist, which is the confusion this removes. The tests that*pinned the old flags now pin that they are gone. --- .../backend/impl/bigquery/cli/introspect.py | 21 ++++++------ .../introspection/test_cli_introspect.py | 34 ++++++++++++------- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/cli/introspect.py b/src/rhosocial/activerecord/backend/impl/bigquery/cli/introspect.py index 6bc81ac..70d6eed 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/cli/introspect.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/cli/introspect.py @@ -99,15 +99,6 @@ def create_parser(subparsers): default="table", help="Output format (default: table)", ) - parser.add_argument( - "--schema", - dest="schema", - default=None, - help=( - "Dataset to introspect (default: --dataset / the " - "connection default). A dataset ID, or project.dataset." - ), - ) parser.add_argument( "--include-system", action="store_true", @@ -215,8 +206,16 @@ def _one(name: str, args) -> str: def _schema_arg(args): - """The dataset a question is scoped to: ``--schema`` over ``--dataset``.""" - return args.schema if args.schema is not None else args.dataset + """The dataset a question is scoped to. + + One flag, ``--dataset``, which the connection already takes: BigQuery has + no schema under a dataset, so the dataset is the only namespace level a + question can be scoped to. An earlier version also accepted ``--schema`` + and fell back to ``--dataset`` when it was absent, which spelled the same + level under a word BigQuery does not use -- and made the fallback order the + thing a reader had to work out. + """ + return args.dataset # --------------------------------------------------------------------------- diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_cli_introspect.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_cli_introspect.py index 6dc4aeb..377a585 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_cli_introspect.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_cli_introspect.py @@ -242,20 +242,28 @@ def test_tables_scopes_to_the_dataset_given(built, capsys): assert built[0].introspector.calls == [("list_tables", "my_dataset", False)] -def test_schema_flag_overrides_the_dataset_flag(built, capsys): - _run( - [ - "tables", - "--dataset", - "my_dataset", - "--schema", - "other_dataset", - "-o", - "json", - ] - ) +def test_one_flag_names_the_dataset(built, capsys): + """``--dataset`` is the only namespace flag, and it is the scope. + + BigQuery has no schema under a dataset, so there is one level to name + and one flag to name it with. An earlier version also accepted + ``--schema`` and made it override this one, which spelled the same + level under a word BigQuery does not use and made the precedence the + thing a reader had to work out. + """ + _run(["tables", "--dataset", "my_dataset", "-o", "json"]) capsys.readouterr() - assert built[0].introspector.calls == [("list_tables", "other_dataset", False)] + assert built[0].introspector.calls == [("list_tables", "my_dataset", False)] + + +def test_the_second_spelling_is_not_accepted(built, capsys): + """``--schema`` is gone, not aliased. + + An alias would let a caller keep naming a level that does not exist + here, which is the confusion the single flag removes. + """ + with pytest.raises(SystemExit): + _run(["tables", "--dataset", "my_dataset", "--schema", "other"]) def test_tables_as_csv(built, capsys): From c7eb3eb46bf9c3603894f09fcaae11d989c6e8b7 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 24 Oct 2026 23:18:16 +0800 Subject: [PATCH 29/35] feat(introspection): declare BigQuery's dataset family A dataset is BigQuery's namespace level above a table and there is no schema under it, so this is the only namespace capability the backend has. Named `supports_dataset_introspection` so the CLI can offer `introspect datasets` and a caller can ask the question rather than try it. --- .../backend/impl/bigquery/mixins/introspection.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/introspection.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/introspection.py index 5ed62e0..095354a 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/introspection.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/introspection.py @@ -98,6 +98,14 @@ class BigQueryIntrospectionMixin(IntrospectionMixin): relation) and triggers (BigQuery has none). """ + def supports_dataset_introspection(self) -> bool: + """BigQuery answers for its datasets from ``INFORMATION_SCHEMA.SCHEMATA``. + + The capability for the ``introspect datasets`` family. A dataset + is BigQuery's namespace level above a table -- there is no + schema under it, so this is the only namespace switch.""" + return True + # ------------------------------------------------------------------ # # Capability declaration # ------------------------------------------------------------------ # From 4cac7972c47647a83719d518898e2a44446de485 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 25 Oct 2026 15:41:29 +0800 Subject: [PATCH 30/35] refactor(bigquery)!: drop the underscores from the introspection contract The row parsers are this backend's implementation of the base introspection contract: the base declares them and every backend must supply them, so the leading underscore described a visibility the code never had. It is also how the next private helper gets justified, which is what this branch is trying to stop. default_schema becomes a property rather than a method, beside dialect and cache: it states a fact about the database, it does not perform an action. No aliases: the old names are gone. BigQuery side: twelve row parsers -- datasets, table options and partitions among them -- plus default_schema and its two call sites. The parsing test follows. --- .../impl/bigquery/introspection/executor.py | 2 +- .../bigquery/introspection/introspector.py | 49 ++++++++++--------- .../test_introspection_parsing.py | 44 ++++++++--------- 3 files changed, 48 insertions(+), 47 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/introspection/executor.py b/src/rhosocial/activerecord/backend/impl/bigquery/introspection/executor.py index 716d435..93beca7 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/introspection/executor.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/introspection/executor.py @@ -26,7 +26,7 @@ def _rows_from_result(result: Any) -> List[Dict[str, Any]]: ``QueryResult.data`` is ``None`` for a statement that returned no result set. An introspection statement always selects, so that should not happen; returning an empty list keeps a surprising result from turning - into an ``AttributeError`` deep inside a ``_parse_*`` method. + into an ``AttributeError`` deep inside a ``parse_*`` method. """ data = getattr(result, "data", None) if not data: diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/introspection/introspector.py b/src/rhosocial/activerecord/backend/impl/bigquery/introspection/introspector.py index 4c77d67..0b59de3 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/introspection/introspector.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/introspection/introspector.py @@ -197,7 +197,7 @@ class BigQueryIntrospectorMixin(IntrospectorMixin): The ``_build_*_sql`` overrides resolve *which* dataset and project a question is about and construct the matching statement object from :mod:`...expression.introspection`; rendering is the dialect's job. The - ``_parse_*`` methods are pure Python: rows in, core introspection + ``parse_*`` methods are pure Python: rows in, core introspection dataclasses out, shared by both managers. """ @@ -205,7 +205,8 @@ class BigQueryIntrospectorMixin(IntrospectorMixin): # Names and qualifiers # ------------------------------------------------------------------ # - def _get_default_schema(self) -> str: + @property + def default_schema(self) -> str: """Return the configured dataset ID, or ``""`` when there is none. The core ``schema`` parameter is BigQuery's dataset. A connection @@ -248,7 +249,7 @@ def _resolve_dataset(self, schema: Optional[str]) -> Tuple[Optional[str], str]: ValueError: When no dataset is available, or when the argument has more than one dot. """ - raw = schema if schema is not None else self._get_default_schema() + raw = schema if schema is not None else self.default_schema raw = (raw or "").strip() if raw.startswith("`") and raw.endswith("`") and len(raw) >= 2: raw = raw[1:-1].strip() @@ -271,7 +272,7 @@ def _schema_label(self, schema: Optional[str]) -> str: still carries whatever the query already selected, and the caller that asked for it supplied the qualifier in the first place. """ - raw = schema if schema is not None else self._get_default_schema() + raw = schema if schema is not None else self.default_schema raw = (raw or "").strip().strip("`") if not raw: return "" @@ -440,7 +441,7 @@ def _build_partition_list_sql(self, table_name: str, schema: Optional[str]): # Parsing # ------------------------------------------------------------------ # - def _parse_database_info(self, rows: List[Dict[str, Any]]) -> IntrospectionDatabaseInfo: + def parse_database_info(self, rows: List[Dict[str, Any]]) -> IntrospectionDatabaseInfo: row = rows[0] if rows else {} version = self._get_version() config = getattr(self._backend, "config", None) @@ -458,7 +459,7 @@ def _parse_database_info(self, rows: List[Dict[str, Any]]) -> IntrospectionDatab }, ) - def _parse_tables( + def parse_tables( self, rows: List[Dict[str, Any]], schema: Optional[str] ) -> List[IntrospectionTableInfo]: label = self._schema_label(schema) @@ -482,7 +483,7 @@ def _parse_tables( ) return tables - def _parse_data_type(self, raw: str, column_name: str) -> Optional[Any]: + def parse_data_type(self, raw: str, column_name: str) -> Optional[Any]: """Read one catalog type string, degrading one column on failure. ``parsed_data_type`` is ``None`` for exactly one reason: the catalog @@ -507,7 +508,7 @@ def _parse_data_type(self, raw: str, column_name: str) -> Optional[Any]: ) return None - def _parse_columns( + def parse_columns( self, rows: List[Dict[str, Any]], table_name: str, schema: str ) -> List[IntrospectionColumnInfo]: label = self._schema_label(schema) @@ -536,7 +537,7 @@ def _parse_columns( data_type=str(raw_type).lower(), data_type_full=str(raw_type) or None, parsed_data_type=( - self._parse_data_type(str(raw_type), column_name) if raw_type else None + self.parse_data_type(str(raw_type), column_name) if raw_type else None ), nullable=nullable, default_value=_row_get(row, "column_default"), @@ -573,7 +574,7 @@ def _parse_columns( ) return columns - def _parse_indexes( + def parse_indexes( self, rows: List[Dict[str, Any]], table_name: str, schema: str ) -> List[IndexInfo]: """Parse primary-key constraint rows into ``IndexInfo`` objects. @@ -610,14 +611,14 @@ def _parse_indexes( ) return list(indexes.values()) - def _parse_foreign_keys( + def parse_foreign_keys( self, rows: List[Dict[str, Any]], table_name: str, schema: str ) -> List[IntrospectionForeignKeyInfo]: """Always empty: the catalog cannot name the referenced relation.""" del rows, table_name, schema return [] - def _parse_views(self, rows: List[Dict[str, Any]], schema: str) -> List[IntrospectionViewInfo]: + def parse_views(self, rows: List[Dict[str, Any]], schema: str) -> List[IntrospectionViewInfo]: label = self._schema_label(schema) return [ IntrospectionViewInfo( @@ -632,21 +633,21 @@ def _parse_views(self, rows: List[Dict[str, Any]], schema: str) -> List[Introspe for row in rows ] - def _parse_view_info( + def parse_view_info( self, rows: List[Dict[str, Any]], view_name: str, schema: str ) -> Optional[IntrospectionViewInfo]: if not rows: return None - return self._parse_views(rows[:1], schema)[0] + return self.parse_views(rows[:1], schema)[0] - def _parse_triggers( + def parse_triggers( self, rows: List[Dict[str, Any]], schema: str ) -> List[IntrospectionTriggerInfo]: """Always empty: BigQuery has no triggers.""" del rows, schema return [] - def _parse_datasets(self, rows: List[Dict[str, Any]]) -> List[BigQueryDatasetInfo]: + def parse_datasets(self, rows: List[Dict[str, Any]]) -> List[BigQueryDatasetInfo]: return [ BigQueryDatasetInfo( schema_name=_row_get(row, "schema_name") or "", @@ -662,7 +663,7 @@ def _parse_datasets(self, rows: List[Dict[str, Any]]) -> List[BigQueryDatasetInf for row in rows ] - def _parse_table_options(self, rows: List[Dict[str, Any]]) -> List[BigQueryTableOptionInfo]: + def parse_table_options(self, rows: List[Dict[str, Any]]) -> List[BigQueryTableOptionInfo]: return [ BigQueryTableOptionInfo( table_name=_row_get(row, "table_name") or "", @@ -675,7 +676,7 @@ def _parse_table_options(self, rows: List[Dict[str, Any]]) -> List[BigQueryTable for row in rows ] - def _parse_partitions(self, rows: List[Dict[str, Any]]) -> List[BigQueryPartitionInfo]: + def parse_partitions(self, rows: List[Dict[str, Any]]) -> List[BigQueryPartitionInfo]: return [ BigQueryPartitionInfo( table_name=_row_get(row, "table_name") or "", @@ -786,7 +787,7 @@ def list_datasets(self) -> List[BigQueryDatasetInfo]: if cached is not None: return cached sql, params = self._build_dataset_list_sql() - result = self._parse_datasets(self._executor.execute(sql, params)) + result = self.parse_datasets(self._executor.execute(sql, params)) self._cache.set(key, result) return result @@ -801,7 +802,7 @@ def list_table_options( if cached is not None: return cached sql, params = self._build_table_options_sql(table_name, schema) - result = self._parse_table_options(self._executor.execute(sql, params)) + result = self.parse_table_options(self._executor.execute(sql, params)) self._cache.set(key, result) return result @@ -816,7 +817,7 @@ def list_partitions( if cached is not None: return cached sql, params = self._build_partition_list_sql(table_name, schema) - result = self._parse_partitions(self._executor.execute(sql, params)) + result = self.parse_partitions(self._executor.execute(sql, params)) self._cache.set(key, result) return result @@ -909,7 +910,7 @@ async def list_datasets(self) -> List[BigQueryDatasetInfo]: if cached is not None: return cached sql, params = self._build_dataset_list_sql() - result = self._parse_datasets(await self._executor.execute(sql, params)) + result = self.parse_datasets(await self._executor.execute(sql, params)) self._cache.set(key, result) return result @@ -924,7 +925,7 @@ async def list_table_options( if cached is not None: return cached sql, params = self._build_table_options_sql(table_name, schema) - result = self._parse_table_options(await self._executor.execute(sql, params)) + result = self.parse_table_options(await self._executor.execute(sql, params)) self._cache.set(key, result) return result @@ -939,7 +940,7 @@ async def list_partitions( if cached is not None: return cached sql, params = self._build_partition_list_sql(table_name, schema) - result = self._parse_partitions(await self._executor.execute(sql, params)) + result = self.parse_partitions(await self._executor.execute(sql, params)) self._cache.set(key, result) return result diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_parsing.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_parsing.py index b500730..46da3c2 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_parsing.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_parsing.py @@ -1,7 +1,7 @@ # tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_parsing.py """Row -> dataclass parsing, fed the documented column names and values. -No connection and no SQL: these tests call the ``_parse_*`` methods with +No connection and no SQL: these tests call the ``parse_*`` methods with rows spelled exactly as the documentation spells them (lowercase column names, ``YES``/``NO`` flags, the documented ``table_type`` words), which is what BigQuery returns. The parsing rules pinned here are the ones a server @@ -119,7 +119,7 @@ def test_tables_parse_the_documented_type_words(introspector): "table_type": "EXTERNAL", }, ] - tables = introspector._parse_tables(rows, "my_dataset") + tables = introspector.parse_tables(rows, "my_dataset") assert [t.name for t in tables] == [ "orders", "orders_clone", @@ -148,14 +148,14 @@ def test_tables_parse_the_documented_type_words(introspector): def test_tables_default_unknown_spellings_to_base_table(introspector): - tables = introspector._parse_tables([{"table_name": "t", "table_type": "SOMETHING NEW"}], "ds") + tables = introspector.parse_tables([{"table_name": "t", "table_type": "SOMETHING NEW"}], "ds") assert tables[0].table_type is TableType.BASE_TABLE assert tables[0].extra["bigquery_table_type"] == "SOMETHING NEW" def test_tables_accept_uppercase_row_keys(introspector): """A driver or fixture that preserves the catalog's own spelling.""" - tables = introspector._parse_tables( + tables = introspector.parse_tables( [{"TABLE_NAME": "orders", "TABLE_TYPE": "BASE TABLE"}], "my_dataset" ) assert tables[0].name == "orders" @@ -197,7 +197,7 @@ def _column_row(**overrides): def test_columns_parse_nullability_type_and_position(introspector): - columns = introspector._parse_columns( + columns = introspector.parse_columns( [ _column_row(), _column_row( @@ -233,7 +233,7 @@ def test_columns_parse_nullability_type_and_position(introspector): def test_columns_drop_pseudo_columns(introspector): - columns = introspector._parse_columns( + columns = introspector.parse_columns( [ _column_row(), _column_row( @@ -251,7 +251,7 @@ def test_columns_drop_pseudo_columns(introspector): def test_columns_parse_identity_columns_under_the_documented_names(introspector): - columns = introspector._parse_columns( + columns = introspector.parse_columns( [ _column_row( column_name="order_id", @@ -278,7 +278,7 @@ def test_columns_parse_identity_columns_under_the_documented_names(introspector) def test_columns_parse_partitioning_and_clustering_flags(introspector): - columns = introspector._parse_columns( + columns = introspector.parse_columns( [ _column_row( column_name="created", @@ -316,7 +316,7 @@ def test_columns_parse_partitioning_and_clustering_flags(introspector): def test_an_unreadable_type_degrades_one_column_with_a_warning(introspector): with pytest.warns(RuntimeWarning, match="STRUCT"): - columns = introspector._parse_columns( + columns = introspector.parse_columns( [ _column_row(), _column_row(column_name="payload", data_type="STRUCT"), @@ -337,7 +337,7 @@ def test_an_unreadable_type_degrades_one_column_with_a_warning(introspector): def test_indexes_group_constraint_rows_by_name(introspector): - indexes = introspector._parse_indexes( + indexes = introspector.parse_indexes( [ { "constraint_name": "pk_orders", @@ -369,7 +369,7 @@ def test_indexes_group_constraint_rows_by_name(introspector): def test_a_foreign_key_constraint_row_parses_as_not_primary(introspector): - indexes = introspector._parse_indexes( + indexes = introspector.parse_indexes( [ { "constraint_name": "fk_orders", @@ -385,7 +385,7 @@ def test_a_foreign_key_constraint_row_parses_as_not_primary(introspector): def test_views_parse_the_definition_and_standard_sql_flag(introspector): - views = introspector._parse_views( + views = introspector.parse_views( [ { "table_catalog": "my-project", @@ -407,11 +407,11 @@ def test_views_parse_the_definition_and_standard_sql_flag(introspector): def test_single_view_info_returns_none_for_no_rows(introspector): - assert introspector._parse_view_info([], "missing", "my_dataset") is None + assert introspector.parse_view_info([], "missing", "my_dataset") is None def test_single_view_info_parses_the_first_row(introspector): - info = introspector._parse_view_info( + info = introspector.parse_view_info( [ { "table_name": "v", @@ -429,7 +429,7 @@ def test_single_view_info_parses_the_first_row(introspector): def test_datasets_parse_the_schemata_columns(introspector): created = datetime(2026, 10, 24, 12, 0, 0) - datasets = introspector._parse_datasets( + datasets = introspector.parse_datasets( [ { "catalog_name": "my-project", @@ -454,7 +454,7 @@ def test_datasets_parse_the_schemata_columns(introspector): def test_table_options_parse_one_row_per_option(introspector): - options = introspector._parse_table_options( + options = introspector.parse_table_options( [ { "table_name": "orders", @@ -481,7 +481,7 @@ def test_table_options_parse_one_row_per_option(introspector): def test_partitions_parse_counts_and_tiers(introspector): modified = datetime(2026, 10, 24, 12, 0, 0) - partitions = introspector._parse_partitions( + partitions = introspector.parse_partitions( [ { "table_name": "orders", @@ -519,7 +519,7 @@ def test_partitions_parse_counts_and_tiers(introspector): def test_database_info_reports_the_project_and_the_backend_level(introspector): - info = introspector._parse_database_info([{"catalog_name": "my-project"}]) + info = introspector.parse_database_info([{"catalog_name": "my-project"}]) assert info.name == "my-project" assert info.vendor == "BigQuery" assert info.version_tuple == (3, 0, 0) @@ -529,7 +529,7 @@ def test_database_info_reports_the_project_and_the_backend_level(introspector): def test_database_info_falls_back_to_the_configured_project(introspector): - info = introspector._parse_database_info([]) + info = introspector.parse_database_info([]) assert info.name == "my-project" @@ -539,7 +539,7 @@ def test_database_info_falls_back_to_the_configured_project(introspector): def test_storage_shape_composes_options_partitions_and_columns(introspector): - columns = introspector._parse_columns( + columns = introspector.parse_columns( [ _column_row( column_name="created", @@ -559,7 +559,7 @@ def test_storage_shape_composes_options_partitions_and_columns(introspector): storage = BigQueryTableStorageInfo( table_name="orders", schema="my_dataset", - options=introspector._parse_table_options( + options=introspector.parse_table_options( [ { "table_name": "orders", @@ -569,7 +569,7 @@ def test_storage_shape_composes_options_partitions_and_columns(introspector): } ] ), - partitions=introspector._parse_partitions( + partitions=introspector.parse_partitions( [{"table_name": "orders", "partition_id": "20260101", "total_rows": 5}] ), partitioning_columns=introspector._partitioning_columns(columns), From 1f5b8df027144ede6419b8b2c9233cadbac9fd51 Mon Sep 17 00:00:00 2001 From: vistart Date: Sun, 25 Oct 2026 22:20:05 +0800 Subject: [PATCH 31/35] refactor(bigquery): name the catalogue questions as statement factories --- .../bigquery/introspection/introspector.py | 99 +++++++++++-------- .../introspection/test_introspection_sql.py | 38 ++++--- 2 files changed, 81 insertions(+), 56 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/introspection/introspector.py b/src/rhosocial/activerecord/backend/impl/bigquery/introspection/introspector.py index 0b59de3..86972df 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/introspection/introspector.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/introspection/introspector.py @@ -79,7 +79,7 @@ import copy import warnings from datetime import datetime -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, NoReturn, Optional, Tuple from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.introspection.base import ( @@ -194,7 +194,7 @@ def _as_bool(value: Any) -> Optional[bool]: class BigQueryIntrospectorMixin(IntrospectorMixin): """SQL building and row parsing shared by the sync and async managers. - The ``_build_*_sql`` overrides resolve *which* dataset and project a + The ``*_statement`` factories resolve *which* dataset and project a question is about and construct the matching statement object from :mod:`...expression.introspection`; rendering is the dialect's job. The ``parse_*`` methods are pure Python: rows in, core introspection @@ -287,21 +287,22 @@ def _dataset_scoped(self, schema: Optional[str]) -> Tuple[Optional[str], str]: return self._resolve_dataset(schema) # ------------------------------------------------------------------ # - # SQL building — statement objects, rendered by the dialect + # Statement factories — rendered by the dialect # ------------------------------------------------------------------ # - def _build_database_info_sql(self): - """``SELECT @@project_id AS catalog_name``, as a statement object.""" - return BigQueryDatabaseInfoQueryStatement(self.dialect).to_sql() + def database_info_statement(self) -> "BigQueryDatabaseInfoQueryStatement": + """Return ``SELECT @@project_id AS catalog_name``, as a statement object.""" + return BigQueryDatabaseInfoQueryStatement(self.dialect) - def _build_table_list_sql( + def table_list_statement( self, + *, schema: Optional[str], include_system: bool, include_views: bool = True, table_type: Optional[str] = None, - ): - """Build the ``INFORMATION_SCHEMA.TABLES`` statement. + ) -> "BigQueryTableListQueryStatement": + """Return the ``INFORMATION_SCHEMA.TABLES`` statement. ``include_system`` has no effect: BigQuery does not list system tables in this view. It is accepted so the core signature keeps @@ -325,10 +326,12 @@ def _build_table_list_sql( catalog=catalog, include_views=include_views, table_type=table_type, - ).to_sql() + ) - def _build_column_info_sql(self, table_name: str, schema: Optional[str]): - """Build the ``INFORMATION_SCHEMA.COLUMNS`` statement. + def column_info_statement( + self, *, table_name: str, schema: Optional[str] + ) -> "BigQueryColumnInfoQueryStatement": + """Return the ``INFORMATION_SCHEMA.COLUMNS`` statement. ``ordinal_position`` is selected because it orders the result; the other columns are the documented ones this backend maps onto @@ -344,10 +347,12 @@ def _build_column_info_sql(self, table_name: str, schema: Optional[str]): table_name=table_name, schema=dataset, catalog=catalog, - ).to_sql() + ) - def _build_index_info_sql(self, table_name: str, schema: Optional[str]): - """Build the primary-key statement over constraints. + def index_info_statement( + self, *, table_name: str, schema: Optional[str] + ) -> "BigQueryIndexInfoQueryStatement": + """Return the primary-key statement over constraints. ``TABLE_CONSTRAINTS`` documents ``constraint_type`` as either ``PRIMARY KEY`` or ``FOREIGN KEY``; only the former maps onto an @@ -361,13 +366,13 @@ def _build_index_info_sql(self, table_name: str, schema: Optional[str]): table_name=table_name, schema=dataset, catalog=catalog, - ).to_sql() + ) - def _build_foreign_key_sql(self, table_name: str, schema: Optional[str]): + def foreign_key_statement(self, *, table_name: str, schema: Optional[str]) -> "NoReturn": """Refuse: the catalog does not expose the referenced relation. See the module docstring. The public ``list_foreign_keys`` returns - an empty list instead of calling this; the builder still refuses so + an empty list instead of calling this; the factory still refuses so a future caller cannot mistake it for an unanswered query. """ del table_name, schema @@ -380,8 +385,10 @@ def _build_foreign_key_sql(self, table_name: str, schema: Optional[str]): "returns an empty list.", ) - def _build_view_list_sql(self, schema: Optional[str], include_system: bool): - """Build the ``INFORMATION_SCHEMA.VIEWS`` statement. + def view_list_statement( + self, *, schema: Optional[str], include_system: bool + ) -> "BigQueryViewListQueryStatement": + """Return the ``INFORMATION_SCHEMA.VIEWS`` statement. ``include_system`` is ignored: every row of this view is a user view. ``VIEWS`` is dataset-qualified, unlike ``SCHEMATA``. @@ -389,20 +396,22 @@ def _build_view_list_sql(self, schema: Optional[str], include_system: bool): """ del include_system catalog, dataset = self._dataset_scoped(schema) - return BigQueryViewListQueryStatement( - self.dialect, schema=dataset, catalog=catalog - ).to_sql() + return BigQueryViewListQueryStatement(self.dialect, schema=dataset, catalog=catalog) - def _build_view_info_sql(self, view_name: str, schema: Optional[str]): - """Build one view's ``INFORMATION_SCHEMA.VIEWS`` statement.""" + def view_info_statement( + self, *, view_name: str, schema: Optional[str] + ) -> "BigQueryViewInfoQueryStatement": + """Return one view's ``INFORMATION_SCHEMA.VIEWS`` statement.""" catalog, dataset = self._dataset_scoped(schema) return BigQueryViewInfoQueryStatement( self.dialect, view_name=view_name, schema=dataset, catalog=catalog - ).to_sql() + ) - def _build_trigger_list_sql(self, table_name: Optional[str], schema: Optional[str]): + def trigger_list_statement( + self, *, schema: Optional[str], table_name: Optional[str] = None + ) -> "NoReturn": """Refuse: BigQuery has no triggers and no trigger catalog view.""" - del table_name, schema + del schema, table_name raise UnsupportedFeatureError( self.dialect.name, "trigger introspection", @@ -410,23 +419,27 @@ def _build_trigger_list_sql(self, table_name: Optional[str], schema: Optional[st "list_triggers() returns an empty list.", ) - def _build_dataset_list_sql(self): - """Build the ``INFORMATION_SCHEMA.SCHEMATA`` statement (project/region).""" + def dataset_list_statement(self) -> "BigQueryDatasetListQueryStatement": + """Return the ``INFORMATION_SCHEMA.SCHEMATA`` statement (project/region).""" config = getattr(self._backend, "config", None) location = getattr(config, "location", None) if config is not None else None return BigQueryDatasetListQueryStatement( self.dialect, catalog=self._project(), location=location - ).to_sql() + ) - def _build_table_options_sql(self, table_name: str, schema: Optional[str]): - """Build the ``INFORMATION_SCHEMA.TABLE_OPTIONS`` statement.""" + def table_options_statement( + self, *, table_name: str, schema: Optional[str] + ) -> "BigQueryTableOptionsQueryStatement": + """Return the ``INFORMATION_SCHEMA.TABLE_OPTIONS`` statement.""" catalog, dataset = self._dataset_scoped(schema) return BigQueryTableOptionsQueryStatement( self.dialect, table_name=table_name, schema=dataset, catalog=catalog - ).to_sql() + ) - def _build_partition_list_sql(self, table_name: str, schema: Optional[str]): - """Build the ``INFORMATION_SCHEMA.PARTITIONS`` statement. + def partition_list_statement( + self, *, table_name: str, schema: Optional[str] + ) -> "BigQueryPartitionListQueryStatement": + """Return the ``INFORMATION_SCHEMA.PARTITIONS`` statement. Unlike ``SCHEMATA`` this view takes a dataset qualifier only, never a region qualifier. @@ -435,7 +448,7 @@ def _build_partition_list_sql(self, table_name: str, schema: Optional[str]): catalog, dataset = self._dataset_scoped(schema) return BigQueryPartitionListQueryStatement( self.dialect, table_name=table_name, schema=dataset, catalog=catalog - ).to_sql() + ) # ------------------------------------------------------------------ # # Parsing @@ -786,7 +799,7 @@ def list_datasets(self) -> List[BigQueryDatasetInfo]: cached = self._cache.get(key) if cached is not None: return cached - sql, params = self._build_dataset_list_sql() + sql, params = self.dataset_list_statement().to_sql() result = self.parse_datasets(self._executor.execute(sql, params)) self._cache.set(key, result) return result @@ -801,7 +814,7 @@ def list_table_options( cached = self._cache.get(key) if cached is not None: return cached - sql, params = self._build_table_options_sql(table_name, schema) + sql, params = self.table_options_statement(table_name=table_name, schema=schema).to_sql() result = self.parse_table_options(self._executor.execute(sql, params)) self._cache.set(key, result) return result @@ -816,7 +829,7 @@ def list_partitions( cached = self._cache.get(key) if cached is not None: return cached - sql, params = self._build_partition_list_sql(table_name, schema) + sql, params = self.partition_list_statement(table_name=table_name, schema=schema).to_sql() result = self.parse_partitions(self._executor.execute(sql, params)) self._cache.set(key, result) return result @@ -909,7 +922,7 @@ async def list_datasets(self) -> List[BigQueryDatasetInfo]: cached = self._cache.get(key) if cached is not None: return cached - sql, params = self._build_dataset_list_sql() + sql, params = self.dataset_list_statement().to_sql() result = self.parse_datasets(await self._executor.execute(sql, params)) self._cache.set(key, result) return result @@ -924,7 +937,7 @@ async def list_table_options( cached = self._cache.get(key) if cached is not None: return cached - sql, params = self._build_table_options_sql(table_name, schema) + sql, params = self.table_options_statement(table_name=table_name, schema=schema).to_sql() result = self.parse_table_options(await self._executor.execute(sql, params)) self._cache.set(key, result) return result @@ -939,7 +952,7 @@ async def list_partitions( cached = self._cache.get(key) if cached is not None: return cached - sql, params = self._build_partition_list_sql(table_name, schema) + sql, params = self.partition_list_statement(table_name=table_name, schema=schema).to_sql() result = self.parse_partitions(await self._executor.execute(sql, params)) self._cache.set(key, result) return result diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_sql.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_sql.py index 27701fe..1f0bb6b 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_sql.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_sql.py @@ -2,7 +2,7 @@ """The SQL shape each introspection question renders to. No connection: the statements render through ``BigQueryDialect`` on their -own, and the ``_build_*_sql`` overrides run against a backend stand-in that +own, and the ``*_statement`` factories run against a backend stand-in that carries the three attributes the introspector reads (``dialect``, ``config``, ``_version``). @@ -284,42 +284,54 @@ def test_no_statement_writes_sql_text_of_its_own(dialect): def test_builders_use_the_configured_project_and_dataset(introspector): introspector, executor = introspector - sql, params = introspector._build_table_list_sql("other_ds", False) + sql, params = introspector.table_list_statement( + schema="other_ds", include_system=False + ).to_sql() assert "FROM `my-project`.`other_ds`.INFORMATION_SCHEMA.TABLES" in sql assert params == () # No schema argument: the connection's default dataset is used. - sql, _params = introspector._build_table_list_sql(None, False) + sql, _params = introspector.table_list_statement(schema=None, include_system=False).to_sql() assert "FROM `my-project`.`my_dataset`.INFORMATION_SCHEMA.TABLES" in sql # A project.dataset pair overrides the configured project. - sql, _params = introspector._build_table_list_sql("other-project.other_ds", False) + sql, _params = introspector.table_list_statement( + schema="other-project.other_ds", include_system=False + ).to_sql() assert "FROM `other-project`.`other_ds`.INFORMATION_SCHEMA.TABLES" in sql def test_every_builder_binds_caller_data_only(introspector): introspector, _executor = introspector - _sql, params = introspector._build_column_info_sql("orders", "my_dataset") + _sql, params = introspector.column_info_statement( + table_name="orders", schema="my_dataset" + ).to_sql() assert params == ("orders",) - _sql, params = introspector._build_view_info_sql("a view", "my_dataset") + _sql, params = introspector.view_info_statement( + view_name="a view", schema="my_dataset" + ).to_sql() assert params == ("a view",) - _sql, params = introspector._build_table_options_sql("orders", "my_dataset") + _sql, params = introspector.table_options_statement( + table_name="orders", schema="my_dataset" + ).to_sql() assert params == ("orders",) - _sql, params = introspector._build_partition_list_sql("orders", "my_dataset") + _sql, params = introspector.partition_list_statement( + table_name="orders", schema="my_dataset" + ).to_sql() assert params == ("orders",) def test_the_foreign_key_builder_refuses(introspector): introspector, _executor = introspector with pytest.raises(UnsupportedFeatureError) as exc_info: - introspector._build_foreign_key_sql("orders", "my_dataset") + introspector.foreign_key_statement(table_name="orders", schema="my_dataset") assert "REFERENTIAL_CONSTRAINTS" in str(exc_info.value) def test_the_trigger_builder_refuses(introspector): introspector, _executor = introspector with pytest.raises(UnsupportedFeatureError): - introspector._build_trigger_list_sql("orders", "my_dataset") + introspector.trigger_list_statement(table_name="orders", schema="my_dataset") def test_no_dataset_anywhere_is_refused_with_the_instruction(introspector): @@ -329,14 +341,14 @@ def test_no_dataset_anywhere_is_refused_with_the_instruction(introspector): project_only = _Sync(_BackendStandIn(project="my-project", dataset=None), _RecordingExecutor()) with pytest.raises(ValueError) as exc_info: - project_only._build_table_list_sql(None, False) + project_only.table_list_statement(schema=None, include_system=False) assert "pass schema=" in str(exc_info.value) with pytest.raises(ValueError): - project_only._build_column_info_sql("orders", None) + project_only.column_info_statement(table_name="orders", schema=None) def test_a_malformed_schema_argument_is_refused(introspector): introspector, _executor = introspector with pytest.raises(ValueError): - introspector._build_table_list_sql("a.b.c", False) + introspector.table_list_statement(schema="a.b.c", include_system=False) From 62e0b459a0d23692adf80e97b92940ce2a4bbbef Mon Sep 17 00:00:00 2001 From: vistart Date: Tue, 27 Oct 2026 02:07:11 +0800 Subject: [PATCH 32/35] feat(cli)!: the introspect table command is gone "table" printed two answers for one name -- the columns and the primary key -- as two JSON arrays back to back. Those are two catalogue questions: the column listing, which ``columns`` already answers, and the key an index listing reports. The command, its help entry, and the branch in ``_report`` that printed both go with it. The registered names and the introspection list are now the same four: tables, views, columns, datasets. --- .../backend/impl/bigquery/cli/__init__.py | 3 +- .../backend/impl/bigquery/cli/introspect.py | 49 ++++++------------- .../introspection/test_cli_introspect.py | 31 ------------ 3 files changed, 16 insertions(+), 67 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/cli/__init__.py b/src/rhosocial/activerecord/backend/impl/bigquery/cli/__init__.py index e099d3c..aa5e790 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/cli/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/cli/__init__.py @@ -3,7 +3,7 @@ The package's shape follows the other backends' small CLIs: ``__main__.py`` parses, this module registers and routes, and each subcommand module owns -its parser and its handler. The five introspection subcommands all live in +its parser and its handler. The four introspection subcommands all live in :mod:`~.introspect`, so this module routes their names there directly. """ @@ -12,7 +12,6 @@ COMMAND_NAMES = [ "tables", "views", - "table", "columns", "datasets", ] diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/cli/introspect.py b/src/rhosocial/activerecord/backend/impl/bigquery/cli/introspect.py index 70d6eed..2f15be6 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/cli/introspect.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/cli/introspect.py @@ -1,6 +1,6 @@ # src/rhosocial/activerecord/backend/impl/bigquery/cli/introspect.py -"""The introspection subcommands: ``tables``, ``views``, ``table``, -``columns`` and ``datasets``. +"""The introspection subcommands: ``tables``, ``views``, ``columns`` and +``datasets``. Each one wires ``backend.introspector`` to an output format: @@ -9,8 +9,6 @@ =============================== ====================================== ``tables`` ``list_tables()`` ``views`` ``list_views()`` -``table NAME`` ``get_table_info()`` -- columns and - primary key of one table ``columns NAME`` ``list_columns()`` ``datasets`` ``list_datasets()`` (the SCHEMATA view) =============================== ====================================== @@ -43,11 +41,11 @@ #: The subcommands this module owns. ``cli.get_handler`` routes each name #: here; nothing else registers one of them. -INTROSPECT_SUBCOMMANDS = ("tables", "views", "table", "columns", "datasets") +INTROSPECT_SUBCOMMANDS = ("tables", "views", "columns", "datasets") -#: The two subcommands that act on one named table, so they carry the -#: positional name the other three do not. -_NAME_COMMANDS = ("table", "columns") +#: The one subcommand that acts on a named table, so it carries the +#: positional name the others do not. +_NAME_COMMANDS = ("columns",) _EPILOG = """Examples: # Every dataset in the project @@ -57,8 +55,7 @@ %(prog)s tables --project my-project --dataset my_dataset %(prog)s views --project my-project --dataset my_dataset - # One table's columns and primary key - %(prog)s table orders --project my-project --dataset my_dataset + # One table's columns %(prog)s columns orders --project my-project --dataset my_dataset # JSON output, and the environment-variable form @@ -86,8 +83,8 @@ def create_parser(subparsers): formatter_class=argparse.RawDescriptionHelpFormatter, ) if name in _NAME_COMMANDS: - # Only these two act on one named table; the others list - # everything and take no positional. + # It acts on one named table; the others list everything and + # take no positional. parser.add_argument( "name", help=_POSITIONAL_HELP[name], @@ -115,13 +112,13 @@ def create_parser(subparsers): _HELP = { "tables": "List the tables and views in a dataset", "views": "List the views in a dataset", - "table": "Show one table's columns and primary key", + "columns": "List one table's columns", "datasets": "List the project's datasets", } _POSITIONAL_HELP = { - "table": "Table name (required)", + "columns": "Table name (required)", } @@ -231,9 +228,7 @@ def _questions(args) -> Dict[str, Callable[[Any], Any]]: schema=schema, include_system=args.include_system ), "views": lambda introspector: introspector.list_views(schema=schema), - "table": lambda introspector: introspector.get_table_info( - _one("table", args), schema=schema - ), + "columns": lambda introspector: introspector.list_columns( _one("columns", args), schema=schema ), @@ -254,24 +249,10 @@ def _async_questions(args) -> Dict[str, Callable[[Any], Any]]: def _report(result: Any, args, provider) -> None: """Print one question's result. - ``table`` answers a whole object, so its columns and primary key are - two tables under their own titles -- the shape the question has. A - missing table is an exit, not an empty table. + One result, one table of rows. The two answers ``table`` used to print + together -- columns and primary key -- are now the ``columns`` command + and the key columns it reports. """ - if args.command == "table": - if result is None: - print(f"Error: table '{args.name}' was not found", file=sys.stderr) - sys.exit(1) - provider.display_results( - _serialize_for_output(result.columns), - title=f"Columns of {args.name}", - ) - if result.indexes: - provider.display_results( - _serialize_for_output(result.indexes), - title=f"Primary key of {args.name}", - ) - return title = "Datasets" if args.command == "datasets" else args.command.title() provider.display_results(_serialize_for_output(list(result)), title=title) diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_cli_introspect.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_cli_introspect.py index 377a585..5fedb73 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_cli_introspect.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_cli_introspect.py @@ -180,7 +180,6 @@ def test_every_subcommand_is_registered(): assert set(COMMAND_NAMES) == { "tables", "views", - "table", "columns", "datasets", } @@ -304,22 +303,6 @@ def test_datasets_lists_datasets_as_json(built, capsys): assert built[0].introspector.calls == [("list_datasets",)] -def test_table_prints_columns_and_the_primary_key(built, capsys): - _run(["table", "orders", "--dataset", "my_dataset", "-o", "json"]) - out = capsys.readouterr().out - # Two displays: the columns, then the primary key. Each is a - # pretty-printed JSON array, back to back, so decode them in order. - decoder = json.JSONDecoder() - columns, index = decoder.raw_decode(out.lstrip()) - rest = out.lstrip()[decoder.raw_decode(out.lstrip())[1] :].lstrip() - indexes, _end = decoder.raw_decode(rest) - assert [row["name"] for row in columns] == ["id"] - assert [row["name"] for row in indexes] == ["pk_orders"] - assert indexes[0]["is_primary"] is True - assert indexes[0]["extra"] == {"constraint_type": "PRIMARY KEY"} - assert built[0].introspector.calls == [("get_table_info", "orders", "my_dataset")] - - def test_columns_lists_one_tables_columns_as_json(built, capsys): _run(["columns", "orders", "--dataset", "my_dataset", "-o", "json"]) payload = json.loads(capsys.readouterr().out) @@ -333,14 +316,6 @@ def test_columns_lists_one_tables_columns_as_json(built, capsys): # --------------------------------------------------------------------------- -def test_table_without_a_name_exits_non_zero(capsys): - with pytest.raises(SystemExit) as exc_info: - _run(["table", "-o", "json"]) - # The positional name is required, so argparse refuses before the - # handler runs -- a different refusal from a table that does not exist. - assert exc_info.value.code == 2 - assert "required" in capsys.readouterr().err - def test_columns_without_a_name_exits_non_zero(capsys): with pytest.raises(SystemExit) as exc_info: @@ -349,12 +324,6 @@ def test_columns_without_a_name_exits_non_zero(capsys): assert "required" in capsys.readouterr().err -def test_an_unknown_table_exits_non_zero(built, capsys): - with pytest.raises(SystemExit) as exc_info: - _run(["table", "missing", "-o", "json"]) - assert exc_info.value.code == 1 - assert "not found" in capsys.readouterr().err - def test_a_value_error_from_the_introspector_exits_non_zero(built, capsys, monkeypatch): """A missing dataset: the introspector raises with the instruction.""" From 579e18dfb87d73c599c859abe0ed2eff00270f29 Mon Sep 17 00:00:00 2001 From: vistart Date: Tue, 27 Oct 2026 11:04:23 +0800 Subject: [PATCH 33/35] fix(bigquery): stop claiming an index catalogue that does not exist supports_index_introspection() answered True while INFORMATION_SCHEMA has no index view and the CLI lists no indexes type, so the capability advertised a question the backend cannot ask. It now answers False and the INDEX scope is no longer reported; the primary-key constraint read stays available for get_primary_key(). The TABLES map also folds MATERIALIZED VIEW onto VIEW, erasing the one distinction the catalog draws now that core has TableType.MATERIALIZED_VIEW. Map it to that member, and keep VIEW and MATERIALIZED_VIEW filters separate while include_views=False still excludes both. --- .../impl/bigquery/expression/introspection.py | 5 ++- .../bigquery/introspection/introspector.py | 16 +++++--- .../impl/bigquery/mixins/introspection.py | 37 ++++++++++++------- .../test_introspection_parsing.py | 12 +++++- .../introspection/test_introspection_sql.py | 29 ++++++++++++++- 5 files changed, 75 insertions(+), 24 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/expression/introspection.py b/src/rhosocial/activerecord/backend/impl/bigquery/expression/introspection.py index a34e464..0941302 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/expression/introspection.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/expression/introspection.py @@ -127,8 +127,9 @@ class BigQueryTableListQueryStatement(IntrospectionExpression): system tables, so there is nothing for the flag to include; it is accepted so the core signature keeps working. table_type: One core ``TableType`` spelling (``BASE_TABLE``, - ``VIEW``, ``EXTERNAL`` ...), or one catalog spelling. The - dialect expands the core spellings onto the catalog ones. + ``VIEW``, ``MATERIALIZED_VIEW``, ``EXTERNAL`` ...), or one + catalog spelling. The dialect expands the core spellings onto + the catalog ones. https://cloud.google.com/bigquery/docs/information-schema-tables """ diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/introspection/introspector.py b/src/rhosocial/activerecord/backend/impl/bigquery/introspection/introspector.py index 86972df..70f547d 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/introspection/introspector.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/introspection/introspector.py @@ -10,7 +10,8 @@ database info ``SELECT @@project_id`` (system variable) tables ``INFORMATION_SCHEMA.TABLES`` columns ``INFORMATION_SCHEMA.COLUMNS`` -indexes (primary keys) ``TABLE_CONSTRAINTS`` + ``KEY_COLUMN_USAGE`` +indexes none -- BigQuery has no index catalogue +primary key ``TABLE_CONSTRAINTS`` + ``KEY_COLUMN_USAGE`` views ``INFORMATION_SCHEMA.VIEWS`` datasets ``INFORMATION_SCHEMA.SCHEMATA`` table options ``INFORMATION_SCHEMA.TABLE_OPTIONS`` @@ -47,9 +48,11 @@ *Triggers.* BigQuery has no triggers, so :meth:`list_triggers` returns an empty list and the builder refuses instead of inventing a catalog view. -*Indexes.* BigQuery has no user-created indexes. Primary-key constraints -are reported as :class:`IndexInfo` values so ``get_primary_key()`` works; -``IndexType`` is ``UNKNOWN`` because there is no index structure behind it. +*Indexes.* BigQuery has no user-created indexes and its catalog exposes no +index view, so :meth:`supports_index_introspection` answers ``False`` and +no CLI type offers the family. Primary-key constraints are still reported +as :class:`IndexInfo` values so ``get_primary_key()`` works; ``IndexType`` +is ``UNKNOWN`` because there is no index structure behind it. Documentation: @@ -136,7 +139,7 @@ "SNAPSHOT": TableType.BASE_TABLE, "EXTERNAL": TableType.EXTERNAL, "VIEW": TableType.VIEW, - "MATERIALIZED VIEW": TableType.VIEW, + "MATERIALIZED VIEW": TableType.MATERIALIZED_VIEW, } #: The views that are not tables, used when ``include_views=False``. @@ -316,7 +319,8 @@ def table_list_statement( exclude views and materialized views. table_type: A core ``TableType`` value or a catalog spelling. ``BASE_TABLE`` expands to base tables, clones and snapshots; - ``VIEW`` expands to views and materialized views. + ``VIEW`` and ``MATERIALIZED_VIEW`` each match their own + catalog spelling. """ catalog, dataset = self._dataset_scoped(schema) del include_system # BigQuery's catalog has no system tables here. diff --git a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/introspection.py b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/introspection.py index 095354a..974a1f7 100644 --- a/src/rhosocial/activerecord/backend/impl/bigquery/mixins/introspection.py +++ b/src/rhosocial/activerecord/backend/impl/bigquery/mixins/introspection.py @@ -92,10 +92,12 @@ class BigQueryIntrospectionMixin(IntrospectionMixin): """BigQuery's answers to the core introspection questions. - ``supports_*`` declares them, ``format_*`` renders them. Two questions - have no answer at all and say so instead of guessing: foreign keys - (the catalog reports *that* one exists without naming the referenced - relation) and triggers (BigQuery has none). + ``supports_*`` declares them, ``format_*`` renders them. Three questions + have no answer at all and say so instead of guessing: indexes (BigQuery + has no index catalogue), foreign keys (the catalog reports *that* one + exists without naming the referenced relation) and triggers (BigQuery + has none). The introspector can still read a primary key from its + constraints; that is a constraint question, not the index family. """ def supports_dataset_introspection(self) -> bool: @@ -127,8 +129,16 @@ def supports_column_introspection(self) -> bool: return True def supports_index_introspection(self) -> bool: - """No physical indexes exist; PRIMARY KEY constraints stand in.""" - return True + """Declined: BigQuery has no index catalogue to ask. + + BigQuery creates no user-visible indexes, and + ``INFORMATION_SCHEMA`` documents no index view at all, so there is + no question this dialect can answer. The primary key is a + constraint rather than an index and stays reachable through the + introspector's constraint read, but claiming the index family here + would offer a CLI type that has nothing to run. + """ + return False def supports_foreign_key_introspection(self) -> bool: """Declined: the catalog never names the referenced relation. @@ -154,13 +164,12 @@ def supports_partition_info(self) -> bool: return True def get_supported_introspection_scopes(self) -> List[IntrospectionScope]: - """The scopes this dialect can answer, less the two it declines.""" + """The scopes this dialect can answer, less the three it declines.""" return [ IntrospectionScope.DATABASE, IntrospectionScope.SCHEMA, IntrospectionScope.TABLE, IntrospectionScope.COLUMN, - IntrospectionScope.INDEX, IntrospectionScope.VIEW, ] @@ -260,10 +269,10 @@ def _expand_table_type(self, table_type: Optional[str]) -> Tuple[str, ...]: A core ``TableType`` spelling is expanded onto the catalog's: ``BASE_TABLE`` covers base tables, clones and snapshots, and - ``VIEW`` covers views and materialized views. A catalog spelling - is passed through unchanged, and a spelling this backend does not - know resolves to nothing -- which the caller renders as the - no-match predicate rather than guessing. + ``VIEW`` and ``MATERIALIZED_VIEW`` keep the distinction the catalog + draws. A catalog spelling is passed through unchanged, and a + spelling this backend does not know resolves to nothing -- which the + caller renders as the no-match predicate rather than guessing. """ if not table_type: return () @@ -271,7 +280,9 @@ def _expand_table_type(self, table_type: Optional[str]) -> Tuple[str, ...]: if raw in ("BASE TABLE", "BASE"): return _BASE_TABLE_TYPES if raw == "VIEW": - return _VIEW_TYPES + return ("VIEW",) + if raw == "MATERIALIZED VIEW": + return ("MATERIALIZED VIEW",) if raw in ("SYSTEM TABLE", "TEMPORARY"): return () return (raw,) diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_parsing.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_parsing.py index 46da3c2..74a6925 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_parsing.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_parsing.py @@ -133,7 +133,7 @@ def test_tables_parse_the_documented_type_words(introspector): TableType.BASE_TABLE, # a clone is a table for "can I select from it" TableType.BASE_TABLE, # a snapshot too TableType.VIEW, - TableType.VIEW, # a materialized view is reported as a view + TableType.MATERIALIZED_VIEW, # the catalog distinguishes it TableType.EXTERNAL, ] orders = tables[0] @@ -580,3 +580,13 @@ def test_storage_shape_composes_options_partitions_and_columns(introspector): assert [p.partition_id for p in storage.partitions] == ["20260101"] assert storage.partitioning_columns == ["created"] assert storage.clustering_columns == ["tier"] + + +# --------------------------------------------------------------------------- +# Both managers share the dialect's capability answers +# --------------------------------------------------------------------------- + + +def test_async_introspector_declines_index_introspection(async_introspector): + """The capability is declared once and the async manager inherits it.""" + assert async_introspector.dialect.supports_index_introspection() is False diff --git a/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_sql.py b/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_sql.py index 1f0bb6b..ad200e9 100644 --- a/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_sql.py +++ b/tests/rhosocial/activerecord_bigquery_test/feature/backend/introspection/test_introspection_sql.py @@ -28,6 +28,7 @@ import pytest from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.introspection.types import IntrospectionScope from rhosocial.activerecord.backend.impl.bigquery.config import ( BigQueryConnectionConfig, @@ -141,9 +142,16 @@ def test_base_table_type_expands_onto_the_catalog_spellings(dialect): assert params == ("BASE TABLE", "CLONE", "SNAPSHOT") -def test_view_type_expands_onto_both_view_spellings(dialect): +def test_view_type_matches_only_views(dialect): _sql, params = BigQueryTableListQueryStatement(dialect, schema="ds", table_type="VIEW").to_sql() - assert params == ("VIEW", "MATERIALIZED VIEW") + assert params == ("VIEW",) + + +def test_materialized_view_type_matches_only_materialized_views(dialect): + _sql, params = BigQueryTableListQueryStatement( + dialect, schema="ds", table_type="MATERIALIZED_VIEW" + ).to_sql() + assert params == ("MATERIALIZED VIEW",) def test_external_type_filters_on_the_catalog_spelling(dialect): @@ -352,3 +360,20 @@ def test_a_malformed_schema_argument_is_refused(introspector): introspector, _executor = introspector with pytest.raises(ValueError): introspector.table_list_statement(schema="a.b.c", include_system=False) + + +# --------------------------------------------------------------------------- +# The capability claims are facts about the catalog +# --------------------------------------------------------------------------- + + +def test_index_introspection_is_declined(dialect): + """BigQuery has no index catalogue, so the family is not claimed. + + The primary-key constraint read is still available as a constraint + question; claiming the index family would advertise a CLI type that has + no view to run. + """ + assert dialect.supports_index_introspection() is False + scopes = dialect.get_supported_introspection_scopes() + assert IntrospectionScope.INDEX not in scopes From ea4588cd7ad2ee9174139aacf987372a01a0130c Mon Sep 17 00:00:00 2001 From: vistart Date: Sat, 31 Oct 2026 00:41:13 +0800 Subject: [PATCH 34/35] ci: install core from the release branch now that #127 has merged `fix/column-constraint-gates` was squashed into `release/v1.0.0.dev30` as `57439841`, so the branch reference this workflow used to resolve core through no longer names anything ahead of the release. Pointing at the release branch makes each run test what a user installs. The testsuite stays on `fix/column-constraint-gates` on purpose: its own branch carries the two `pytest_compat` shims that keep pytest 9.2 working (the benchmark fixture's `_nodeid` read and xdist's loadgroup suffix), and those are not on the testsuite's release branch yet. That line is repointed when the testsuite merges. --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 01ee97d..d12997d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,7 +44,7 @@ env: # The previous pin, the fix/schema-name-propagation-gaps branch, predates all # of that layer, so the imports this backend now makes do not resolve against # it. - CORE_PKG: "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@fix/column-constraint-gates" + 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@fix/column-constraint-gates" jobs: From 4ab71cb4b587764d81e0d4036a2f8f1de9a7dd0a Mon Sep 17 00:00:00 2001 From: vistart Date: Sat, 31 Oct 2026 13:23:37 +0800 Subject: [PATCH 35/35] docs(changelog): the fragments for #5 Named after the pull request once it had a number, which is the order the fragment check requires: 4 entries, one per kind of change. --- changelog.d/5.added.md | 1 + changelog.d/5.changed.md | 1 + changelog.d/5.fixed.md | 1 + changelog.d/5.internal.md | 1 + 4 files changed, 4 insertions(+) create mode 100644 changelog.d/5.added.md create mode 100644 changelog.d/5.changed.md create mode 100644 changelog.d/5.fixed.md create mode 100644 changelog.d/5.internal.md diff --git a/changelog.d/5.added.md b/changelog.d/5.added.md new file mode 100644 index 0000000..07fa86c --- /dev/null +++ b/changelog.d/5.added.md @@ -0,0 +1 @@ +The dataset family, the metadata questions answered from `INFORMATION_SCHEMA`, and statement factories for the catalogue reads. The introspect type list derives from `_TYPE_CAPABILITIES`. diff --git a/changelog.d/5.changed.md b/changelog.d/5.changed.md new file mode 100644 index 0000000..804365f --- /dev/null +++ b/changelog.d/5.changed.md @@ -0,0 +1 @@ +`introspect table` is gone, and the catalogue questions are named as statement factories under the engine's own namespace. diff --git a/changelog.d/5.fixed.md b/changelog.d/5.fixed.md new file mode 100644 index 0000000..88ba147 --- /dev/null +++ b/changelog.d/5.fixed.md @@ -0,0 +1 @@ +The backend no longer claims an index catalogue it does not have, and the `ILIKE` spelling its grammar lacks is refused. diff --git a/changelog.d/5.internal.md b/changelog.d/5.internal.md new file mode 100644 index 0000000..619b894 --- /dev/null +++ b/changelog.d/5.internal.md @@ -0,0 +1 @@ +The round-trip matrix follows the core's current tree, and CI installs core from `release/v1.0.0.dev30` now that core #127 has merged.