From 0183bdf7b10bc9ff64beed6e5a302158e6e69beb Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 19 Sep 2026 21:28:08 +0800 Subject: [PATCH 01/28] fix(snowflake): drop index by name without ON (cherry picked from commit a9b4bfd8dec4a20f36e3e9a2c3f9fb20c7636d37) (cherry picked from commit 3c87feba1fc1b4a4bfaa826b3ef1ef928a48d49a) --- .github/workflows/test.yml | 6 +++--- .../backend/impl/snowflake/mixins/capabilities.py | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fbd72ec..a72c2e2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,7 +33,7 @@ jobs: run: | pip install -r requirements.txt pip install -r requirements-dev.txt - pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30#egg=rhosocial-activerecord + pip install git+https://github.com/rhosocial/python-activerecord.git@feature/activerecord-derived-ddl#egg=rhosocial-activerecord pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl#egg=rhosocial-activerecord-testsuite - name: Install package @@ -77,7 +77,7 @@ jobs: run: | pip install -r requirements.txt pip install -r requirements-dev.txt - pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30#egg=rhosocial-activerecord + pip install git+https://github.com/rhosocial/python-activerecord.git@feature/activerecord-derived-ddl#egg=rhosocial-activerecord pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl#egg=rhosocial-activerecord-testsuite - name: Install package @@ -109,7 +109,7 @@ jobs: run: | pip install -r requirements.txt pip install -r requirements-dev.txt - pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30#egg=rhosocial-activerecord + pip install git+https://github.com/rhosocial/python-activerecord.git@feature/activerecord-derived-ddl#egg=rhosocial-activerecord pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl#egg=rhosocial-activerecord-testsuite - name: Install package diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/capabilities.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/capabilities.py index 1b1ac9d..ece3afb 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/capabilities.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/capabilities.py @@ -97,6 +97,10 @@ def supports_indexes(self) -> bool: """Snowflake supports indexes (clustering keys and search optimization).""" return True + def supports_drop_index_on_table(self) -> bool: + """Snowflake drops indexes/search-optimization by name (no ON
).""" + return False + def supports_constraints(self) -> bool: """Snowflake supports constraints (PK, FK, UNIQUE, NOT NULL, CHECK).""" return True From 7590ed82217fbee5c9fe041d43cd715ebd585c1d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 09:51:27 +0800 Subject: [PATCH 02/28] refactor(snowflake): correct partition semantics to clustering/external forms Snowflake has no declarative RANGE/LIST/HASH table partitioning, so the previous SnowflakePartitionClause (RANGE/LIST + VALUES LESS THAN/IN) produced invalid Snowflake SQL. Correct the model: - Capability bits are honest: supports_table_partitioning / supports_partitioned_table_creation / range / list / hash now return False. - The generic PartitionClause is rejected (UnsupportedFeatureError); standard tables use CLUSTER BY. - Add SnowflakeClusterByClause + format_cluster_by_clause (wired into SnowflakeTableModifierMixin/protocol) for standard-table clustering keys. - Add SnowflakeExternalPartitionClause + format_external_partition_clause for the external-table PARTITION BY (cols) form (no method, no VALUES). - Update tests to the corrected semantics. (cherry picked from commit 9dbc506bdaf80afc2e3f654c3d8eec717519443a) (cherry picked from commit f409122dc8df02983b7d7d22fce529bf6a9c7168) --- .../impl/snowflake/expression/__init__.py | 8 +- .../impl/snowflake/expression/partition.py | 110 ++++++++++--- .../snowflake/mixins/ddl_table_modifier.py | 22 +++ .../impl/snowflake/mixins/partition.py | 91 +++++++---- .../impl/snowflake/protocols/partition.py | 20 ++- .../snowflake/protocols/table_modifier.py | 6 +- .../backend/snowflake/test_partition.py | 147 ++++++++---------- 7 files changed, 263 insertions(+), 141 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/__init__.py index 69c6690..8a2fec5 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/__init__.py @@ -18,7 +18,10 @@ SnowflakeVarcharType, SnowflakeVariantType, ) -from .partition import SnowflakePartitionClause +from .partition import ( + SnowflakeClusterByClause, + SnowflakeExternalPartitionClause, +) from .identifier import SnowflakeIdentifierExpression from .sample import ( SnowflakeSampleExpression, @@ -92,7 +95,8 @@ "SnowflakeTimestampTzType", "SnowflakeVarcharType", "SnowflakeVariantType", - "SnowflakePartitionClause", + "SnowflakeClusterByClause", + "SnowflakeExternalPartitionClause", "SnowflakeIdentifierExpression", "SnowflakeSampleExpression", "SnowflakeSampleForm", diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/partition.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/partition.py index 3cd0485..5d8597e 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/partition.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/partition.py @@ -1,34 +1,106 @@ # src/rhosocial/activerecord/backend/impl/snowflake/expression/partition.py -"""Snowflake partition expression — PARTITION BY clause for DDL.""" +"""Snowflake partition/clustering DDL expressions. -from typing import Any, Dict, Optional, Sequence, Type +Snowflake does **not** use declarative (RANGE/LIST/HASH) table partitioning: + +* Standard tables are micro-partitioned automatically. The only user control + over data layout is ``CLUSTER BY ( [, ...] )`` clustering keys. +* External tables support ``PARTITION BY ( [, ...] )`` — a + partition **column list**, not VALUES-based partition definitions. + +This module defines Snowflake-owned clause expressions for both forms. The +generic RANGE/LIST/HASH ``PartitionClause`` is *not* valid Snowflake syntax +and is rejected by the Snowflake partition mixin. +""" + +from typing import Any, Dict, Optional, Sequence, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression -from rhosocial.activerecord.backend.expression.statements.ddl_partition import ( - PartitionClause, - PartitionStrategy, -) +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase -class SnowflakePartitionClause(PartitionClause): - """Snowflake PARTITION BY clause with explicit partition definitions. - Snowflake supports PARTITION BY on external tables with named - partitions and boundary values. - """ +__all__ = [ + "SnowflakeClusterByClause", + "SnowflakeExternalPartitionClause", +] + - strategy_type: Type = PartitionStrategy +class SnowflakeClusterByClause(BaseExpression): + """Snowflake ``CLUSTER BY ( [, ...] )`` clustering-key clause. + + Applies to standard (and materialized-view) DDL, not to declarative + partitioning. Carries the clustering key expressions; SQL generation is + delegated to ``dialect.format_cluster_by_clause``. + """ def __init__( self, - dialect: "Any", - method: PartitionStrategy, + dialect: "SQLDialectBase", keys: Sequence[BaseExpression], *, - partitions: Optional[list] = None, dialect_options: Optional[Dict[str, Any]] = None, ): - combined_options = dict(dialect_options or {}) - if partitions: - combined_options["partitions"] = partitions - super().__init__(dialect, method, keys, dialect_options=combined_options) + super().__init__(dialect) + if not keys: + raise ValueError("CLUSTER BY requires at least one clustering key") + for key in keys: + if not isinstance(key, BaseExpression): + raise TypeError( + "clustering keys must be BaseExpression instances, " + f"got {type(key).__name__}" + ) + if dialect_options is not None and not isinstance(dialect_options, dict): + raise TypeError( + "dialect_options must be a dict when provided, " + f"got {type(dialect_options).__name__}" + ) + self.keys = list(keys) + self.dialect_options = dict(dialect_options or {}) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_cluster_by_clause" + + +class SnowflakeExternalPartitionClause(BaseExpression): + """Snowflake external-table ``PARTITION BY ( [, ...] )`` clause. + + External-table partitioning declares one or more partition **columns**; + it has no RANGE/LIST/HASH method and no inline ``VALUES`` boundaries. + This expression therefore carries only the partition columns and renders + the bare list; it is deliberately **not** a generic ``PartitionClause``, + because its shape (no method) differs from declarative partitioning. + SQL generation is delegated to ``dialect.format_external_partition_clause``. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + columns: Sequence[BaseExpression], + *, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + if not columns: + raise ValueError("PARTITION BY requires at least one partition column") + for column in columns: + if not isinstance(column, BaseExpression): + raise TypeError( + "partition columns must be BaseExpression instances, " + f"got {type(column).__name__}" + ) + if dialect_options is not None and not isinstance(dialect_options, dict): + raise TypeError( + "dialect_options must be a dict when provided, " + f"got {type(dialect_options).__name__}" + ) + self.columns = list(columns) + self.dialect_options = dict(dialect_options or {}) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_external_partition_clause" diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_table_modifier.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_table_modifier.py index 67aa6c9..50303db 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_table_modifier.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_table_modifier.py @@ -11,6 +11,11 @@ ``TableMixin`` CREATE TABLE renderer is not modified. """ +from typing import Tuple, TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover + from ..expression.partition import SnowflakeClusterByClause + class SnowflakeTableModifierMixin: """Mixin for Snowflake table DDL modifier support.""" @@ -19,6 +24,23 @@ def supports_create_or_replace_table(self) -> bool: """Snowflake supports CREATE OR REPLACE TABLE.""" return True + def format_cluster_by_clause(self, expr: "SnowflakeClusterByClause") -> Tuple[str, tuple]: + """Format ``CLUSTER BY ( [, ...] )`` clustering keys. + + Args: + expr: SnowflakeClusterByClause carrying the clustering key expressions. + + Returns: + Tuple of (SQL string, parameters tuple). + """ + parts = [] + params = [] + for key in expr.keys: + key_sql, key_params = key.to_sql() + parts.append(key_sql) + params.extend(key_params) + return f" CLUSTER BY ({', '.join(parts)})", tuple(params) + def supports_transient_table(self) -> bool: """Snowflake supports TRANSIENT tables.""" return True diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/partition.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/partition.py index 759d7a4..cdfa303 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/partition.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/partition.py @@ -1,35 +1,52 @@ # src/rhosocial/activerecord/backend/impl/snowflake/mixins/partition.py -"""Snowflake partition mixin — partition DDL support for external tables.""" +"""Snowflake partition/clustering mixin. + +Snowflake has no declarative (RANGE/LIST/HASH) table partitioning: + +* Standard tables are micro-partitioned automatically; the only user control + is ``CLUSTER BY`` clustering keys (see ``SnowflakeTableModifierMixin``). +* External tables support ``PARTITION BY ( [, ...] )``, a partition + column list with no VALUES boundaries. + +Accordingly this mixin reports the generic declarative-partitioning +capability bits as ``False`` and rejects the generic ``PartitionClause`` +(the RANGE/LIST/HASH form) as unsupported Snowflake syntax, while providing +the external-table ``PARTITION BY`` formatter. +""" from typing import Tuple, TYPE_CHECKING +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.dialect.mixins.partition import PartitionMixin if TYPE_CHECKING: - from rhosocial.activerecord.backend.expression.statements import PartitionClause + from ..expression.partition import SnowflakeExternalPartitionClause class SnowflakePartitionMixin(PartitionMixin): """Snowflake partition implementation. - Snowflake supports PARTITION BY on external tables. - Standard tables use automatic micro-partitioning. + Snowflake standard tables use automatic micro-partitioning and, optionally, + ``CLUSTER BY``. Declarative RANGE/LIST/HASH partitioning does not exist; + external tables declare partition columns via ``PARTITION BY (cols)``. """ def supports_table_partitioning(self) -> bool: - return True + """Snowflake has no user-defined declarative table partitioning.""" + return False def supports_partitioned_table_creation(self) -> bool: - return True + """Snowflake standard CREATE TABLE does not take a PARTITION BY clause.""" + return False def supports_partition_metadata_introspection(self) -> bool: return False def supports_range_table_partitioning(self) -> bool: - return True + return False def supports_list_table_partitioning(self) -> bool: - return True + return False def supports_hash_table_partitioning(self) -> bool: return False @@ -55,23 +72,43 @@ def supports_attach_partition(self) -> bool: def supports_detach_partition(self) -> bool: return False - def format_partition_clause(self, expr: "PartitionClause") -> Tuple[str, tuple]: - method = expr.method.upper() - cols = ", ".join( - key.to_sql()[0] for key in expr.keys + def supports_external_table_partitioning(self) -> bool: + """Snowflake external tables support ``PARTITION BY (cols)``.""" + return True + + def format_partition_clause(self, expr) -> Tuple[str, tuple]: + """Reject the generic declarative ``PartitionClause`` on Snowflake. + + Snowflake has no RANGE/LIST/HASH declarative partitioning; use + ``CLUSTER BY`` (``SnowflakeClusterByClause``) for standard tables or + ``SnowflakeExternalPartitionClause`` for external tables. + + Raises: + UnsupportedFeatureError: always. + """ + raise UnsupportedFeatureError( + self.name, + "declarative table partitioning", + suggestion="Snowflake standard tables use automatic micro-partitioning; " + "use CLUSTER BY for data layout, or SnowflakeExternalPartitionClause " + "for external-table PARTITION BY (cols).", ) - partition_defs = expr.dialect_options.get("partitions", []) - if partition_defs: - parts = [] - for p in partition_defs: - name = p.get("name", "") - if method == "RANGE": - values = p.get("values", []) - parts.append(f"PARTITION {name} VALUES LESS THAN ({', '.join(values)})") - elif method == "LIST": - values = p.get("values", []) - parts.append(f"PARTITION {name} VALUES IN ({', '.join(values)})") - if parts: - clause = f"PARTITION BY {method} ({cols}) (\n " + ",\n ".join(parts) + "\n)" - return clause, () - return f"PARTITION BY {method} ({cols})", () + + def format_external_partition_clause( + self, expr: "SnowflakeExternalPartitionClause" + ) -> Tuple[str, tuple]: + """Format ``PARTITION BY ( [, ...] )`` for external tables. + + Args: + expr: SnowflakeExternalPartitionClause carrying the partition columns. + + Returns: + Tuple of (SQL string, parameters tuple). + """ + parts = [] + params = [] + for column in expr.columns: + column_sql, column_params = column.to_sql() + parts.append(column_sql) + params.extend(column_params) + return f" PARTITION BY ({', '.join(parts)})", tuple(params) diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/partition.py b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/partition.py index bfae386..2a735f5 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/partition.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/partition.py @@ -1,18 +1,26 @@ # src/rhosocial/activerecord/backend/impl/snowflake/protocols/partition.py """Snowflake partition support protocol. -Snowflake supports PARTITION BY for external tables. -Standard tables use automatic micro-partitioning and CLUSTER BY. +Snowflake has no declarative (RANGE/LIST/HASH) table partitioning. Standard +tables use automatic micro-partitioning with optional ``CLUSTER BY``. +External tables support ``PARTITION BY ( [, ...] )``. """ -from typing import Protocol, runtime_checkable +from typing import Tuple, Protocol, runtime_checkable @runtime_checkable class SnowflakePartitionSupport(Protocol): """Protocol for Snowflake partition support. - Snowflake supports RANGE and LIST partitioning for external tables. - Standard tables use automatic micro-partitioning. + Declarative RANGE/LIST/HASH partitioning is not supported; external + tables declare partition columns via ``PARTITION BY (cols)``. """ - pass + + def supports_external_table_partitioning(self) -> bool: + """Whether external-table ``PARTITION BY (cols)`` is supported.""" + ... + + def format_external_partition_clause(self, expr) -> Tuple[str, tuple]: + """Format ``PARTITION BY ( [, ...] )`` for external tables.""" + ... diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/table_modifier.py b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/table_modifier.py index 1bb1ebd..16aede4 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/protocols/table_modifier.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/protocols/table_modifier.py @@ -12,7 +12,7 @@ - https://docs.snowflake.com/en/sql-reference/sql/create-table - https://docs.snowflake.com/en/sql-reference/sql/alter-table """ -from typing import Protocol, runtime_checkable +from typing import Protocol, Tuple, runtime_checkable @runtime_checkable @@ -23,6 +23,10 @@ def supports_create_or_replace_table(self) -> bool: """Whether CREATE OR REPLACE TABLE is supported.""" ... + def format_cluster_by_clause(self, expr) -> Tuple[str, tuple]: + """Format ``CLUSTER BY ( [, ...] )`` clustering keys.""" + ... + def supports_transient_table(self) -> bool: """Whether TRANSIENT tables are supported.""" ... diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/snowflake/test_partition.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/snowflake/test_partition.py index 5e46e96..cc24d46 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/snowflake/test_partition.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/snowflake/test_partition.py @@ -1,16 +1,20 @@ # tests/rhosocial/activerecord_snowflake_test/feature/backend/snowflake/test_partition.py -"""Tests for Snowflake partition DDL support.""" +"""Tests for Snowflake partition/clustering DDL support. + +Snowflake has no declarative (RANGE/LIST/HASH) partitioning: standard tables +are micro-partitioned automatically with optional CLUSTER BY, and external +tables use PARTITION BY (cols). These tests assert the honest capability bits +and the two valid clause forms. +""" from typing import Set import pytest -from rhosocial.activerecord.backend.dialect import protocols as dialect_protocols from rhosocial.activerecord.backend.dialect import PartitionSupport -from rhosocial.activerecord.backend.dialect.mixins import PartitionMixin from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.bases import BaseExpression -from rhosocial.activerecord.backend.expression.statements.ddl_partition import ( +from rhosocial.activerecord.backend.expression.statements import ( PartitionClause, PartitionStrategy, ) @@ -18,12 +22,14 @@ from rhosocial.activerecord.backend.impl.snowflake.mixins import SnowflakePartitionMixin from rhosocial.activerecord.backend.impl.snowflake.protocols import SnowflakePartitionSupport from rhosocial.activerecord.backend.impl.snowflake.expression import ( - SnowflakePartitionClause, + SnowflakeClusterByClause, + SnowflakeExternalPartitionClause, ) class MockKey(BaseExpression): """Mock key expression for testing.""" + def __init__(self, dialect, name="col"): super().__init__(dialect) self._name = name @@ -37,11 +43,6 @@ def dialect(): return SnowflakeDialect(version=(8, 0, 0)) -@pytest.fixture -def old_dialect(): - return SnowflakeDialect(version=(6, 0, 0)) - - class TestSnowflakePartitionProtocolConformance: """Verify SnowflakePartitionSupport protocol and mixin.""" @@ -52,9 +53,7 @@ def test_partition_mixin_satisfies_partition_support_protocol(self): if name.startswith("supports_") or name == "format_partition_clause": if not hasattr(SnowflakePartitionMixin, name): missing.add(name) - assert not missing, ( - f"SnowflakePartitionMixin missing methods: {missing}" - ) + assert not missing, f"SnowflakePartitionMixin missing methods: {missing}" def test_snowflake_partition_protocol_methods_are_implemented(self, dialect): """Dialect should satisfy isinstance checks for partition protocols.""" @@ -63,18 +62,13 @@ def test_snowflake_partition_protocol_methods_are_implemented(self, dialect): class TestSnowflakePartitionCapabilities: - """Test partition capability detection.""" + """Snowflake has no declarative table partitioning; capabilities are False.""" - def test_supports_table_partitioning(self, dialect): - assert dialect.supports_table_partitioning() is True - - def test_supports_range_partitioning(self, dialect): - assert dialect.supports_range_table_partitioning() is True - - def test_supports_list_partitioning(self, dialect): - assert dialect.supports_list_table_partitioning() is True - - def test_hash_not_supported(self, dialect): + def test_no_declarative_table_partitioning(self, dialect): + assert dialect.supports_table_partitioning() is False + assert dialect.supports_partitioned_table_creation() is False + assert dialect.supports_range_table_partitioning() is False + assert dialect.supports_list_table_partitioning() is False assert dialect.supports_hash_table_partitioning() is False def test_subpartitioning_not_supported(self, dialect): @@ -88,75 +82,56 @@ def test_maintenance_operations_not_supported(self, dialect): assert dialect.supports_attach_partition() is False assert dialect.supports_detach_partition() is False + def test_external_table_partitioning_supported(self, dialect): + assert dialect.supports_external_table_partitioning() is True -class TestSnowflakePartitionClauseFormatting: - """Test PARTITION BY clause SQL generation.""" - def test_format_partition_clause_range(self, dialect): - key = MockKey(dialect, "date_col") +class TestSnowflakeGenericPartitionClauseRejected: + """The generic RANGE/LIST/HASH PartitionClause is not valid Snowflake SQL.""" - clause = SnowflakePartitionClause( - dialect, - PartitionStrategy.RANGE, - [key], - partitions=[ - {"name": "p1", "values": ["'2020-01-01'"]}, - {"name": "p2", "values": ["'2022-01-01'"]}, - ], - ) + def test_generic_partition_clause_rejected(self, dialect): + clause = PartitionClause(dialect, PartitionStrategy.RANGE, [MockKey(dialect, "id")]) + with pytest.raises(UnsupportedFeatureError): + clause.to_sql() + + +class TestSnowflakeClusterByClause: + """CLUSTER BY is the standard-table data-layout mechanism.""" + + def test_cluster_by_single_key(self, dialect): + clause = SnowflakeClusterByClause(dialect, [MockKey(dialect, "ts")]) sql, params = clause.to_sql() - assert "PARTITION BY RANGE" in sql - assert '"date_col"' in sql - assert "PARTITION p1" in sql - assert "VALUES LESS THAN" in sql - assert "'2020-01-01'" in sql - - def test_format_partition_clause_list(self, dialect): - key = MockKey(dialect, "region") - - clause = SnowflakePartitionClause( - dialect, - PartitionStrategy.LIST, - [key], - partitions=[ - {"name": "p_north", "values": ["'North'"]}, - ], + assert sql == ' CLUSTER BY ("ts")' + assert params == () + + def test_cluster_by_multiple_keys(self, dialect): + clause = SnowflakeClusterByClause( + dialect, [MockKey(dialect, "date"), MockKey(dialect, "id")] ) - sql, params = clause.to_sql() - assert "PARTITION BY LIST" in sql - assert "VALUES IN" in sql + sql, _ = clause.to_sql() + assert sql == ' CLUSTER BY ("date", "id")' - def test_format_partition_clause_without_partitions(self, dialect): - key = MockKey(dialect, "id") + def test_cluster_by_requires_keys(self, dialect): + with pytest.raises(ValueError, match="at least one clustering key"): + SnowflakeClusterByClause(dialect, []) - clause = PartitionClause( - dialect, - PartitionStrategy.RANGE, - [key], - ) - sql, params = clause.to_sql() - assert "PARTITION BY RANGE" in sql - assert '"id"' in sql - def test_format_partition_clause_multiple_keys(self, dialect): - key1 = MockKey(dialect, "year") - key2 = MockKey(dialect, "month") +class TestSnowflakeExternalPartitionClause: + """External tables use PARTITION BY (cols) — no method, no VALUES.""" - clause = PartitionClause( - dialect, - PartitionStrategy.RANGE, - [key1, key2], - ) + def test_external_partition_single_column(self, dialect): + clause = SnowflakeExternalPartitionClause(dialect, [MockKey(dialect, "date_part")]) sql, params = clause.to_sql() - assert "PARTITION BY RANGE" in sql - assert '"year"' in sql - assert '"month"' in sql - - def test_partition_not_supported_on_default_mixin(self): - """UnsupportedFeatureError should be raised on plain PartitionMixin.""" - class TestDialect: - name = "test" - mixin = PartitionMixin() - mixin.name = "test" - with pytest.raises(UnsupportedFeatureError): - mixin.format_partition_clause(None) + assert sql == ' PARTITION BY ("date_part")' + assert params == () + + def test_external_partition_multiple_columns(self, dialect): + clause = SnowflakeExternalPartitionClause( + dialect, [MockKey(dialect, "col1"), MockKey(dialect, "col2")] + ) + sql, _ = clause.to_sql() + assert sql == ' PARTITION BY ("col1", "col2")' + + def test_external_partition_requires_columns(self, dialect): + with pytest.raises(ValueError, match="at least one partition column"): + SnowflakeExternalPartitionClause(dialect, []) From e5b9925ec2797baf1a3aa4e945a236ffc8bd4fd6 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 12:09:47 +0800 Subject: [PATCH 03/28] feat(snowflake): add SnowflakeCreateTableOptions and render TRANSIENT Introduce SnowflakeCreateTableOptions (deriving the generic CreateTableOptions) carrying the Snowflake-only TRANSIENT header modifier. The generic format_create_table_options no longer renders TRANSIENT; Snowflake overrides it to add TRANSIENT and accepts both the generic and Snowflake options. (cherry picked from commit bbd0fac76ce4b29c428155e6e907003a76f2fe5e) (cherry picked from commit 8046777bfe1a28729f26babd269cdf3be0999b43) --- .../impl/snowflake/expression/__init__.py | 2 + .../snowflake/expression/table_options.py | 38 +++++++++++++++++++ .../snowflake/mixins/ddl_table_modifier.py | 19 ++++++++++ 3 files changed, 59 insertions(+) create mode 100644 src/rhosocial/activerecord/backend/impl/snowflake/expression/table_options.py diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/__init__.py index 8a2fec5..7bd4d65 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/__init__.py @@ -18,6 +18,7 @@ SnowflakeVarcharType, SnowflakeVariantType, ) +from .table_options import SnowflakeCreateTableOptions from .partition import ( SnowflakeClusterByClause, SnowflakeExternalPartitionClause, @@ -95,6 +96,7 @@ "SnowflakeTimestampTzType", "SnowflakeVarcharType", "SnowflakeVariantType", + "SnowflakeCreateTableOptions", "SnowflakeClusterByClause", "SnowflakeExternalPartitionClause", "SnowflakeIdentifierExpression", diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/table_options.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/table_options.py new file mode 100644 index 0000000..1c54f3a --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/table_options.py @@ -0,0 +1,38 @@ +# src/rhosocial/activerecord/backend/impl/snowflake/expression/table_options.py +"""Snowflake-specific CREATE TABLE options. + +Snowflake adds the ``TRANSIENT`` header modifier to ``CREATE TABLE``, which +has no generic equivalent. It lives on ``SnowflakeCreateTableOptions`` +(deriving the generic ``CreateTableOptions``) and is rendered by the Snowflake +``format_create_table_options`` override. +""" + +from typing import Optional, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.statements import CreateTableOptions + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "SnowflakeCreateTableOptions", +] + + +class SnowflakeCreateTableOptions(CreateTableOptions): + """A Snowflake CREATE TABLE options declaration extending the generic one. + + Adds the Snowflake-only ``transient`` header modifier. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + *, + or_replace: bool = False, + comment: Optional[str] = None, + transient: bool = False, + ): + super().__init__(dialect, or_replace=or_replace, comment=comment) + self.transient = transient diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_table_modifier.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_table_modifier.py index 50303db..256fa67 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_table_modifier.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_table_modifier.py @@ -49,6 +49,25 @@ def supports_cluster_by(self) -> bool: """Snowflake supports CLUSTER BY clustering keys.""" return True + def format_create_table_options(self, expr) -> Tuple[str, tuple]: + """Format the CREATE header modifiers for Snowflake. + + Accepts both the generic ``CreateTableOptions`` (renders ``OR REPLACE``) + and the Snowflake ``SnowflakeCreateTableOptions`` (adds ``TRANSIENT``). + """ + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + from ..expression.table_options import SnowflakeCreateTableOptions + + base_sql, params = super().format_create_table_options(expr) + parts = [base_sql] if base_sql else [] + if isinstance(expr, SnowflakeCreateTableOptions) and expr.transient: + if not self.supports_transient_table(): + raise UnsupportedFeatureError(self.name, "CREATE TRANSIENT TABLE") + parts.append("TRANSIENT") + return " ".join(parts), params + def supports_search_optimization(self) -> bool: """Snowflake supports SEARCH OPTIMIZATION.""" return True From 5e6dce1c7868b41eee4c8568606f8dea10bb630d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 12:46:47 +0800 Subject: [PATCH 04/28] test(snowflake): use SnowflakeCreateTableOptions for transient tests TRANSIENT now lives on the typed SnowflakeCreateTableOptions. (cherry picked from commit 05333a8ac4e28d7745371d38e202f1cad31ecdd7) (cherry picked from commit 8b8794dd69ecb301298d2a0f01ef8c9f16259798) --- .../feature/backend/ddl/test_create_table_family.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_create_table_family.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_create_table_family.py index e7c481b..de16402 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_create_table_family.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_create_table_family.py @@ -8,6 +8,9 @@ import pytest from rhosocial.activerecord.backend.impl.snowflake.dialect import SnowflakeDialect +from rhosocial.activerecord.backend.impl.snowflake.expression import ( + SnowflakeCreateTableOptions, +) from rhosocial.activerecord.backend.expression import ( CreateTableLikeExpression, CreateTableCloneExpression, @@ -73,7 +76,7 @@ def test_create_transient(self, dialect): dialect, table="t", columns=[], - table_options=CreateTableOptions(dialect, transient=True), + table_options=SnowflakeCreateTableOptions(dialect, transient=True), ) sql, _ = expr.to_sql() assert sql.startswith("CREATE TRANSIENT TABLE") @@ -83,7 +86,9 @@ def test_create_or_replace_transient(self, dialect): dialect, table="t", columns=[], - table_options=CreateTableOptions(dialect, or_replace=True, transient=True), + table_options=SnowflakeCreateTableOptions( + dialect, or_replace=True, transient=True + ), ) sql, _ = expr.to_sql() assert sql.startswith("CREATE OR REPLACE TRANSIENT TABLE") From 0a368f9431fdab08633c9b400984a799b1265629 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 14:45:14 +0800 Subject: [PATCH 05/28] refactor(snowflake): typed database and insert-overwrite expressions Add SnowflakeCreateDatabaseExpression (transient/clone) and SnowflakeInsertExpression (overwrite), replacing dialect_options reads. (cherry picked from commit bd88851d9d5a921c07adb4a97076b0ac8d523339) (cherry picked from commit ca1b76d3d48f31149710deba069305f2e0740561) --- .../impl/snowflake/expression/__init__.py | 4 ++ .../impl/snowflake/expression/database.py | 57 +++++++++++++++++++ .../backend/impl/snowflake/expression/dml.py | 44 ++++++++++++++ .../impl/snowflake/mixins/ddl_database.py | 5 +- .../backend/impl/snowflake/mixins/dml.py | 10 ++-- .../backend/dml/test_insert_overwrite.py | 11 ++-- 6 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/snowflake/expression/database.py create mode 100644 src/rhosocial/activerecord/backend/impl/snowflake/expression/dml.py diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/__init__.py index 7bd4d65..c5c6db9 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/__init__.py @@ -19,6 +19,8 @@ SnowflakeVariantType, ) from .table_options import SnowflakeCreateTableOptions +from .database import SnowflakeCreateDatabaseExpression +from .dml import SnowflakeInsertExpression from .partition import ( SnowflakeClusterByClause, SnowflakeExternalPartitionClause, @@ -97,6 +99,8 @@ "SnowflakeVarcharType", "SnowflakeVariantType", "SnowflakeCreateTableOptions", + "SnowflakeCreateDatabaseExpression", + "SnowflakeInsertExpression", "SnowflakeClusterByClause", "SnowflakeExternalPartitionClause", "SnowflakeIdentifierExpression", diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/database.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/database.py new file mode 100644 index 0000000..120e697 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/database.py @@ -0,0 +1,57 @@ +# src/rhosocial/activerecord/backend/impl/snowflake/expression/database.py +"""Snowflake-specific CREATE DATABASE expression.""" + +from typing import TYPE_CHECKING, Optional + +from rhosocial.activerecord.backend.expression.statements.ddl_database import ( + CreateDatabaseExpression, +) + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +class SnowflakeCreateDatabaseExpression(CreateDatabaseExpression): + """A Snowflake CREATE DATABASE statement extending the generic one. + + Adds the Snowflake-only ``TRANSIENT`` qualifier and ``CLONE `` + form, which have no generic equivalent. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + database_name: str, + if_not_exists: bool = False, + owner: Optional[str] = None, + encoding: Optional[str] = None, + collation: Optional[str] = None, + tablespace: Optional[str] = None, + template: Optional[str] = None, + connection_limit: Optional[int] = None, + comment: Optional[str] = None, + or_replace: bool = False, + *, + transient: bool = False, + clone: Optional[str] = None, + ): + super().__init__( + dialect, + database_name=database_name, + if_not_exists=if_not_exists, + owner=owner, + encoding=encoding, + collation=collation, + tablespace=tablespace, + template=template, + connection_limit=connection_limit, + comment=comment, + or_replace=or_replace, + ) + self.transient = transient + self.clone = clone + + +__all__ = [ + "SnowflakeCreateDatabaseExpression", +] diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/dml.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/dml.py new file mode 100644 index 0000000..20b0efd --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/dml.py @@ -0,0 +1,44 @@ +# src/rhosocial/activerecord/backend/impl/snowflake/expression/dml.py +"""Snowflake-specific DML expression classes.""" + +from typing import TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.statements import InsertExpression + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +class SnowflakeInsertExpression(InsertExpression): + """A Snowflake INSERT statement extending the generic one. + + Snowflake supports ``INSERT OVERWRITE INTO`` which truncates the target + table inside the same transaction; the flag lives here as a typed field and + is rendered by ``SnowflakeDMLMixin.format_insert_statement``. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + into, + source, + columns=None, + *, + on_conflict=None, + returning=None, + overwrite: bool = False, + ): + super().__init__( + dialect, + into=into, + source=source, + columns=columns, + on_conflict=on_conflict, + returning=returning, + ) + self.overwrite = overwrite + + +__all__ = [ + "SnowflakeInsertExpression", +] diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_database.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_database.py index 0986248..b065210 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_database.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_database.py @@ -61,14 +61,13 @@ def format_create_database_statement( parts = ["CREATE"] if expr.or_replace: parts.append("OR REPLACE") - transient = expr.dialect_options.get("transient", False) - if transient: + if getattr(expr, "transient", False): parts.append("TRANSIENT") parts.append("DATABASE") if expr.if_not_exists: parts.append("IF NOT EXISTS") parts.append(self.format_identifier(expr.database_name)) - clone_source = expr.dialect_options.get("clone") + clone_source = getattr(expr, "clone", None) if clone_source: parts.append(f"CLONE {self.format_identifier(clone_source)}") if expr.comment: diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/dml.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/dml.py index 29e2720..de64cd8 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/dml.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/dml.py @@ -12,9 +12,9 @@ class SnowflakeDMLMixin: """Mixin for Snowflake DML statement support. - Adds ``INSERT OVERWRITE`` on top of the core ``DMLMixin``. The core - ``InsertExpression`` carries the ``overwrite`` flag through its - ``dialect_options`` so no core changes are required. + Adds ``INSERT OVERWRITE`` on top of the core ``DMLMixin``. The Snowflake + ``SnowflakeInsertExpression`` carries the ``overwrite`` flag as a typed + field so no core changes are required. """ def supports_insert_overwrite(self) -> bool: @@ -26,7 +26,7 @@ def format_insert_statement( ) -> Tuple[str, tuple]: """Format an INSERT statement, honouring the ``overwrite`` option. - When ``expr.dialect_options["overwrite"]`` is truthy the statement is + When ``getattr(expr, "overwrite", False)`` is truthy the statement is rendered as ``INSERT OVERWRITE INTO ...``. Args: @@ -36,7 +36,7 @@ def format_insert_statement( Tuple of (SQL string, params tuple). """ - if expr.dialect_options.get("overwrite"): + if getattr(expr, "overwrite", False): return self.format_insert_overwrite_statement(expr) return super().format_insert_statement(expr) diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/dml/test_insert_overwrite.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/dml/test_insert_overwrite.py index edcc27f..e0ab1ad 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/dml/test_insert_overwrite.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/dml/test_insert_overwrite.py @@ -16,6 +16,9 @@ ValuesSource, ) from rhosocial.activerecord.backend.impl.snowflake.dialect import SnowflakeDialect +from rhosocial.activerecord.backend.impl.snowflake.expression import ( + SnowflakeInsertExpression, +) from rhosocial.activerecord.backend.impl.snowflake.protocols import ( SnowflakeDMLSupport, ) @@ -50,12 +53,12 @@ class TestSnowflakeInsertOverwrite: """INSERT OVERWRITE statement generation.""" def test_insert_overwrite_via_dialect_option(self, dialect, select_source): - expr = InsertExpression( + expr = SnowflakeInsertExpression( dialect, into="t", source=select_source, columns=["id", "name"], - dialect_options={"overwrite": True}, + overwrite=True, ) sql, params = expr.to_sql() assert sql == ( @@ -89,11 +92,11 @@ def test_insert_overwrite_values_source(self, dialect): Literal(dialect, "b"), ], ] - expr = InsertExpression( + expr = SnowflakeInsertExpression( dialect, into="t", source=ValuesSource(dialect, values), - dialect_options={"overwrite": True}, + overwrite=True, ) sql, params = expr.to_sql() assert sql == ( From 5115baada1b3ec013f7588e7a20c1288942265e9 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 18:05:09 +0800 Subject: [PATCH 06/28] refactor(snowflake): drop reserved dialect_options from partition clauses (cherry picked from commit d24c51b16ae196aa3ee20d9d243de4443fe4537f) (cherry picked from commit d53f5305db2a9141af47dfc3210b64ba1aea0efb) --- .../impl/snowflake/expression/partition.py | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/partition.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/partition.py index 5d8597e..71530de 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/partition.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/partition.py @@ -13,7 +13,7 @@ and is rejected by the Snowflake partition mixin. """ -from typing import Any, Dict, Optional, Sequence, TYPE_CHECKING +from typing import Sequence, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -39,8 +39,6 @@ def __init__( self, dialect: "SQLDialectBase", keys: Sequence[BaseExpression], - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) if not keys: @@ -51,13 +49,7 @@ def __init__( "clustering keys must be BaseExpression instances, " f"got {type(key).__name__}" ) - if dialect_options is not None and not isinstance(dialect_options, dict): - raise TypeError( - "dialect_options must be a dict when provided, " - f"got {type(dialect_options).__name__}" - ) self.keys = list(keys) - self.dialect_options = dict(dialect_options or {}) @property def format_method(self) -> str: @@ -80,8 +72,6 @@ def __init__( self, dialect: "SQLDialectBase", columns: Sequence[BaseExpression], - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) if not columns: @@ -92,13 +82,7 @@ def __init__( "partition columns must be BaseExpression instances, " f"got {type(column).__name__}" ) - if dialect_options is not None and not isinstance(dialect_options, dict): - raise TypeError( - "dialect_options must be a dict when provided, " - f"got {type(dialect_options).__name__}" - ) self.columns = list(columns) - self.dialect_options = dict(dialect_options or {}) @property def format_method(self) -> str: From cc682dc02b262c26acc26e5e477ab292509e9949 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 19:56:41 +0800 Subject: [PATCH 07/28] refactor(snowflake): drop dialect_options from data-type value objects Core DataType no longer carries a dialect_options bag; backend type subclasses stop forwarding it. Update the type-protocol tests. (cherry picked from commit f441874696449f9d601c15c5401d9a11b7f618b6) (cherry picked from commit ce04ee3545cacec748ae210373fc69ac15b5ca0c) --- .../impl/snowflake/expression/types.py | 49 +++++++------------ .../types/test_snowflake_type_protocol.py | 41 ++-------------- 2 files changed, 23 insertions(+), 67 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/types.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/types.py index 69b923e..f10a580 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/types.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/types.py @@ -19,7 +19,7 @@ - GEOGRAPHY - GEOMETRY """ -from typing import Any, Dict, Optional +from typing import Any, Optional from rhosocial.activerecord.backend.expression.types._base import DataType from rhosocial.activerecord.backend.expression.types.integer import IntegerType @@ -57,9 +57,8 @@ class SnowflakeVarcharType(SnowflakeDataTypeMixin, VarCharType): name = "snowflake_varchar" - def __init__(self, dialect=None, *, length: Optional[int] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, length=length, dialect_options=dialect_options) + def __init__(self, dialect=None, *, length: Optional[int] = None): + super().__init__(dialect, length=length) def _type_params(self) -> tuple: return (self.length,) @@ -81,10 +80,8 @@ class SnowflakeNumberType(SnowflakeDataTypeMixin, DecimalType): name = "snowflake_number" def __init__(self, dialect=None, *, precision: Optional[int] = None, - scale: Optional[int] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, precision=precision, scale=scale, - dialect_options=dialect_options) + scale: Optional[int] = None): + super().__init__(dialect, precision=precision, scale=scale) def _type_params(self) -> tuple: return (self.precision, self.scale) @@ -105,9 +102,8 @@ class SnowflakeFloatType(SnowflakeDataTypeMixin, FloatType): name = "snowflake_float" - def __init__(self, dialect=None, *, precision: Optional[int] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, precision=precision, dialect_options=dialect_options) + def __init__(self, dialect=None, *, precision: Optional[int] = None): + super().__init__(dialect, precision=precision) def _type_params(self) -> tuple: return (self.precision,) @@ -139,9 +135,8 @@ class SnowflakeTimestampLtzType(SnowflakeDataTypeMixin, TimestampType): name = "snowflake_timestamp_ltz" - def __init__(self, dialect=None, *, precision: Optional[int] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, precision=precision, dialect_options=dialect_options) + def __init__(self, dialect=None, *, precision: Optional[int] = None): + super().__init__(dialect, precision=precision) def _type_params(self) -> tuple: return (self.precision,) @@ -160,9 +155,8 @@ class SnowflakeTimestampNtzType(SnowflakeDataTypeMixin, TimestampType): name = "snowflake_timestamp_ntz" - def __init__(self, dialect=None, *, precision: Optional[int] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, precision=precision, dialect_options=dialect_options) + def __init__(self, dialect=None, *, precision: Optional[int] = None): + super().__init__(dialect, precision=precision) def _type_params(self) -> tuple: return (self.precision,) @@ -181,9 +175,8 @@ class SnowflakeTimestampTzType(SnowflakeDataTypeMixin, TimestampType): name = "snowflake_timestamp_tz" - def __init__(self, dialect=None, *, precision: Optional[int] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, precision=precision, dialect_options=dialect_options) + def __init__(self, dialect=None, *, precision: Optional[int] = None): + super().__init__(dialect, precision=precision) def _type_params(self) -> tuple: return (self.precision,) @@ -210,9 +203,8 @@ class SnowflakeTimeType(SnowflakeDataTypeMixin, TimeType): name = "snowflake_time" - def __init__(self, dialect=None, *, precision: Optional[int] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, precision=precision, dialect_options=dialect_options) + def __init__(self, dialect=None, *, precision: Optional[int] = None): + super().__init__(dialect, precision=precision) def _type_params(self) -> tuple: return (self.precision,) @@ -235,9 +227,8 @@ class SnowflakeBinaryType(SnowflakeDataTypeMixin, BlobType): length: Optional[int] = None - def __init__(self, dialect=None, *, length: Optional[int] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect=dialect, dialect_options=dialect_options) + def __init__(self, dialect=None, *, length: Optional[int] = None): + super().__init__(dialect=dialect) self.length = length def _type_params(self) -> tuple: @@ -277,10 +268,8 @@ class SnowflakeArrayType(SnowflakeDataTypeMixin, ArrayType): name = "snowflake_array" - def __init__(self, dialect=None, *, element_type: Optional[DataType] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, element_type=element_type or IntegerType(), - dialect_options=dialect_options) + def __init__(self, dialect=None, *, element_type: Optional[DataType] = None): + super().__init__(dialect, element_type=element_type or IntegerType()) def _type_params(self) -> tuple: return (type(self.element_type),) diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/types/test_snowflake_type_protocol.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/types/test_snowflake_type_protocol.py index b3190a8..7899470 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/types/test_snowflake_type_protocol.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/types/test_snowflake_type_protocol.py @@ -59,42 +59,17 @@ def test_all_names_match_regex(self): ) -class TestDialectOptionsForwarding: - def test_varchar_forwards_dialect_options(self): - opts = {"max_length": 100} - t = SnowflakeVarcharType(dialect_options=opts) - assert t.dialect_options == opts +class TestDialectOptionsRemoved: + def test_constructor_rejects_dialect_options(self): + with pytest.raises(TypeError): + SnowflakeVarcharType(length=100, dialect_options={"x": 1}) - def test_number_forwards_dialect_options(self): - opts = {"unsigned": True} - t = SnowflakeNumberType(precision=10, dialect_options=opts) - assert t.dialect_options == opts - def test_float_forwards_dialect_options(self): - opts = {"storage": "8bytes"} - t = SnowflakeFloatType(precision=53, dialect_options=opts) - assert t.dialect_options == opts - def test_timestamp_ltz_forwards_dialect_options(self): - opts = {"tz": "PST"} - t = SnowflakeTimestampLtzType(dialect_options=opts) - assert t.dialect_options == opts - def test_timestamp_ntz_default_dialect_options(self): - t = SnowflakeTimestampNtzType() - assert t.dialect_options == {} - def test_time_forwards_dialect_options(self): - t = SnowflakeTimeType(precision=3, dialect_options={"utc": True}) - assert t.dialect_options == {"utc": True} - def test_binary_forwards_dialect_options(self): - t = SnowflakeBinaryType(length=1024, dialect_options={"compress": True}) - assert t.dialect_options == {"compress": True} - def test_array_forwards_dialect_options(self): - t = SnowflakeArrayType(dialect_options={"max_items": 100}) - assert t.dialect_options == {"max_items": 100} class TestTypeParams: @@ -158,15 +133,7 @@ def test_type_mismatch_not_equal(self): b = SnowflakeNumberType(precision=100) assert a != b - def test_dialect_options_participate_in_eq(self): - a = SnowflakeVarcharType(length=100, dialect_options={"x": 1}) - b = SnowflakeVarcharType(length=100, dialect_options={"x": 2}) - assert a != b - def test_dialect_options_not_in_hash(self): - a = SnowflakeVarcharType(length=100, dialect_options={"x": 1}) - b = SnowflakeVarcharType(length=100, dialect_options={"x": 2}) - assert hash(a) == hash(b) class TestNoHandWrittenEqHash: From abc860c1c9afdbe2749460d1b9e7a804b17d85bd Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 22:34:30 +0800 Subject: [PATCH 08/28] test(snowflake): pin generated-column capability bits (cherry picked from commit 7cfcc817c69ff5afd665620acbfbe7445516ce55) (cherry picked from commit bb908815bc7e6fda60b018057dbb5d469592f530) --- .../test_generated_column_capabilities.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 tests/rhosocial/activerecord_snowflake_test/feature/backend/test_generated_column_capabilities.py diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_generated_column_capabilities.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_generated_column_capabilities.py new file mode 100644 index 0000000..f423a6b --- /dev/null +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_generated_column_capabilities.py @@ -0,0 +1,17 @@ +# tests/rhosocial/activerecord_snowflake_test/feature/backend/test_generated_column_capabilities.py +"""Explicit Snowflake generated-column capability assertions. + +Snowflake supports virtual (expression) computed columns but not stored ones. +""" + +from rhosocial.activerecord.backend.impl.snowflake.dialect import SnowflakeDialect + + +def test_generated_columns_supported(): + assert SnowflakeDialect((8, 0, 0)).supports_generated_columns() is True + + +def test_stored_unsupported_virtual_supported(): + dialect = SnowflakeDialect((8, 0, 0)) + assert dialect.supports_stored_generated_columns() is False + assert dialect.supports_virtual_generated_columns() is True From ae48491c2c9b4c0860eb84f013b614eeba457d46 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 21 Sep 2026 00:19:31 +0800 Subject: [PATCH 09/28] fix(snowflake): wire SnowflakeDatabaseMixin into the dialect (cherry picked from commit b8aa4781e8a2f070cf60accbe0bcc8205812ba55) (cherry picked from commit 777b306d147c08b9d163a1b4b381af1be657ab17) --- .../backend/impl/snowflake/dialect.py | 2 ++ .../backend/test_snowflake_database_ddl.py | 29 +++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 tests/rhosocial/activerecord_snowflake_test/feature/backend/test_snowflake_database_ddl.py diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/dialect.py b/src/rhosocial/activerecord/backend/impl/snowflake/dialect.py index 8a62502..a28060c 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/dialect.py @@ -135,6 +135,7 @@ SnowflakeAlterColumnModifierMixin, SnowflakeWarehouseMixin, SnowflakeSchemaMixin, + SnowflakeDatabaseMixin, # New mixins from dialect.py split SnowflakeDateTimeMixin, SnowflakeCollationMixin, @@ -194,6 +195,7 @@ class SnowflakeDialect( ViewMixin, TruncateMixin, SnowflakeSchemaMixin, # Before SchemaMixin to enable Snowflake schema DDL + SnowflakeDatabaseMixin, SchemaMixin, IndexMixin, SnowflakeTableModifierMixin, # Before TableMixin to override create-table capability flags diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_snowflake_database_ddl.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_snowflake_database_ddl.py new file mode 100644 index 0000000..092a6a2 --- /dev/null +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_snowflake_database_ddl.py @@ -0,0 +1,29 @@ +# tests/rhosocial/activerecord_snowflake_test/feature/backend/test_snowflake_database_ddl.py +"""Explicit SnowflakeDialect database DDL capability + rendering tests.""" + +from rhosocial.activerecord.backend.expression.statements.ddl_database import ( + CreateDatabaseExpression, + DropDatabaseExpression, +) +from rhosocial.activerecord.backend.impl.snowflake.dialect import SnowflakeDialect + + +def _dialect(): + return SnowflakeDialect() + + +def test_database_capabilities(): + dialect = _dialect() + assert dialect.supports_create_database() is True + assert dialect.supports_drop_database() is True + + +def test_create_database_renders(): + sql, params = CreateDatabaseExpression(_dialect(), database_name="app").to_sql() + assert "CREATE DATABASE" in sql + assert params == () + + +def test_drop_database_renders(): + sql, _ = DropDatabaseExpression(_dialect(), database_name="app").to_sql() + assert "DROP DATABASE" in sql From a60a23993389f42f4e6f7b5f3bc74358a2da90a4 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 21 Sep 2026 22:31:47 +0800 Subject: [PATCH 10/28] test(ddl): classify ColumnAttributeSupport; use deriver.creation_plan() Core added the ColumnAttributeSupport protocol and renamed the deriver plan entry to creation_plan(); update protocol conformance and derived-DDL tests. (cherry picked from commit 9271a8b155b874f0a1bd01d063e6a3f0b9701be2) (cherry picked from commit 19fb76f852ed5ac49254c45ebab7d5d2320889c9) --- .../feature/backend/protocol/test_protocol_conformance.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/protocol/test_protocol_conformance.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/protocol/test_protocol_conformance.py index d275f6a..5fcdedb 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/protocol/test_protocol_conformance.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/protocol/test_protocol_conformance.py @@ -30,6 +30,7 @@ AdvancedGroupingSupport, AutoIncrementSupport, CollationSupport, + ColumnAttributeSupport, ConstraintSupport, CTESupport, DDLTypeSupport, @@ -71,6 +72,7 @@ AutoIncrementSupport, CollationSupport, CTESupport, + ColumnAttributeSupport, DDLTypeSupport, ExplainSupport, FilterClauseSupport, From 7de0edf1a3e13d43ef30c159b424169bf5c91cc6 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 21 Sep 2026 23:59:21 +0800 Subject: [PATCH 11/28] feat(snowflake): IDENTITY(start, step) and quoted column collation Snowflake has no SQL-standard GENERATED ... AS IDENTITY column syntax; render IDENTITY(start, step) (synonym for AUTOINCREMENT). Column collation must be a quoted specification (COLLATE 'en-ci'), so override format_column_attribute. (cherry picked from commit 55cc82601358e94cb337bc850b939c85301b2894) (cherry picked from commit 7366f4427f2c19a36fb0635983046d7b6beccdc1) --- .../backend/impl/snowflake/mixins/collation.py | 12 +++++++++++- .../impl/snowflake/mixins/generated_column.py | 12 ++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/collation.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/collation.py index 54d5f87..097cc4c 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/collation.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/collation.py @@ -1,6 +1,6 @@ # src/rhosocial/activerecord/backend/impl/snowflake/mixins/collation.py """Snowflake collation support mixin.""" -from typing import TYPE_CHECKING +from typing import Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError @@ -24,5 +24,15 @@ def validate_collation_name(self, expr: "CollateExpression") -> str: spec = validate_snowflake_collation_name(expr.collation_name, getattr(self, "version", None)) return f"'{self._escape_sql_string(spec)}'" + def format_column_attribute(self, attr) -> Tuple[str, tuple]: + """Snowflake requires a quoted collation specification on a column.""" + from rhosocial.activerecord.base.ddl.attributes import CollationAttribute + from ..collation import validate_snowflake_collation_name + + if isinstance(attr, CollationAttribute): + spec = validate_snowflake_collation_name(attr.name, getattr(self, "version", None)) + return f" COLLATE '{self._escape_sql_string(spec)}'", () + return super().format_column_attribute(attr) + __all__ = ['SnowflakeCollationMixin'] diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/generated_column.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/generated_column.py index 4133136..2aaafe1 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/generated_column.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/generated_column.py @@ -1,10 +1,22 @@ # src/rhosocial/activerecord/backend/impl/snowflake/mixins/generated_column.py """Snowflake generated column support mixin.""" +from typing import Tuple + class SnowflakeGeneratedColumnMixin: """Snowflake generated (computed) column support.""" + def format_identity_clause(self, expr) -> Tuple[str, tuple]: + """Snowflake uses ``IDENTITY(start, step)`` (synonym for AUTOINCREMENT). + + Snowflake does not accept the SQL-standard + ``GENERATED ... AS IDENTITY`` form. + """ + start = expr.start if expr.start is not None else 1 + increment = expr.increment if expr.increment is not None else 1 + return f" IDENTITY({start}, {increment})", () + def supports_generated_columns(self) -> bool: """Snowflake supports generated (computed) columns.""" return True From f92893c865d68fa45ad180b4c56cbeb5b7daf40f Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 22 Sep 2026 00:01:12 +0800 Subject: [PATCH 12/28] test(snowflake): pin IDENTITY(start, step) and quoted column collation (cherry picked from commit e5b92f702e26cfc5b0cb1efbff78fdbaad3f1986) (cherry picked from commit 56d359fda78deb36f0ced47244707d75fb60b1e8) --- .../backend/ddl/test_column_attributes.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_column_attributes.py diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_column_attributes.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_column_attributes.py new file mode 100644 index 0000000..1a6a372 --- /dev/null +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_column_attributes.py @@ -0,0 +1,31 @@ +# tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_column_attributes.py +"""Snowflake rendering of the dialect-free column-attribute channel.""" + +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition +from rhosocial.activerecord.backend.expression.types import IntegerType +from rhosocial.activerecord.base.ddl.attributes import CollationAttribute, IdentityAttribute +from rhosocial.activerecord.backend.impl.snowflake.dialect import SnowflakeDialect + + +def _column(dialect, attrs): + col = ColumnDefinition(dialect, "id", IntegerType(dialect)) + col.attributes = dialect.select_column_attributes(attrs) + return col + + +def test_identity_uses_identity_start_step(): + dialect = SnowflakeDialect((8, 0, 0)) + col = _column(dialect, [IdentityAttribute(generation="BY DEFAULT", start=5, increment=2)]) + assert col.to_sql()[0] == '"id" INTEGER IDENTITY(5, 2)' + + +def test_identity_defaults_to_start_step_one(): + dialect = SnowflakeDialect((8, 0, 0)) + col = _column(dialect, [IdentityAttribute(generation="ALWAYS")]) + assert col.to_sql()[0] == '"id" INTEGER IDENTITY(1, 1)' + + +def test_collation_is_quoted(): + dialect = SnowflakeDialect((8, 0, 0)) + col = _column(dialect, [CollationAttribute(name="en-ci")]) + assert col.to_sql()[0] == '"id" INTEGER COLLATE \'en-ci\'' From a73aeb38d1c9c3a568413885cc74e347b1020d82 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 23 Sep 2026 18:10:55 +0800 Subject: [PATCH 13/28] feat(snowflake): advertise comment capabilities; render table comment inline Snowflake natively supports both the inline COMMENT = 'text' table option and the standalone COMMENT ON statement; declare supports_table_comment() / supports_column_comment() as True so the capability bits match the actual rendering behavior. format_table_comment renders the Snowflake table-level form (COMMENT = 'text'), the column level keeps the bare form. (cherry picked from commit 304fc4454894427391f0ae627ea608d7ef4e38de) (cherry picked from commit 6b9f701e6995203fdb50ee27b80a0e4b1d7c8d0a) --- .../backend/impl/snowflake/mixins/ddl.py | 11 ++++++++++ .../snowflake/mixins/ddl_table_modifier.py | 22 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl.py index 9ad5127..4f7c327 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl.py @@ -32,6 +32,17 @@ def supports_drop_constraint_if_exists(self) -> bool: """Snowflake does **not** support ``DROP CONSTRAINT IF EXISTS``.""" return False + def supports_column_comment(self) -> bool: + """Whether inline ``COMMENT 'text'`` in a column definition is + supported. + + Snowflake renders column comments natively (the column-level + ``COMMENT 'text'`` form), so the capability advertises True and the + inline path is the rendering path. Declared on this mixin, which is + composed before the generic ``DDLColumnMixin`` in the MRO. + """ + return True + def format_add_column_action(self, action) -> Tuple[str, tuple]: if getattr(action, "if_not_exists", None) is True: forbidden = { diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_table_modifier.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_table_modifier.py index 256fa67..f720862 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_table_modifier.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_table_modifier.py @@ -72,6 +72,28 @@ def supports_search_optimization(self) -> bool: """Snowflake supports SEARCH OPTIMIZATION.""" return True + def supports_table_comment(self) -> bool: + """Whether inline comments on ``CREATE TABLE`` are supported. + + Snowflake natively supports both the inline ``COMMENT = 'text'`` table + option and the standalone ``COMMENT ON`` statement; the inline path is + the rendering path, so the capability advertises True. + """ + return True + + def format_table_comment(self, comment: str) -> Tuple[str, tuple]: + """Render a table-level ``COMMENT = 'text'`` option. + + Snowflake's table-level comment form carries the ``=`` sign (unlike + the bare ``COMMENT 'text'`` of MySQL/MariaDB/ClickHouse, which the + generic core rendering emits); the column-level form stays bare and + is rendered by the generic ``format_column_definition``. + """ + from rhosocial.activerecord.backend.dialect.base import SQLDialectBase + + escaped = SQLDialectBase._escape_sql_string(comment) + return f"COMMENT = '{escaped}'", () + def supports_create_table_like(self) -> bool: """Snowflake supports CREATE TABLE ... LIKE (empty copy).""" return True From 4c60f4e0edb10281846869fb3ebba1d3924f33a9 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 23 Sep 2026 21:53:10 +0800 Subject: [PATCH 14/28] feat(ddl): support standalone COMMENT ON Advertise supports_comment_on and compose CommentOnMixin so the core CommentOnExpression renders native COMMENT ON for tables, columns, and other objects, complementing the inline COMMENT = '...' table option. (cherry picked from commit 03fa5c96f4b978379b1f15d14f5cae1aef44c35e) (cherry picked from commit e0525fd16015dd9883a3c57b4aeaecaa15f2b6d3) --- .../activerecord/backend/impl/snowflake/dialect.py | 2 ++ .../backend/impl/snowflake/mixins/ddl_table_modifier.py | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/dialect.py b/src/rhosocial/activerecord/backend/impl/snowflake/dialect.py index a28060c..cafb42f 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/dialect.py @@ -57,6 +57,7 @@ ArrayMixin, AutoIncrementMixin, CollationMixin, + CommentOnMixin, ConstraintMixin, CTEMixin, DDLColumnMixin, @@ -201,6 +202,7 @@ class SnowflakeDialect( SnowflakeTableModifierMixin, # Before TableMixin to override create-table capability flags TableMixin, ConstraintMixin, + CommentOnMixin, # Snowflake-specific mixins (before generic IntrospectionMixin to override methods) SnowflakeTransactionMixin, SnowflakeTimeTravelMixin, diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_table_modifier.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_table_modifier.py index f720862..4d5d327 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_table_modifier.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_table_modifier.py @@ -81,6 +81,15 @@ def supports_table_comment(self) -> bool: """ return True + def supports_comment_on(self) -> bool: + """Whether standalone ``COMMENT ON`` statements are supported. + + Snowflake natively supports ``COMMENT [IF EXISTS] ON IS + 'text'`` for tables, columns, views, and other objects; the generic + :class:`CommentOnMixin` rendering is used as-is. + """ + return True + def format_table_comment(self, comment: str) -> Tuple[str, tuple]: """Render a table-level ``COMMENT = 'text'`` option. From 5904b16f23a6cb8ea340d1db87865e97ec19b767 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 23 Sep 2026 22:31:40 +0800 Subject: [PATCH 15/28] test(ddl): classify CommentSupport for Snowflake conformance (cherry picked from commit fc1411fca43abe914abeff61c2e68020d01e8a7e) (cherry picked from commit 6139b3a9371a3dad5e19ceba3274b90b4e5385d4) --- .../feature/backend/protocol/test_protocol_conformance.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/protocol/test_protocol_conformance.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/protocol/test_protocol_conformance.py index 5fcdedb..3da627f 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/protocol/test_protocol_conformance.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/protocol/test_protocol_conformance.py @@ -31,6 +31,7 @@ AutoIncrementSupport, CollationSupport, ColumnAttributeSupport, + CommentSupport, ConstraintSupport, CTESupport, DDLTypeSupport, @@ -73,6 +74,7 @@ CollationSupport, CTESupport, ColumnAttributeSupport, + CommentSupport, DDLTypeSupport, ExplainSupport, FilterClauseSupport, From f6f99bfdb7ddf5089e833dfee6563d5bac5b73f1 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 24 Sep 2026 01:11:22 +0800 Subject: [PATCH 16/28] fix(ddl): stop advertising unrenderable ALTER SCHEMA capabilities supports_alter_schema* returned True with no AlterSchemaExpression/formatter, so the switches were dead. Return False until the statement is modelled. Also type SnowflakeCreateTableOptions.comment as TableCommentClause. (cherry picked from commit e78ee1d27392704b4d4373071d98cb8bad924ed0) (cherry picked from commit 8b57feb9216b3a671391c494c6e29edc7809af65) --- .../snowflake/expression/table_options.py | 7 ++++-- .../backend/impl/snowflake/mixins/schema.py | 25 +++++++++++-------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/table_options.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/table_options.py index 1c54f3a..0e10dca 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/table_options.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/table_options.py @@ -9,7 +9,10 @@ from typing import Optional, TYPE_CHECKING -from rhosocial.activerecord.backend.expression.statements import CreateTableOptions +from rhosocial.activerecord.backend.expression.statements import ( + CreateTableOptions, + TableCommentClause, +) if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.dialect import SQLDialectBase @@ -31,7 +34,7 @@ def __init__( dialect: "SQLDialectBase", *, or_replace: bool = False, - comment: Optional[str] = None, + comment: Optional[TableCommentClause] = None, transient: bool = False, ): super().__init__(dialect, or_replace=or_replace, comment=comment) diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/schema.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/schema.py index d8dd73d..eb870f3 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/schema.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/schema.py @@ -49,24 +49,29 @@ def supports_schema_authorization(self) -> bool: return False def supports_alter_schema(self) -> bool: - """Snowflake supports ALTER SCHEMA.""" - return True + """Whether ``ALTER SCHEMA`` is supported. + + Snowflake natively supports ALTER SCHEMA, but there is no + ``AlterSchemaExpression`` / formatter yet, so the capability is + advertised as unsupported to avoid a dead, unrenderable switch. + """ + return False def supports_alter_schema_rename(self) -> bool: - """Snowflake supports ALTER SCHEMA RENAME TO.""" - return True + """Snowflake supports ALTER SCHEMA RENAME TO (not modelled yet).""" + return False def supports_alter_schema_swap(self) -> bool: - """Snowflake supports ALTER SCHEMA SWAP WITH.""" - return True + """Snowflake supports ALTER SCHEMA SWAP WITH (not modelled yet).""" + return False def supports_alter_schema_set_property(self) -> bool: - """Snowflake supports ALTER SCHEMA SET.""" - return True + """Snowflake supports ALTER SCHEMA SET (not modelled yet).""" + return False def supports_alter_schema_managed_access(self) -> bool: - """Snowflake supports ALTER SCHEMA SET MANAGED ACCESS.""" - return True + """Snowflake supports ALTER SCHEMA SET MANAGED ACCESS (not modelled yet).""" + return False def supports_undrop_schema(self) -> bool: """Snowflake supports UNDROP SCHEMA.""" From f515bf57467e0fc74648b9ab2b4763b185cc8c6b Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 24 Sep 2026 09:41:52 +0800 Subject: [PATCH 17/28] feat(snowflake): add type DDL support (cherry picked from commit f0995e1a7be283e3f128e4ec0b373b20ec580b72) (cherry picked from commit 7fb94432c3d3fc9faf6e4deab6919732189fe6a5) --- .../backend/impl/snowflake/__init__.py | 22 + .../backend/impl/snowflake/dialect.py | 14 +- .../impl/snowflake/expression/__init__.py | 18 + .../impl/snowflake/expression/ddl/__init__.py | 18 + .../impl/snowflake/expression/ddl/type.py | 273 +++++++++++ .../impl/snowflake/expression/types.py | 61 ++- .../backend/impl/snowflake/mixins/__init__.py | 2 + .../backend/impl/snowflake/mixins/ddl_type.py | 251 ++++++++++ .../backend/impl/snowflake/mixins/types.py | 30 ++ .../feature/backend/ddl/test_type_ddl.py | 440 ++++++++++++++++++ .../protocol/test_protocol_conformance.py | 5 +- 11 files changed, 1126 insertions(+), 8 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/type.py create mode 100644 src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_type.py create mode 100644 tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_type_ddl.py diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/__init__.py b/src/rhosocial/activerecord/backend/impl/snowflake/__init__.py index 1bda281..b9f14b8 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/__init__.py @@ -49,6 +49,7 @@ SnowflakeStageMixin, SnowflakeConcurrencyMixin, AsyncSnowflakeConcurrencyMixin, + SnowflakeTypeDDLMixin, SnowflakeTypeSupportMixin, SnowflakeWarehouseMixin, ) @@ -71,9 +72,20 @@ SnowflakeTimestampLtzType, SnowflakeTimestampNtzType, SnowflakeTimestampTzType, + SnowflakeUserDefinedType, SnowflakeVarcharType, SnowflakeVariantType, ) +from .expression.ddl.type import ( + SnowflakeTypeField, + SnowflakeScalarTypeDefinition, + SnowflakeObjectTypeDefinition, + SnowflakeSetTypeCommentAction, + SnowflakeUnsetTypeCommentAction, + SnowflakeCreateTypeExpression, + SnowflakeAlterTypeExpression, + SnowflakeDropTypeExpression, +) from .functions import ( array_append, array_agg, @@ -140,6 +152,7 @@ "SnowflakeStageMixin", "SnowflakeConcurrencyMixin", "AsyncSnowflakeConcurrencyMixin", + "SnowflakeTypeDDLMixin", "SnowflakeTypeSupportMixin", "SnowflakeWarehouseMixin", # Field Mixins @@ -163,6 +176,15 @@ "SnowflakeObjectType", "SnowflakeGeographyType", "SnowflakeGeometryType", + "SnowflakeUserDefinedType", + "SnowflakeTypeField", + "SnowflakeScalarTypeDefinition", + "SnowflakeObjectTypeDefinition", + "SnowflakeSetTypeCommentAction", + "SnowflakeUnsetTypeCommentAction", + "SnowflakeCreateTypeExpression", + "SnowflakeAlterTypeExpression", + "SnowflakeDropTypeExpression", # ARRAY functions "array_construct", "array_append", diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/dialect.py b/src/rhosocial/activerecord/backend/impl/snowflake/dialect.py index cafb42f..f205a8f 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/dialect.py @@ -11,7 +11,7 @@ - MERGE with complex conditions - Warehouse-based compute management """ -from typing import Any, Dict, Tuple, TYPE_CHECKING +from typing import Any, Tuple, TYPE_CHECKING if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.transaction import ( @@ -48,6 +48,7 @@ TransactionControlSupport, TruncateSupport, UpsertSupport, + UserDefinedTypeSupport, ViewSupport, WildcardSupport, WindowFunctionSupport, @@ -87,8 +88,6 @@ ViewMixin, WindowFunctionMixin, ) -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError -from .collation import validate_snowflake_collation_name from .reserved_words import SNOWFLAKE_RESERVED_WORDS from .protocols import ( SnowflakeArraySupport, @@ -130,6 +129,7 @@ SnowflakeTaskMixin, SnowflakeTimeTravelMixin, SnowflakeTransactionMixin, + SnowflakeTypeDDLMixin, SnowflakeTypeSupportMixin, SnowflakeUndropMixin, SnowflakeVariantMixin, @@ -173,6 +173,7 @@ class SnowflakeDialect( SnowflakeAlterColumnModifierMixin, # Before DDLColumnMixin to override format_*_action DDLColumnMixin, AutoIncrementMixin, + SnowflakeTypeDDLMixin, SnowflakeTypeSupportMixin, TransactionControlMixin, SetOperationMixin, @@ -230,6 +231,7 @@ class SnowflakeDialect( CTESupport, ConstraintSupport, DDLTypeSupport, + UserDefinedTypeSupport, ExplainSupport, FilterClauseSupport, GeneratedColumnSupport, @@ -290,7 +292,11 @@ class SnowflakeDialect( feature gating where applicable. """ - def __init__(self, version: Tuple[int, ...] = (8, 0, 0), **kwargs): + def __init__( + self, + version: Tuple[int, ...] = (8, 0, 0), + **kwargs: Any, + ) -> None: """Initialize Snowflake dialect with version. Args: diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/__init__.py index c5c6db9..f0f515b 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/__init__.py @@ -15,6 +15,7 @@ SnowflakeTimestampLtzType, SnowflakeTimestampNtzType, SnowflakeTimestampTzType, + SnowflakeUserDefinedType, SnowflakeVarcharType, SnowflakeVariantType, ) @@ -80,6 +81,14 @@ SnowflakeUndropObjectType, SnowflakeUndropExpression, SnowflakeCreateMaterializedViewExpression, + SnowflakeTypeField, + SnowflakeScalarTypeDefinition, + SnowflakeObjectTypeDefinition, + SnowflakeSetTypeCommentAction, + SnowflakeUnsetTypeCommentAction, + SnowflakeCreateTypeExpression, + SnowflakeAlterTypeExpression, + SnowflakeDropTypeExpression, ) __all__ = [ @@ -96,6 +105,7 @@ "SnowflakeTimestampLtzType", "SnowflakeTimestampNtzType", "SnowflakeTimestampTzType", + "SnowflakeUserDefinedType", "SnowflakeVarcharType", "SnowflakeVariantType", "SnowflakeCreateTableOptions", @@ -149,4 +159,12 @@ "SnowflakeUndropObjectType", "SnowflakeUndropExpression", "SnowflakeCreateMaterializedViewExpression", + "SnowflakeTypeField", + "SnowflakeScalarTypeDefinition", + "SnowflakeObjectTypeDefinition", + "SnowflakeSetTypeCommentAction", + "SnowflakeUnsetTypeCommentAction", + "SnowflakeCreateTypeExpression", + "SnowflakeAlterTypeExpression", + "SnowflakeDropTypeExpression", ] diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/__init__.py index c5512a7..cadf190 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/__init__.py @@ -67,6 +67,16 @@ from .materialized_view import ( SnowflakeCreateMaterializedViewExpression, ) +from .type import ( + SnowflakeTypeField, + SnowflakeScalarTypeDefinition, + SnowflakeObjectTypeDefinition, + SnowflakeSetTypeCommentAction, + SnowflakeUnsetTypeCommentAction, + SnowflakeCreateTypeExpression, + SnowflakeAlterTypeExpression, + SnowflakeDropTypeExpression, +) __all__ = [ "SnowflakeAlterWarehouseMode", @@ -104,4 +114,12 @@ "SnowflakeUndropObjectType", "SnowflakeUndropExpression", "SnowflakeCreateMaterializedViewExpression", + "SnowflakeTypeField", + "SnowflakeScalarTypeDefinition", + "SnowflakeObjectTypeDefinition", + "SnowflakeSetTypeCommentAction", + "SnowflakeUnsetTypeCommentAction", + "SnowflakeCreateTypeExpression", + "SnowflakeAlterTypeExpression", + "SnowflakeDropTypeExpression", ] diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/type.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/type.py new file mode 100644 index 0000000..827e020 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/type.py @@ -0,0 +1,273 @@ +"""Snowflake schema-level user-defined type DDL expressions.""" + +from collections.abc import Mapping as ABCMapping +from typing import Any, Optional, Sequence, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.serialization import ExpressionRegistry +from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + AlterTypeExpression, + CreateTypeExpression, + DropTypeExpression, + TypeAlterAction, + TypeDefinition, +) +from rhosocial.activerecord.backend.expression.types import DataType + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +def _validate_name(value: str, field_name: str) -> None: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field_name} must be a non-empty string") + + +def _validate_optional_name(value: Optional[str], field_name: str) -> None: + if value is not None: + _validate_name(value, field_name) + + +def _validate_database_qualification( + database_name: Optional[str], + schema_name: Optional[str], +) -> None: + _validate_optional_name(database_name, "database_name") + if database_name is not None and schema_name is None: + raise ValueError("schema_name is required when database_name is provided") + + +class SnowflakeTypeField(BaseExpression): + """One field in a Snowflake structured OBJECT type definition.""" + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + data_type: DataType, + ) -> None: + super().__init__(dialect) + _validate_name(name, "field name") + if not isinstance(data_type, DataType): + raise TypeError(f"field data_type must be a DataType instance, got {type(data_type).__name__}") + self.name = name + self.data_type = data_type + + @property + def format_method(self) -> str: + return "format_snowflake_type_field" + + +def _coerce_type_field( + value: Any, + dialect: "SQLDialectBase", +) -> SnowflakeTypeField: + if isinstance(value, SnowflakeTypeField): + return value + if isinstance(value, ABCMapping): + if "name" in value and "data_type" in value: + if len(value) != 2: + raise ValueError("field mapping must contain only name and data_type") + return SnowflakeTypeField(dialect, value["name"], value["data_type"]) + if len(value) != 1: + raise ValueError("field mapping must contain one name and data_type pair") + name, data_type = next(iter(value.items())) + return SnowflakeTypeField(dialect, name, data_type) + if isinstance(value, (tuple, list)) and len(value) == 2: + return SnowflakeTypeField(dialect, value[0], value[1]) + raise TypeError( + f"fields must contain SnowflakeTypeField, mapping, or (name, data_type) pairs, got {type(value).__name__}" + ) + + +class SnowflakeScalarTypeDefinition(TypeDefinition): + """Scalar UDT definition based on an existing Snowflake data type.""" + + definition_kind = "snowflake.scalar" + + def __init__( + self, + dialect: "SQLDialectBase", + data_type: DataType, + ) -> None: + super().__init__(dialect) + if not isinstance(data_type, DataType): + raise TypeError(f"data_type must be a DataType instance, got {type(data_type).__name__}") + self.data_type = data_type + + +class SnowflakeObjectTypeDefinition(TypeDefinition): + """Structured UDT definition based on OBJECT fields.""" + + definition_kind = "snowflake.object" + + def __init__( + self, + dialect: "SQLDialectBase", + fields: Any, + ) -> None: + super().__init__(dialect) + if isinstance(fields, ABCMapping) and not ("name" in fields and "data_type" in fields): + raw_fields: Any = [{name: data_type} for name, data_type in fields.items()] + else: + raw_fields = fields + if raw_fields is None: + raw_fields = [] + try: + field_values = list(raw_fields) + except TypeError as exc: + raise TypeError( + f"fields must be a sequence of Snowflake OBJECT type fields, got {type(fields).__name__}" + ) from exc + if not field_values: + raise ValueError("Snowflake OBJECT type definitions require at least one field") + coerced_fields = [_coerce_type_field(value, dialect) for value in field_values] + names = [field.name for field in coerced_fields] + if len(names) != len(set(names)): + raise ValueError("Snowflake OBJECT type field names must be unique") + self.fields = coerced_fields + + +class SnowflakeSetTypeCommentAction(TypeAlterAction): + """SET COMMENT action for an existing Snowflake UDT.""" + + action_kind = "snowflake.set_type_comment" + + def __init__( + self, + dialect: "SQLDialectBase", + comment: str, + ) -> None: + super().__init__(dialect) + if not isinstance(comment, str): + raise TypeError("comment must be a string") + self.comment = comment + + +class SnowflakeUnsetTypeCommentAction(TypeAlterAction): + """UNSET COMMENT action for an existing Snowflake UDT.""" + + action_kind = "snowflake.unset_type_comment" + + +class SnowflakeCreateTypeExpression(CreateTypeExpression): + """CREATE TYPE expression with Snowflake qualification and comment syntax.""" + + def __init__( + self, + dialect: "SQLDialectBase", + type_name: str, + definition: TypeDefinition, + *, + database_name: Optional[str] = None, + schema_name: Optional[str] = None, + if_not_exists: bool = False, + or_replace: bool = False, + comment: Optional[str] = None, + ) -> None: + _validate_database_qualification(database_name, schema_name) + if comment is not None and not isinstance(comment, str): + raise TypeError("comment must be a string or None") + super().__init__( + dialect, + type_name, + definition, + schema_name=schema_name, + if_not_exists=if_not_exists, + or_replace=or_replace, + ) + self.database_name = database_name + self.comment = comment + + +class SnowflakeAlterTypeExpression(AlterTypeExpression): + """ALTER TYPE expression with Snowflake database/schema qualification.""" + + def __init__( + self, + dialect: "SQLDialectBase", + type_name: str, + actions: Sequence[TypeAlterAction], + *, + database_name: Optional[str] = None, + schema_name: Optional[str] = None, + if_exists: bool = False, + ) -> None: + _validate_database_qualification(database_name, schema_name) + super().__init__( + dialect, + type_name, + actions, + schema_name=schema_name, + if_exists=if_exists, + ) + self.database_name = database_name + + +class SnowflakeDropTypeExpression(DropTypeExpression): + """DROP TYPE expression with Snowflake database/schema qualification.""" + + def __init__( + self, + dialect: "SQLDialectBase", + type_name: str, + *, + database_name: Optional[str] = None, + schema_name: Optional[str] = None, + if_exists: bool = False, + cascade: bool = False, + restrict: bool = False, + ) -> None: + _validate_database_qualification(database_name, schema_name) + if not isinstance(cascade, bool): + raise TypeError("cascade must be a bool") + if not isinstance(restrict, bool): + raise TypeError("restrict must be a bool") + if cascade: + raise UnsupportedFeatureError( + dialect.name, + "DROP TYPE CASCADE", + suggestion="Snowflake DROP TYPE does not support CASCADE.", + ) + if restrict: + raise UnsupportedFeatureError( + dialect.name, + "DROP TYPE RESTRICT", + suggestion="Snowflake DROP TYPE does not support RESTRICT.", + ) + super().__init__( + dialect, + type_name, + schema_name=schema_name, + if_exists=if_exists, + ) + self.database_name = database_name + self.cascade = False + self.restrict = False + + +_EXPRESSION_TYPES = ( + SnowflakeTypeField, + SnowflakeScalarTypeDefinition, + SnowflakeObjectTypeDefinition, + SnowflakeSetTypeCommentAction, + SnowflakeUnsetTypeCommentAction, + SnowflakeCreateTypeExpression, + SnowflakeAlterTypeExpression, + SnowflakeDropTypeExpression, +) +for _expression_type in _EXPRESSION_TYPES: + ExpressionRegistry.register(_expression_type) + + +__all__ = [ + "SnowflakeTypeField", + "SnowflakeScalarTypeDefinition", + "SnowflakeObjectTypeDefinition", + "SnowflakeSetTypeCommentAction", + "SnowflakeUnsetTypeCommentAction", + "SnowflakeCreateTypeExpression", + "SnowflakeAlterTypeExpression", + "SnowflakeDropTypeExpression", +] diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/types.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/types.py index f10a580..cbabaf6 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/types.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/types.py @@ -19,8 +19,9 @@ - GEOGRAPHY - GEOMETRY """ -from typing import Any, Optional +from typing import Any, Optional, TYPE_CHECKING +from rhosocial.activerecord.backend.expression.serialization import ExpressionRegistry from rhosocial.activerecord.backend.expression.types._base import DataType from rhosocial.activerecord.backend.expression.types.integer import IntegerType from rhosocial.activerecord.backend.expression.types.string import VarCharType @@ -31,6 +32,9 @@ from rhosocial.activerecord.backend.expression.types.json_ import JsonType from rhosocial.activerecord.backend.expression.types.array import ArrayType +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + class SnowflakeDataTypeMixin: """Mixin that provides Snowflake-specific DDL type formatting. @@ -309,4 +313,57 @@ class SnowflakeGeometryType(SnowflakeDataTypeMixin, DataType): name = "snowflake_geometry" def format_type(self, dialect=None) -> str: - return "GEOMETRY" \ No newline at end of file + return "GEOMETRY" + + +class SnowflakeUserDefinedType(DataType): + """Reference to a Snowflake schema-level user-defined type.""" + + name = "snowflake_user_defined" + + def __init__( + self, + dialect: Optional["SQLDialectBase"] = None, + *, + type_name: str, + schema_name: Optional[str] = None, + database_name: Optional[str] = None, + ) -> None: + super().__init__(dialect) + for field_name, value in ( + ("type_name", type_name), + ("schema_name", schema_name), + ("database_name", database_name), + ): + if value is not None and (not isinstance(value, str) or not value.strip()): + raise ValueError(f"{field_name} must be a non-empty string") + if database_name is not None and schema_name is None: + raise ValueError("schema_name is required when database_name is provided") + self.type_name = type_name + self.schema_name = schema_name + self.database_name = database_name + + def _type_params(self) -> tuple: + return (self.database_name, self.schema_name, self.type_name) + + +_SNOWFLAKE_DATA_TYPES = ( + SnowflakeVarcharType, + SnowflakeNumberType, + SnowflakeFloatType, + SnowflakeBooleanType, + SnowflakeTimestampLtzType, + SnowflakeTimestampNtzType, + SnowflakeTimestampTzType, + SnowflakeDateType, + SnowflakeTimeType, + SnowflakeBinaryType, + SnowflakeVariantType, + SnowflakeObjectType, + SnowflakeArrayType, + SnowflakeGeographyType, + SnowflakeGeometryType, + SnowflakeUserDefinedType, +) +for _data_type in _SNOWFLAKE_DATA_TYPES: + ExpressionRegistry.register(_data_type) diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/__init__.py index 4f99f31..8423cce 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/__init__.py @@ -36,6 +36,7 @@ from .types import SnowflakeTypeSupportMixin from .warehouse import SnowflakeWarehouseMixin from .ddl import SnowflakeAlterColumnModifierMixin +from .ddl_type import SnowflakeTypeDDLMixin # New mixins from dialect.py split from .datetime import SnowflakeDateTimeMixin from .collation import SnowflakeCollationMixin @@ -76,6 +77,7 @@ "SnowflakeTypeSupportMixin", "SnowflakeWarehouseMixin", "SnowflakeAlterColumnModifierMixin", + "SnowflakeTypeDDLMixin", "SnowflakeDateTimeMixin", "SnowflakeCollationMixin", "SnowflakeSetOperationMixin", diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_type.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_type.py new file mode 100644 index 0000000..0b0f269 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/ddl_type.py @@ -0,0 +1,251 @@ +"""Snowflake schema-level user-defined type DDL capability and SQL formatting.""" + +from typing import Any, Tuple, Type, cast + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.dialect.mixins.user_defined_type import UserDefinedTypeMixin +from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + AlterTypeExpression, + CreateTypeExpression, + DropTypeExpression, + TypeAlterAction, + TypeDefinition, +) + +from ..expression.ddl.type import ( + SnowflakeObjectTypeDefinition, + SnowflakeScalarTypeDefinition, + SnowflakeSetTypeCommentAction, + SnowflakeTypeField, + SnowflakeUnsetTypeCommentAction, +) +from ..expression.types import SnowflakeUserDefinedType + + +SNOWFLAKE_TYPE_DDL_MIN_VERSION = (10, 8, 0) + + +class SnowflakeTypeDDLMixin(UserDefinedTypeMixin): + """Snowflake UDT capability gates and TYPE DDL formatters.""" + + _TYPE_DEFINITION_TYPES = ( + SnowflakeScalarTypeDefinition, + SnowflakeObjectTypeDefinition, + ) + _TYPE_ACTION_TYPES = ( + SnowflakeSetTypeCommentAction, + SnowflakeUnsetTypeCommentAction, + ) + + def _has_type_support(self) -> bool: + version = getattr(self, "version", None) + if version is None: + return False + try: + return tuple(version) >= SNOWFLAKE_TYPE_DDL_MIN_VERSION + except TypeError: + return False + + def _require_type_support(self, feature_name: str) -> None: + if not self._has_type_support(): + raise UnsupportedFeatureError( + self.name, + feature_name, + suggestion="Snowflake schema-level TYPE DDL requires server version 10.8 or newer.", + ) + + def supports_type_objects(self) -> bool: + return self._has_type_support() + + def supports_create_type(self) -> bool: + return self._has_type_support() + + def supports_alter_type(self) -> bool: + return self._has_type_support() + + def supports_drop_type(self) -> bool: + return self._has_type_support() + + def supported_type_definitions(self) -> Tuple[Type[TypeDefinition], ...]: + if not self._has_type_support(): + return () + return self._TYPE_DEFINITION_TYPES + + def supports_type_definition( + self, + definition_type: Type[TypeDefinition], + ) -> bool: + try: + return self._has_type_support() and issubclass( + definition_type, + self._TYPE_DEFINITION_TYPES, + ) + except TypeError: + return False + + def supports_type_alter_action( + self, + action_type: Type[TypeAlterAction], + ) -> bool: + try: + return self._has_type_support() and issubclass( + action_type, + self._TYPE_ACTION_TYPES, + ) + except TypeError: + return False + + def supports_create_type_if_not_exists(self) -> bool: + return self._has_type_support() + + def supports_create_type_or_replace(self) -> bool: + return self._has_type_support() + + def supports_alter_type_if_exists(self) -> bool: + return self._has_type_support() + + def supports_drop_type_if_exists(self) -> bool: + return False + + def supports_multiple_type_alter_actions(self) -> bool: + return False + + def _format_type_name(self, expr: Any) -> str: + parts = [] + database_name = getattr(expr, "database_name", None) + schema_name = getattr(expr, "schema_name", None) + if database_name is not None: + parts.append(cast(str, self.format_identifier(database_name))) + if schema_name is not None: + parts.append(cast(str, self.format_identifier(schema_name))) + parts.append(cast(str, self.format_identifier(expr.type_name))) + return ".".join(parts) + + def format_create_type_statement( + self, + expr: CreateTypeExpression, + ) -> Tuple[str, tuple]: + self._require_type_support("CREATE TYPE") + if expr.if_not_exists and expr.or_replace: + raise ValueError("CREATE TYPE IF NOT EXISTS and OR REPLACE are mutually exclusive") + if expr.if_not_exists and not self.supports_create_type_if_not_exists(): + raise UnsupportedFeatureError(self.name, "CREATE TYPE IF NOT EXISTS") + if expr.or_replace and not self.supports_create_type_or_replace(): + raise UnsupportedFeatureError(self.name, "CREATE OR REPLACE TYPE") + if not self.supports_type_definition(type(expr.definition)): + raise UnsupportedFeatureError( + self.name, + f"TYPE definition {expr.definition.definition_kind}", + ) + definition_sql, definition_params = expr.definition.to_sql() + parts = ["CREATE"] + if expr.or_replace: + parts.append("OR REPLACE") + parts.append("TYPE") + if expr.if_not_exists: + parts.append("IF NOT EXISTS") + parts.extend((self._format_type_name(expr), definition_sql)) + comment = getattr(expr, "comment", None) + if comment is not None: + if not isinstance(comment, str): + raise TypeError("Snowflake TYPE comment must be a string") + parts.extend(("COMMENT", "=", self.format_literal(comment))) + return " ".join(parts), tuple(definition_params) + + def format_alter_type_statement( + self, + expr: AlterTypeExpression, + ) -> Tuple[str, tuple]: + self._require_type_support("ALTER TYPE") + if expr.if_exists and not self.supports_alter_type_if_exists(): + raise UnsupportedFeatureError(self.name, "ALTER TYPE IF EXISTS") + if not expr.actions: + raise ValueError("ALTER TYPE requires at least one action") + if len(expr.actions) > 1 and not self.supports_multiple_type_alter_actions(): + raise UnsupportedFeatureError(self.name, "multiple ALTER TYPE actions") + action_parts = [] + action_params = [] + for action in expr.actions: + if not self.supports_type_alter_action(type(action)): + raise UnsupportedFeatureError( + self.name, + f"ALTER TYPE action {action.action_kind}", + ) + action_sql, params = action.to_sql() + action_parts.append(action_sql) + action_params.extend(params) + parts = ["ALTER TYPE"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.extend((self._format_type_name(expr), ", ".join(action_parts))) + return " ".join(parts), tuple(action_params) + + def format_drop_type_statement( + self, + expr: DropTypeExpression, + ) -> Tuple[str, tuple]: + self._require_type_support("DROP TYPE") + if expr.if_exists: + raise UnsupportedFeatureError(self.name, "DROP TYPE IF EXISTS") + if getattr(expr, "cascade", False): + raise UnsupportedFeatureError(self.name, "DROP TYPE CASCADE") + if getattr(expr, "restrict", False): + raise UnsupportedFeatureError(self.name, "DROP TYPE RESTRICT") + return f"DROP TYPE {self._format_type_name(expr)}", () + + def format_type_definition( + self, + expr: TypeDefinition, + ) -> Tuple[str, tuple]: + self._require_type_support("TYPE definition") + if isinstance(expr, SnowflakeScalarTypeDefinition): + if isinstance(expr.data_type, SnowflakeUserDefinedType): + raise UnsupportedFeatureError( + self.name, + "scalar UDT based on another UDT", + suggestion="Snowflake TYPE definitions must use a built-in Snowflake data type as their base.", + ) + data_type_sql, data_type_params = cast( + Tuple[str, tuple], + expr.data_type.to_sql(), + ) + return f"AS {data_type_sql}", data_type_params + if isinstance(expr, SnowflakeObjectTypeDefinition): + field_sql = [] + field_params = [] + for field in expr.fields: + if not isinstance(field, SnowflakeTypeField): + raise TypeError("Snowflake OBJECT type fields must be SnowflakeTypeField instances") + sql, params = field.to_sql() + field_sql.append(sql) + field_params.extend(params) + return f"AS OBJECT({', '.join(field_sql)})", tuple(field_params) + raise UnsupportedFeatureError( + self.name, + f"TYPE definition {getattr(expr, 'definition_kind', type(expr).__name__)}", + ) + + def format_snowflake_type_field( + self, + expr: SnowflakeTypeField, + ) -> Tuple[str, tuple]: + self._require_type_support("Snowflake OBJECT UDT field") + type_sql, type_params = expr.data_type.to_sql() + return f"{self.format_identifier(expr.name)} {type_sql}", tuple(type_params) + + def format_type_alter_action( + self, + expr: TypeAlterAction, + ) -> Tuple[str, tuple]: + self._require_type_support("ALTER TYPE action") + if isinstance(expr, SnowflakeSetTypeCommentAction): + return f"SET COMMENT = {self.format_literal(expr.comment)}", () + if isinstance(expr, SnowflakeUnsetTypeCommentAction): + return "UNSET COMMENT", () + raise UnsupportedFeatureError( + self.name, + f"ALTER TYPE action {getattr(expr, 'action_kind', type(expr).__name__)}", + ) + + +__all__ = ["SNOWFLAKE_TYPE_DDL_MIN_VERSION", "SnowflakeTypeDDLMixin"] diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/types.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/types.py index b1a6cf9..22f6074 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/types.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/types.py @@ -9,6 +9,7 @@ import re from typing import Tuple +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.dialect.mixins.ddl_type import DDLTypeMixin from rhosocial.activerecord.backend.dialect.protocols import DDLTypeSupport from rhosocial.activerecord.backend.expression.types import ( @@ -45,9 +46,11 @@ SnowflakeTimestampLtzType, SnowflakeTimestampNtzType, SnowflakeTimestampTzType, + SnowflakeUserDefinedType, SnowflakeVariantType, SnowflakeVarcharType, ) +from .ddl_type import SNOWFLAKE_TYPE_DDL_MIN_VERSION class SnowflakeTypeSupportMixin(DDLTypeMixin, DDLTypeSupport): @@ -70,6 +73,15 @@ class SnowflakeTypeSupportMixin(DDLTypeMixin, DDLTypeSupport): _SNOW_TIMESTAMP_PRECISION_MAX = 9 # fractional seconds _SNOW_TIME_PRECISION_MAX = 9 # fractional seconds + def supports_data_type_snowflake_user_defined(self) -> bool: + version = getattr(self, "version", None) + if version is None: + return False + try: + return tuple(version) >= SNOWFLAKE_TYPE_DDL_MIN_VERSION + except TypeError: + return False + def _validate_number_precision(self, precision: int, scale: int = 0) -> None: if not 1 <= precision <= self._SNOW_NUMBER_PRECISION_MAX: raise ValueError( @@ -234,6 +246,24 @@ def format_data_type_snowflake_object(self, expr: SnowflakeObjectType) -> Tuple[ def format_data_type_snowflake_array(self, expr: SnowflakeArrayType) -> Tuple[str, tuple]: return "ARRAY", () + def format_data_type_snowflake_user_defined( + self, + expr: SnowflakeUserDefinedType, + ) -> Tuple[str, tuple]: + if not self.supports_data_type_snowflake_user_defined(): + raise UnsupportedFeatureError( + self.name, + "user-defined type references", + suggestion="Snowflake user-defined types require server version 10.8 or newer.", + ) + parts = [] + if expr.database_name is not None: + parts.append(self.format_identifier(expr.database_name)) + if expr.schema_name is not None: + parts.append(self.format_identifier(expr.schema_name)) + parts.append(self.format_identifier(expr.type_name)) + return ".".join(parts), () + def format_data_type_snowflake_geography(self, expr: SnowflakeGeographyType) -> Tuple[str, tuple]: return "GEOGRAPHY", () diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_type_ddl.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_type_ddl.py new file mode 100644 index 0000000..a6931de --- /dev/null +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_type_ddl.py @@ -0,0 +1,440 @@ +"""Snowflake schema-level user-defined TYPE DDL unit tests.""" + +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.dialect.protocols import UserDefinedTypeSupport +from rhosocial.activerecord.backend.expression.serialization import ( + ExpressionRegistry, + deserialize, + deserialize_json, + deserialize_xml, + serialize, + serialize_json, + serialize_xml, +) +from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + AlterTypeExpression, + CreateTypeExpression, + DropTypeExpression, + TypeDefinition, +) +from rhosocial.activerecord.backend.impl.snowflake import ( + SnowflakeAlterTypeExpression, + SnowflakeCreateTypeExpression, + SnowflakeDropTypeExpression, + SnowflakeObjectTypeDefinition, + SnowflakeScalarTypeDefinition, + SnowflakeSetTypeCommentAction, + SnowflakeTypeDDLMixin, + SnowflakeTypeField, + SnowflakeUnsetTypeCommentAction, + SnowflakeUserDefinedType, +) +from rhosocial.activerecord.backend.impl.snowflake.dialect import SnowflakeDialect +from rhosocial.activerecord.backend.impl.snowflake.expression.types import ( + SnowflakeArrayType, + SnowflakeBinaryType, + SnowflakeBooleanType, + SnowflakeDateType, + SnowflakeFloatType, + SnowflakeGeographyType, + SnowflakeGeometryType, + SnowflakeNumberType, + SnowflakeObjectType, + SnowflakeTimeType, + SnowflakeTimestampLtzType, + SnowflakeTimestampNtzType, + SnowflakeTimestampTzType, + SnowflakeVariantType, + SnowflakeVarcharType, +) + + +def _dialect(version=(10, 8, 0)): + return SnowflakeDialect(version=version) + + +def _snowflake_data_types(dialect): + return ( + SnowflakeVarcharType(dialect, length=64), + SnowflakeNumberType(dialect, precision=12, scale=2), + SnowflakeFloatType(dialect, precision=53), + SnowflakeBooleanType(dialect), + SnowflakeTimestampLtzType(dialect, precision=3), + SnowflakeTimestampNtzType(dialect, precision=6), + SnowflakeTimestampTzType(dialect, precision=9), + SnowflakeDateType(dialect), + SnowflakeTimeType(dialect, precision=3), + SnowflakeBinaryType(dialect, length=1024), + SnowflakeVariantType(dialect), + SnowflakeObjectType(dialect), + SnowflakeArrayType( + dialect, + element_type=SnowflakeVarcharType(dialect, length=32), + ), + SnowflakeGeographyType(dialect), + SnowflakeGeometryType(dialect), + SnowflakeUserDefinedType( + dialect, + database_name="DB", + schema_name="APP", + type_name="label_type", + ), + ) + + +def test_capabilities_protocol_and_mro(): + dialect = _dialect() + + assert dialect.supports_type_objects() is True + assert dialect.supports_create_type() is True + assert dialect.supports_alter_type() is True + assert dialect.supports_drop_type() is True + assert dialect.supports_create_type_if_not_exists() is True + assert dialect.supports_create_type_or_replace() is True + assert dialect.supports_alter_type_if_exists() is True + assert dialect.supports_drop_type_if_exists() is False + assert dialect.supports_multiple_type_alter_actions() is False + assert dialect.supported_type_definitions() == ( + SnowflakeScalarTypeDefinition, + SnowflakeObjectTypeDefinition, + ) + assert isinstance(dialect, UserDefinedTypeSupport) + assert SnowflakeDialect.format_type_definition is SnowflakeTypeDDLMixin.format_type_definition + + mro = SnowflakeDialect.__mro__ + assert mro.index(SnowflakeTypeDDLMixin) < mro.index(UserDefinedTypeSupport) + + +def test_version_boundary_and_low_version_fail_fast(): + dialect = _dialect((10, 7, 99)) + scalar = SnowflakeScalarTypeDefinition( + dialect, + SnowflakeNumberType(dialect, precision=3, scale=0), + ) + object_definition = SnowflakeObjectTypeDefinition( + dialect, + {"street": SnowflakeVarcharType(dialect, length=100)}, + ) + field = SnowflakeTypeField( + dialect, + "street", + SnowflakeVarcharType(dialect, length=100), + ) + set_action = SnowflakeSetTypeCommentAction(dialect, "comment") + reference = SnowflakeUserDefinedType(dialect, type_name="age") + statements = [ + CreateTypeExpression(dialect, "age", scalar), + AlterTypeExpression(dialect, "age", [set_action]), + DropTypeExpression(dialect, "age"), + ] + + assert dialect.supports_type_objects() is False + assert dialect.supports_create_type() is False + assert dialect.supports_alter_type() is False + assert dialect.supports_drop_type() is False + assert dialect.supported_type_definitions() == () + assert dialect.supports_data_type_snowflake_user_defined() is False + + for expression in [ + scalar, + object_definition, + field, + set_action, + reference, + *statements, + ]: + with pytest.raises(UnsupportedFeatureError): + expression.to_sql() + + +def test_scalar_create_type_renders_schema_qualification(): + dialect = _dialect() + definition = SnowflakeScalarTypeDefinition( + dialect, + SnowflakeNumberType(dialect, precision=3, scale=0), + ) + + expression = CreateTypeExpression( + dialect, + "age", + definition, + schema_name="app", + ) + + assert definition.to_sql() == ("AS NUMBER(3, 0)", ()) + assert expression.to_sql() == ('CREATE TYPE "app"."age" AS NUMBER(3, 0)') + + +def test_create_options_are_rendered_in_snowflake_order(): + dialect = _dialect() + definition = SnowflakeScalarTypeDefinition( + dialect, + SnowflakeVarcharType(dialect), + ) + + replace = SnowflakeCreateTypeExpression( + dialect, + "label", + definition, + or_replace=True, + ) + create_if_missing = SnowflakeCreateTypeExpression( + dialect, + "label", + definition, + if_not_exists=True, + ) + + assert replace.to_sql() == 'CREATE OR REPLACE TYPE "label" AS VARCHAR' + assert create_if_missing.to_sql() == 'CREATE TYPE IF NOT EXISTS "label" AS VARCHAR' + with pytest.raises(ValueError, match="mutually exclusive"): + SnowflakeCreateTypeExpression( + dialect, + "label", + definition, + if_not_exists=True, + or_replace=True, + ) + + +def test_create_type_qualification_and_comment_are_safely_quoted(): + dialect = _dialect() + definition = SnowflakeScalarTypeDefinition( + dialect, + SnowflakeNumberType(dialect, precision=3, scale=0), + ) + expression = SnowflakeCreateTypeExpression( + dialect, + "age type", + definition, + database_name='DB"name', + schema_name='S"chema', + comment="owner's type", + ) + + assert expression.to_sql() == ( + 'CREATE TYPE "DB""name"."S""chema"."age type" AS NUMBER(3, 0) COMMENT = \'owner\'\'s type\'' + ) + + +def test_object_type_definition_renders_typed_object_fields(): + dialect = _dialect() + definition = SnowflakeObjectTypeDefinition( + dialect, + { + "street": SnowflakeVarcharType(dialect, length=100), + "segments": SnowflakeArrayType(dialect), + }, + ) + expression = SnowflakeCreateTypeExpression( + dialect, + "address", + definition, + database_name="DB", + schema_name="APP", + ) + + assert definition.to_sql() == ( + 'AS OBJECT("street" VARCHAR(100), "segments" ARRAY)', + (), + ) + assert expression.to_sql() == ( + 'CREATE TYPE "DB"."APP"."address" AS OBJECT("street" VARCHAR(100), "segments" ARRAY)' + ) + + +def test_object_type_field_validation_and_type_kind_separation(): + dialect = _dialect() + data_type = SnowflakeVarcharType(dialect, length=20) + + with pytest.raises(ValueError, match="at least one field"): + SnowflakeObjectTypeDefinition(dialect, []) + with pytest.raises(ValueError, match="unique"): + SnowflakeObjectTypeDefinition( + dialect, + [("name", data_type), {"name": data_type}], + ) + with pytest.raises(TypeError, match="DataType"): + SnowflakeObjectTypeDefinition(dialect, [("name", object())]) + + assert isinstance(SnowflakeObjectType(dialect), TypeDefinition) is False + assert isinstance(SnowflakeArrayType(dialect), TypeDefinition) is False + assert isinstance(SnowflakeObjectTypeDefinition(dialect, [("name", data_type)]), TypeDefinition) + + +def test_scalar_udt_rejects_another_udt_as_base_type(): + dialect = _dialect() + reference = SnowflakeUserDefinedType( + dialect, + type_name="age", + database_name="DB", + schema_name="APP", + ) + definition = SnowflakeScalarTypeDefinition(dialect, reference) + + assert reference.to_sql() == '"DB"."APP"."age"' + with pytest.raises(UnsupportedFeatureError, match="another UDT"): + definition.to_sql() + + +def test_alter_type_set_and_unset_comment_actions(): + dialect = _dialect() + set_action = SnowflakeSetTypeCommentAction(dialect, "owner's type") + set_expression = SnowflakeAlterTypeExpression( + dialect, + "age", + [set_action], + database_name="DB", + schema_name="APP", + if_exists=True, + ) + unset_expression = AlterTypeExpression( + dialect, + "age", + [SnowflakeUnsetTypeCommentAction(dialect)], + schema_name="APP", + ) + + assert set_expression.to_sql() == ('ALTER TYPE IF EXISTS "DB"."APP"."age" SET COMMENT = \'owner\'\'s type\'') + assert unset_expression.to_sql() == ('ALTER TYPE "APP"."age" UNSET COMMENT') + + +def test_alter_type_rejects_multiple_or_unknown_actions(): + dialect = _dialect() + + expression = AlterTypeExpression( + dialect, + "age", + [ + SnowflakeSetTypeCommentAction(dialect, "first"), + SnowflakeUnsetTypeCommentAction(dialect), + ], + ) + with pytest.raises(UnsupportedFeatureError, match="multiple ALTER TYPE actions"): + expression.to_sql() + with pytest.raises(UnsupportedFeatureError, match="ALTER TYPE action"): + dialect.format_type_alter_action(object()) + + +def test_drop_type_has_no_if_exists_cascade_or_restrict(): + dialect = _dialect() + expression = SnowflakeDropTypeExpression( + dialect, + "age", + database_name="DB", + schema_name="APP", + ) + + assert expression.to_sql() == 'DROP TYPE "DB"."APP"."age"' + + with pytest.raises(UnsupportedFeatureError, match="IF EXISTS"): + DropTypeExpression(dialect, "age", if_exists=True).to_sql() + with pytest.raises(UnsupportedFeatureError, match="CASCADE"): + SnowflakeDropTypeExpression(dialect, "age", cascade=True) + with pytest.raises(UnsupportedFeatureError, match="RESTRICT"): + SnowflakeDropTypeExpression(dialect, "age", restrict=True) + + expression.cascade = True + with pytest.raises(UnsupportedFeatureError, match="CASCADE"): + expression.to_sql() + + +def test_udt_expressions_are_explicitly_registered_for_serialization(): + expression_types = ( + SnowflakeVarcharType, + SnowflakeNumberType, + SnowflakeFloatType, + SnowflakeBooleanType, + SnowflakeTimestampLtzType, + SnowflakeTimestampNtzType, + SnowflakeTimestampTzType, + SnowflakeDateType, + SnowflakeTimeType, + SnowflakeBinaryType, + SnowflakeVariantType, + SnowflakeObjectType, + SnowflakeArrayType, + SnowflakeGeographyType, + SnowflakeGeometryType, + SnowflakeUserDefinedType, + SnowflakeTypeField, + SnowflakeScalarTypeDefinition, + SnowflakeObjectTypeDefinition, + SnowflakeSetTypeCommentAction, + SnowflakeUnsetTypeCommentAction, + SnowflakeCreateTypeExpression, + SnowflakeAlterTypeExpression, + SnowflakeDropTypeExpression, + ) + + for expression_type in expression_types: + fqn = f"{expression_type.__module__}.{expression_type.__name__}" + assert ExpressionRegistry.lookup(fqn) is expression_type + + +def test_udt_expression_dict_json_and_xml_round_trips(): + dialect = _dialect() + definition = SnowflakeObjectTypeDefinition( + dialect, + {"label": SnowflakeVarcharType(dialect, length=80)}, + ) + expression = SnowflakeCreateTypeExpression( + dialect, + "label_type", + definition, + database_name="DB", + schema_name="APP", + comment="round trip", + ) + expected = expression.to_sql() + + restored = deserialize(serialize(expression), dialect) + assert isinstance(restored, SnowflakeCreateTypeExpression) + assert restored.to_sql() == expected + + restored = deserialize_json(serialize_json(expression), dialect) + assert isinstance(restored, SnowflakeCreateTypeExpression) + assert restored.to_sql() == expected + + restored = deserialize_xml(serialize_xml(expression), dialect) + assert isinstance(restored, SnowflakeCreateTypeExpression) + assert restored.to_sql() == expected + + +def test_all_nestable_snowflake_data_types_round_trip(): + dialect = _dialect() + + for original in _snowflake_data_types(dialect): + expected = original.to_sql() + + restored = deserialize(serialize(original), dialect) + assert type(restored) is type(original) + assert restored.to_sql() == expected + + restored = deserialize_json(serialize_json(original), dialect) + assert type(restored) is type(original) + assert restored.to_sql() == expected + + restored = deserialize_xml(serialize_xml(original), dialect) + assert type(restored) is type(original) + assert restored.to_sql() == expected + + +def test_public_exports_include_udt_ddl_api(): + import rhosocial.activerecord.backend.impl.snowflake as snowflake + + for name in ( + "SnowflakeUserDefinedType", + "SnowflakeTypeField", + "SnowflakeScalarTypeDefinition", + "SnowflakeObjectTypeDefinition", + "SnowflakeSetTypeCommentAction", + "SnowflakeUnsetTypeCommentAction", + "SnowflakeCreateTypeExpression", + "SnowflakeAlterTypeExpression", + "SnowflakeDropTypeExpression", + "SnowflakeTypeDDLMixin", + ): + assert hasattr(snowflake, name) diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/protocol/test_protocol_conformance.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/protocol/test_protocol_conformance.py index 3da627f..0f6fa2d 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/protocol/test_protocol_conformance.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/protocol/test_protocol_conformance.py @@ -5,7 +5,6 @@ protocol conformance test classes. """ import inspect -from typing import get_type_hints import pytest @@ -23,7 +22,6 @@ SnowflakeArrayMixin, SnowflakeCloneMixin, SnowflakeStageMixin, - SnowflakeTransactionMixin, ) from rhosocial.activerecord.backend.dialect import protocols as dialect_protocols from rhosocial.activerecord.backend.dialect.protocols import ( @@ -56,10 +54,12 @@ TransactionControlSupport, TruncateSupport, UpsertSupport, + UserDefinedTypeSupport, ViewSupport, WildcardSupport, WindowFunctionSupport, ArraySupport as GenericArraySupport, + AlterTableModifierSupport, PartitionSupport, TableSupport, @@ -76,6 +76,7 @@ ColumnAttributeSupport, CommentSupport, DDLTypeSupport, + UserDefinedTypeSupport, ExplainSupport, FilterClauseSupport, GeneratedColumnSupport, From 7d182b06bd73ba6083ec872ddf596509b12cb8af Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 24 Sep 2026 10:20:44 +0800 Subject: [PATCH 18/28] test(ddl): cover table inheritance and tablespace (cherry picked from commit 6e128187b59ac2109c0ec9959f6f940310948098) (cherry picked from commit e1f7373f0e8b7373cd2de7ba0658ce34a4f7a636) --- .../test_snowflake_ddl_improvements.py | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_snowflake_ddl_improvements.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_snowflake_ddl_improvements.py index d60bed4..6052f83 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_snowflake_ddl_improvements.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_snowflake_ddl_improvements.py @@ -3,6 +3,8 @@ import pytest from unittest.mock import patch, PropertyMock +from rhosocial.activerecord.base.ddl import TableDDLDeriver +from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.backend.expression import ( Column, TableExpression, @@ -60,3 +62,51 @@ def test_schema_if_exists_supported(self): """Snowflake supports DROP SCHEMA IF EXISTS.""" dialect = SnowflakeDialect() assert dialect.supports_schema_if_exists() is True + + +class InheritedTable(ActiveRecord): + __table_name__ = "inherited" + + id: int + + @classmethod + def table_inherits(cls): + return ["parent_a", "parent_b"] + + +class TablespacedTable(ActiveRecord): + __table_name__ = "tablespaced" + + id: int + + @classmethod + def table_tablespace(cls): + return "ts_data" + + +class TestSnowflakeTableDeclarationGating: + def test_table_declaration_defaults_are_absent(self): + class Plain(ActiveRecord): + __table_name__ = "plain_table_defaults" + + id: int + + expression = TableDDLDeriver(Plain, SnowflakeDialect()).create_table() + assert expression.inherits == [] + assert expression.tablespace is None + + def test_table_inherits_is_propagated_and_rejected(self): + dialect = SnowflakeDialect(version=(8, 0, 0)) + assert dialect.supports_table_inheritance() is False + expression = TableDDLDeriver(InheritedTable, dialect).create_table() + assert expression.inherits == ["parent_a", "parent_b"] + with pytest.raises(UnsupportedFeatureError, match="INHERITS"): + expression.to_sql() + + def test_table_tablespace_is_propagated_and_rejected(self): + dialect = SnowflakeDialect(version=(8, 0, 0)) + assert dialect.supports_table_tablespace() is False + expression = TableDDLDeriver(TablespacedTable, dialect).create_table() + assert expression.tablespace == "ts_data" + with pytest.raises(UnsupportedFeatureError, match="TABLESPACE"): + expression.to_sql() From 0ee155856a94ad977ba82a88eda4e243e277b01b Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 24 Sep 2026 11:18:39 +0800 Subject: [PATCH 19/28] test(snowflake): align type DDL result contract (cherry picked from commit 3492613574c9f65a7d6d4eb5e5034435121ee198) (cherry picked from commit 2e8bd063da13d7640d77d06df7461d2a27d26db4) --- .../feature/backend/ddl/test_type_ddl.py | 41 +++++++++++++++---- .../protocol/test_protocol_conformance.py | 16 ++++---- 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_type_ddl.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_type_ddl.py index a6931de..e821ad1 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_type_ddl.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_type_ddl.py @@ -164,7 +164,10 @@ def test_scalar_create_type_renders_schema_qualification(): ) assert definition.to_sql() == ("AS NUMBER(3, 0)", ()) - assert expression.to_sql() == ('CREATE TYPE "app"."age" AS NUMBER(3, 0)') + assert expression.to_sql() == ( + 'CREATE TYPE "app"."age" AS NUMBER(3, 0)', + (), + ) def test_create_options_are_rendered_in_snowflake_order(): @@ -187,8 +190,14 @@ def test_create_options_are_rendered_in_snowflake_order(): if_not_exists=True, ) - assert replace.to_sql() == 'CREATE OR REPLACE TYPE "label" AS VARCHAR' - assert create_if_missing.to_sql() == 'CREATE TYPE IF NOT EXISTS "label" AS VARCHAR' + assert replace.to_sql() == ( + 'CREATE OR REPLACE TYPE "label" AS VARCHAR', + (), + ) + assert create_if_missing.to_sql() == ( + 'CREATE TYPE IF NOT EXISTS "label" AS VARCHAR', + (), + ) with pytest.raises(ValueError, match="mutually exclusive"): SnowflakeCreateTypeExpression( dialect, @@ -215,7 +224,8 @@ def test_create_type_qualification_and_comment_are_safely_quoted(): ) assert expression.to_sql() == ( - 'CREATE TYPE "DB""name"."S""chema"."age type" AS NUMBER(3, 0) COMMENT = \'owner\'\'s type\'' + 'CREATE TYPE "DB""name"."S""chema"."age type" AS NUMBER(3, 0) COMMENT = \'owner\'\'s type\'', + (), ) @@ -241,7 +251,8 @@ def test_object_type_definition_renders_typed_object_fields(): (), ) assert expression.to_sql() == ( - 'CREATE TYPE "DB"."APP"."address" AS OBJECT("street" VARCHAR(100), "segments" ARRAY)' + 'CREATE TYPE "DB"."APP"."address" AS OBJECT("street" VARCHAR(100), "segments" ARRAY)', + (), ) @@ -274,7 +285,10 @@ def test_scalar_udt_rejects_another_udt_as_base_type(): ) definition = SnowflakeScalarTypeDefinition(dialect, reference) - assert reference.to_sql() == '"DB"."APP"."age"' + assert reference.to_sql() == ( + '"DB"."APP"."age"', + (), + ) with pytest.raises(UnsupportedFeatureError, match="another UDT"): definition.to_sql() @@ -297,8 +311,14 @@ def test_alter_type_set_and_unset_comment_actions(): schema_name="APP", ) - assert set_expression.to_sql() == ('ALTER TYPE IF EXISTS "DB"."APP"."age" SET COMMENT = \'owner\'\'s type\'') - assert unset_expression.to_sql() == ('ALTER TYPE "APP"."age" UNSET COMMENT') + assert set_expression.to_sql() == ( + 'ALTER TYPE IF EXISTS "DB"."APP"."age" SET COMMENT = \'owner\'\'s type\'', + (), + ) + assert unset_expression.to_sql() == ( + 'ALTER TYPE "APP"."age" UNSET COMMENT', + (), + ) def test_alter_type_rejects_multiple_or_unknown_actions(): @@ -327,7 +347,10 @@ def test_drop_type_has_no_if_exists_cascade_or_restrict(): schema_name="APP", ) - assert expression.to_sql() == 'DROP TYPE "DB"."APP"."age"' + assert expression.to_sql() == ( + 'DROP TYPE "DB"."APP"."age"', + (), + ) with pytest.raises(UnsupportedFeatureError, match="IF EXISTS"): DropTypeExpression(dialect, "age", if_exists=True).to_sql() diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/protocol/test_protocol_conformance.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/protocol/test_protocol_conformance.py index 0f6fa2d..17e9c1f 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/protocol/test_protocol_conformance.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/protocol/test_protocol_conformance.py @@ -180,6 +180,7 @@ def test_implements_protocol(self, dialect, protocol): # --- Intentional non-support --- # Snowflake dialect does not compose the generic DatabaseMixin. dialect_protocols.DatabaseSupport, + dialect_protocols.DomainSupport, # Snowflake has no SQL/XML support. dialect_protocols.SQLXMLSupport, dialect_protocols.SQLXMLParsingSupport, @@ -233,20 +234,17 @@ def test_does_not_implement_protocol(self, dialect, protocol): def test_positive_and_negative_lists_partition_all_protocols(self): """Every generic protocol must be classified for Snowflake.""" - all_protos = set(get_all_generic_protocols()) - positive = { - p.__name__ - for p in SNOWFLAKE_PROTOCOLS - if p.__module__ == dialect_protocols.__name__ - } - negative = {p.__name__ for p in SNOWFLAKE_NOT_IMPLEMENTED} + all_protos = set(get_all_generic_protocols().values()) + positive = {p for p in SNOWFLAKE_PROTOCOLS if p in all_protos} + negative = set(SNOWFLAKE_NOT_IMPLEMENTED) - overlap = positive & negative + overlap = {p.__name__ for p in positive & negative} assert not overlap, f"Protocols in BOTH lists: {sorted(overlap)}" unclassified = all_protos - positive - negative + unclassified_names = sorted(p.__name__ for p in unclassified) assert not unclassified, ( - f"Generic protocols not classified for Snowflake: {sorted(unclassified)}. " + f"Generic protocols not classified for Snowflake: {unclassified_names}. " f"Add each to SNOWFLAKE_PROTOCOLS or SNOWFLAKE_NOT_IMPLEMENTED." ) From 2f76a02b4a4e9804281bcdb7ce07ff3fac9b117d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 24 Sep 2026 14:34:13 +0800 Subject: [PATCH 20/28] chore(snowflake): add backend contribution templates (cherry picked from commit 668e0e7c9a7fb8210596c706a78c0ee5b9b22407) (cherry picked from commit a7ebaff05b8ae7988a913d17a7dc151e746151d0) --- .github/ISSUE_TEMPLATE/bug_report.md | 72 ++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 52 +++++++++ .../PULL_REQUEST_TEMPLATE/bugfix_template.md | 110 ++++++++++++++++++ .../PULL_REQUEST_TEMPLATE/feature_template.md | 97 +++++++++++++++ .../release_hotfix_template.md | 79 +++++++++++++ 5 files changed, 410 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE/bugfix_template.md create mode 100644 .github/PULL_REQUEST_TEMPLATE/feature_template.md create mode 100644 .github/PULL_REQUEST_TEMPLATE/release_hotfix_template.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..df97601 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,72 @@ +--- +name: Bug Report +about: Report a bug in the rhosocial ActiveRecord Snowflake backend +title: '[BUG] ' +labels: 'bug' +assignees: '' +--- + +## Before Submitting + +Please ensure this bug is specific to the Snowflake backend. For behavior shared by all database backends, please submit the bug report to the core `rhosocial-activerecord` repository instead. + +## Description + +A clear and concise description of the bug. + +## Environment + +- **rhosocial-activerecord-snowflake Version**: [e.g. 1.0.0.dev2] +- **Python Version**: [e.g. 3.14] +- **Snowflake Connector Version**: [e.g. 3.17.0] +- **Snowflake Account/Region**: [e.g. account/region] +- **Warehouse/Database/Schema**: [e.g. warehouse/database/schema] +- **OS**: [e.g. Linux, macOS, Windows] + +## Steps to Reproduce + +1. +2. +3. + +## Expected Behavior + +A clear and concise description of what you expected to happen. + +## Actual Behavior + +What actually happened instead of the expected behavior. + +## Database Query + +If applicable, provide the generated SQL query that causes the issue: + +```sql +-- Your problematic SQL query here +``` + +## Model Definition + +If the issue is related to a specific model, please share your model definition: + +```python +# Example model definition +class User(ActiveRecord): + __table_name__ = 'users' + + id: Optional[int] = None + name: str + email: EmailStr +``` + +## Error Details + +If you're getting an error, include the full error message and stack trace: + +``` +Paste the full error message here +``` + +## Additional Context + +Any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..234ec8d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,52 @@ +--- +name: Feature Request +about: Suggest a feature for the rhosocial ActiveRecord Snowflake backend +title: '[FEATURE] ' +labels: 'enhancement' +assignees: '' +--- + +## Before Submitting + +Please ensure this feature request is specific to the Snowflake backend. For functionality that should behave consistently across all database backends, please submit the request to the core `rhosocial-activerecord` repository instead. + +## Description + +A clear and concise description of the desired feature. + +## Problem Statement + +Is your feature request related to a specific problem? Please describe. + +## Proposed Solution + +Describe the solution you'd like to see implemented. + +## Use Cases + +What specific use cases would this feature address? + +1. +2. + +## Snowflake Scope + +Which Snowflake areas should this feature support? (Select all that apply) + +- [ ] Snowflake SQL dialect and expressions +- [ ] Snowflake data types and adapters +- [ ] Snowflake connection, pool, or transaction behavior +- [ ] Snowflake DDL, DML, or warehouse features +- [ ] Snowflake test providers and CI + +## Technical Considerations + +Are there any technical constraints or considerations for implementing this feature? + +## Comparison with Alternatives + +How does this feature compare to alternative approaches? + +## Additional Context + +Add any other context or examples about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE/bugfix_template.md b/.github/PULL_REQUEST_TEMPLATE/bugfix_template.md new file mode 100644 index 0000000..421027e --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/bugfix_template.md @@ -0,0 +1,110 @@ +--- +name: "Bugfix Pull Request" +about: Propose a bug fix for the rhosocial-activerecord Snowflake backend, targeting a pre-release branch (e.g., release/vX.Y.Z). +title: "fix(): " +labels: "bug" +assignees: "" +--- + +## Description + + + +Fixes # (issue) + +## Reproduction Steps + + + +1. +2. +3. + +## Type of change + + + +- [ ] `fix`: A bug fix +- [ ] `docs`: Documentation only changes (if related to the bug or fix) +- [ ] `test`: Adding missing tests or correcting existing tests (for the bug or fix) +- [ ] `refactor`: A code change that neither fixes a bug nor adds a feature (if part of the fix) +- [ ] `perf`: A code change that improves performance (if part of the fix) +- [ ] `chore`: Changes to the build process or auxiliary tools and libraries such as documentation generation (if related to the bug or fix) +- [ ] `ci`: Changes to our CI configuration files and scripts (if related to the bug or fix) +- [ ] `build`: Changes that affect the build system or external dependencies (if related to the bug or fix) +- [ ] `revert`: Reverts a previous commit + +## Breaking Change + + + +- [ ] Yes, for end-users (backward-incompatible API changes) +- [ ] Yes, for backend developers (internal architectural changes that may affect custom implementations) +- [ ] No breaking changes +(If Yes, please describe the impact and migration path below): + +**Impact on End-Users:** + + +**Impact on Backend Developers (Custom Implementations):** + + + + +## Related Repositories/Packages + + + + +## Testing + + + +- [ ] I have added new tests to cover my changes. +- [ ] I have updated existing tests to reflect my changes. +- [ ] All existing tests pass. +- [ ] This change has been tested on [specify backend/DB versions]. + +**Test Plan:** + + + +## Checklist + + + +- [ ] My code follows the project's code style guidelines in `.claude/code_style.md`. +- [ ] I have performed a self-review of my own code. +- [ ] I have commented my code, particularly in hard-to-understand areas. +- [ ] I have made corresponding changes to the documentation (Docstrings, `README.md`, etc.). +- [ ] My changes generate no new warnings. +- [ ] I have added a Changelog fragment as described in `.claude/version_control.md` (`changelog.d/..md`). +- [ ] I have verified that my changes do not introduce SQL injection vulnerabilities. +- [ ] I have checked for potential performance regressions. + +## Additional Notes + + diff --git a/.github/PULL_REQUEST_TEMPLATE/feature_template.md b/.github/PULL_REQUEST_TEMPLATE/feature_template.md new file mode 100644 index 0000000..273521d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/feature_template.md @@ -0,0 +1,97 @@ +--- +name: "Feature Pull Request" +about: Propose a new feature for the rhosocial-activerecord Snowflake backend, targeting a pre-release branch (e.g., release/vX.Y.Z). +title: "feat(): " +labels: "enhancement" +assignees: "" +--- + +## Description + + + +Adds # (issue) + +## Type of change + + + +- [ ] `feat`: A new feature +- [ ] `docs`: Documentation only changes (if related to the new feature) +- [ ] `perf`: A code change that improves performance (if part of the feature) +- [ ] `test`: Adding missing tests or correcting existing tests (for the new feature) +- [ ] `chore`: Changes to the build process or auxiliary tools and libraries such as documentation generation (if related to the new feature) +- [ ] `ci`: Changes to our CI configuration files and scripts (if related to the new feature) +- [ ] `build`: Changes that affect the build system or external dependencies (if related to the new feature) +- [ ] `revert`: Reverts a previous commit + +## Breaking Change + + + +- [ ] Yes, for end-users (backward-incompatible API changes) +- [ ] Yes, for backend developers (internal architectural changes that may affect custom implementations) +- [ ] No breaking changes +(If Yes, please describe the impact and migration path below): + +**Impact on End-Users:** + + +**Impact on Backend Developers (Custom Implementations):** + + + +## Related Repositories/Packages + + + + +## Testing + + + +- [ ] I have added new tests to cover my changes. +- [ ] I have updated existing tests to reflect my changes. +- [ ] All existing tests pass. +- [ ] This change has been tested on [specify backend/DB versions]. + +**Test Plan:** + + + +## Checklist + + + +- [ ] My code follows the project's code style guidelines in `.claude/code_style.md`. +- [ ] I have performed a self-review of my own code. +- [ ] I have commented my code, particularly in hard-to-understand areas. +- [ ] I have made corresponding changes to the documentation (Docstrings, `README.md`, etc.). +- [ ] My changes generate no new warnings. +- [ ] I have added a Changelog fragment as described in `.claude/version_control.md` (`changelog.d/..md`). +- [ ] I have verified that my changes do not introduce SQL injection vulnerabilities. +- [ ] I have checked for potential performance regressions. + +## Additional Notes + + diff --git a/.github/PULL_REQUEST_TEMPLATE/release_hotfix_template.md b/.github/PULL_REQUEST_TEMPLATE/release_hotfix_template.md new file mode 100644 index 0000000..f7ef292 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/release_hotfix_template.md @@ -0,0 +1,79 @@ +--- +name: "Release or Hotfix Pull Request" +about: Propose changes for a release of the rhosocial-activerecord Snowflake backend (e.g., merging a release branch to main) or a critical hotfix. +title: "chore: Release vX.Y.Z" +assignees: "" +--- + +## Description + + + +## Release/Hotfix Details + +- **Target Version**: (e.g., `v1.2.0`) +- **Type**: (e.g., `Major Release`, `Minor Release`, `Patch Release`, `Security Hotfix`) +- **Key Changes**: + - [ ] New Features (if applicable) + - [ ] Bug Fixes (if applicable) + - [ ] Breaking Changes (if applicable, with migration path) + - [ ] Security Fixes (if applicable, with CVE) + +## Related Issues/PRs + + + +## Breaking Change + + + +- [ ] Yes, for end-users (backward-incompatible API changes) +- [ ] Yes, for backend developers (internal architectural changes that may affect custom implementations) +- [ ] No breaking changes +(If Yes, please describe the impact and migration path below): + +**Impact on End-Users:** + + +**Impact on Backend Developers (Custom Implementations):** + + + + +## Testing + + + +- [ ] All CI checks passed on the source branch (e.g., `release/vX.Y.Z` or `hotfix/ar-XXX`). +- [ ] Specific tests for this hotfix have been run and passed. + +**Test Plan:** + + + +## Checklist + + + +- [ ] My changes follow the project's version control guidelines in `.claude/version_control.md` (especially for release and hotfix procedures). +- [ ] I have updated the `CHANGELOG.md` using `towncrier build` (for releases). +- [ ] All necessary documentation (e.g., release notes, migration guides) has been prepared or updated. +- [ ] I have confirmed all CI checks have passed on the target branch after merging (post-merge verification). + +## Additional Notes + + From 032353fd89d2443feb1ca78c4700526eda0bcbc3 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 25 Sep 2026 18:21:57 +0800 Subject: [PATCH 21/28] refactor(snowflake): use direct DDL expressions (cherry picked from commit 911d7cb68e0ae364276e76f483950d49adc3669f) (cherry picked from commit b5bb21a1067b9574b3205e374a4e4f1b1c2a06f0) --- .../impl/snowflake/mixins/collation.py | 2 +- .../backend/ddl/test_column_attributes.py | 4 +- .../test_snowflake_ddl_improvements.py | 67 ++++++++----------- 3 files changed, 30 insertions(+), 43 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/collation.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/collation.py index 097cc4c..c1fa382 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/collation.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/collation.py @@ -26,7 +26,7 @@ def validate_collation_name(self, expr: "CollateExpression") -> str: def format_column_attribute(self, attr) -> Tuple[str, tuple]: """Snowflake requires a quoted collation specification on a column.""" - from rhosocial.activerecord.base.ddl.attributes import CollationAttribute + from rhosocial.activerecord.base import CollationAttribute from ..collation import validate_snowflake_collation_name if isinstance(attr, CollationAttribute): diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_column_attributes.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_column_attributes.py index 1a6a372..be617ce 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_column_attributes.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_column_attributes.py @@ -3,13 +3,13 @@ from rhosocial.activerecord.backend.expression.statements import ColumnDefinition from rhosocial.activerecord.backend.expression.types import IntegerType -from rhosocial.activerecord.base.ddl.attributes import CollationAttribute, IdentityAttribute +from rhosocial.activerecord.base import CollationAttribute, IdentityAttribute from rhosocial.activerecord.backend.impl.snowflake.dialect import SnowflakeDialect def _column(dialect, attrs): col = ColumnDefinition(dialect, "id", IntegerType(dialect)) - col.attributes = dialect.select_column_attributes(attrs) + col.attributes = attrs return col diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_snowflake_ddl_improvements.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_snowflake_ddl_improvements.py index 6052f83..569aa3e 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_snowflake_ddl_improvements.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/test_snowflake_ddl_improvements.py @@ -1,19 +1,12 @@ # tests/rhosocial/activerecord_snowflake_test/feature/backend/test_snowflake_ddl_improvements.py """Tests for Snowflake DDL improvements: capability gating, UnsupportedFeatureError.""" import pytest -from unittest.mock import patch, PropertyMock - -from rhosocial.activerecord.base.ddl import TableDDLDeriver -from rhosocial.activerecord.model import ActiveRecord -from rhosocial.activerecord.backend.expression import ( - Column, - TableExpression, - QueryExpression, - CreateViewExpression, - DropViewExpression, -) -from rhosocial.activerecord.backend.impl.snowflake.dialect import SnowflakeDialect + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition +from rhosocial.activerecord.backend.expression.types import IntegerType +from rhosocial.activerecord.backend.impl.snowflake.dialect import SnowflakeDialect class TestSnowflakeViewCapabilityGating: @@ -64,41 +57,30 @@ def test_schema_if_exists_supported(self): assert dialect.supports_schema_if_exists() is True -class InheritedTable(ActiveRecord): - __table_name__ = "inherited" - - id: int - - @classmethod - def table_inherits(cls): - return ["parent_a", "parent_b"] - - -class TablespacedTable(ActiveRecord): - __table_name__ = "tablespaced" - - id: int - - @classmethod - def table_tablespace(cls): - return "ts_data" - - class TestSnowflakeTableDeclarationGating: def test_table_declaration_defaults_are_absent(self): - class Plain(ActiveRecord): - __table_name__ = "plain_table_defaults" - - id: int - - expression = TableDDLDeriver(Plain, SnowflakeDialect()).create_table() + dialect = SnowflakeDialect(version=(8, 0, 0)) + expression = CreateTableExpression( + dialect, + "plain_table_defaults", + [ColumnDefinition(dialect, "id", IntegerType(dialect))], + ) + sql, params = expression.to_sql() assert expression.inherits == [] assert expression.tablespace is None + assert "plain_table_defaults" in sql.lower() + assert "id" in sql.lower() + assert params == () def test_table_inherits_is_propagated_and_rejected(self): dialect = SnowflakeDialect(version=(8, 0, 0)) assert dialect.supports_table_inheritance() is False - expression = TableDDLDeriver(InheritedTable, dialect).create_table() + expression = CreateTableExpression( + dialect, + "inherited", + [ColumnDefinition(dialect, "id", IntegerType(dialect))], + inherits=["parent_a", "parent_b"], + ) assert expression.inherits == ["parent_a", "parent_b"] with pytest.raises(UnsupportedFeatureError, match="INHERITS"): expression.to_sql() @@ -106,7 +88,12 @@ def test_table_inherits_is_propagated_and_rejected(self): def test_table_tablespace_is_propagated_and_rejected(self): dialect = SnowflakeDialect(version=(8, 0, 0)) assert dialect.supports_table_tablespace() is False - expression = TableDDLDeriver(TablespacedTable, dialect).create_table() + expression = CreateTableExpression( + dialect, + "tablespaced", + [ColumnDefinition(dialect, "id", IntegerType(dialect))], + tablespace="ts_data", + ) assert expression.tablespace == "ts_data" with pytest.raises(UnsupportedFeatureError, match="TABLESPACE"): expression.to_sql() From 30313342c1c17be2564dfe91d16e0fb9344e0007 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 25 Sep 2026 18:42:55 +0800 Subject: [PATCH 22/28] docs: document repository search scope (cherry picked from commit e053d00c029533dae577e3c6acaf709e78305d6f) (cherry picked from commit 0f1946b1849637e74f9438a9a19c6f5e403c7af1) --- CLAUDE.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index ce5c1d9..dfd6afe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,3 +45,9 @@ This project is under active development. Key features implemented: ## Version Control and Changelog This project adheres to the same version control, branching, commit message, and changelog management standards as the main `python-activerecord` project. + +## Search Scope + +- Use `rg` for repository searches; never use `grep`. +- Limit searches to this `python-activerecord` repository and its related testsuite/backend directories. +- Exclude `.git/`, `.venv*/`, `__pycache__/`, `build/`, `dist/`, and other cache directories. From aff9a6514db76e8891c50c6f85249bb9b54d216e Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 26 Sep 2026 01:22:15 +0800 Subject: [PATCH 23/28] test(ddl): cover DDLSource collection (cherry picked from commit 6b29659c8dcd87649b5178eaa939b21deb7272e3) (cherry picked from commit ab0d5cb996a342d09bfbaf2449d3648de242844e) --- .../backend/ddl/test_ddl_source_mixin.py | 477 ++++++++++++++++++ 1 file changed, 477 insertions(+) create mode 100644 tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_ddl_source_mixin.py diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_ddl_source_mixin.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_ddl_source_mixin.py new file mode 100644 index 0000000..6ac12cc --- /dev/null +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_ddl_source_mixin.py @@ -0,0 +1,477 @@ +# tests/rhosocial/activerecord_snowflake_test/feature/backend/ddl/test_ddl_source_mixin.py + +from typing import Optional + +try: + from typing import Annotated +except ImportError: + from typing_extensions import Annotated + +import pytest + +from rhosocial.activerecord.backend.expression.core import Column +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraintType, + ColumnDefinition, + CreateTableExpression, + IndexDefinition, + StorageOptionsExpression, + TableConstraint, + TableConstraintType, +) +from rhosocial.activerecord.backend.expression.statements.ddl_partition import ( + PartitionClause, + PartitionStrategy, +) +from rhosocial.activerecord.backend.impl.snowflake.dialect import SnowflakeDialect +from rhosocial.activerecord.backend.impl.snowflake.expression import ( + SnowflakeCreateTableOptions, + SnowflakeVariantType, + SnowflakeVarcharType, +) +from rhosocial.activerecord.base import ( + CollationAttribute, + DDLSource, + IdentityAttribute, + UseColumn, + UseColumnAttributes, + UseComment, + UseConstraint, + UseGeneratedColumn, + UseIndex, + UseSqlType, +) +from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord + +NATIVE_TYPES = (SnowflakeVarcharType(length=32), SnowflakeVariantType()) +SQL_TYPE = UseSqlType(*NATIVE_TYPES) +VALUE_COLUMN = UseColumn("physical_value") +DEFAULT_CONSTRAINT = UseConstraint( + ColumnConstraintType.DEFAULT, + name="dflt_value", + default_value=17, +) +UNIQUE_CONSTRAINT = UseConstraint( + ColumnConstraintType.UNIQUE, + name="uq_value", +) +CHECK_CONDITION = Column(None, "physical_value") == "active" +CHECK_CONSTRAINT = UseConstraint( + ColumnConstraintType.CHECK, + name="ck_value", + check_condition=CHECK_CONDITION, +) +FIRST_INDEX_CONDITION = object() +FIRST_INCLUDE_COLUMNS = ["id"] +FIRST_INDEX = UseIndex( + "idx_value_first", + unique=True, + type="BTREE", + partial_condition=FIRST_INDEX_CONDITION, + include_columns=FIRST_INCLUDE_COLUMNS, + if_not_exists=True, + tablespace="ts_index", + concurrent=True, +) +SECOND_INDEX = UseIndex("idx_value_second", type="BTREE") +VALUE_ATTRIBUTES = ( + CollationAttribute(name="NOCASE"), + IdentityAttribute(generation="ALWAYS", start=10, increment=2), +) +VALUE_ATTRIBUTES_MARKER = UseColumnAttributes(*VALUE_ATTRIBUTES) +VALUE_COMMENT = UseComment("physical value") +def generated_expression(dialect): + return object() + + +GENERATED_EXPRESSION = generated_expression +VALUE_GENERATED = UseGeneratedColumn(GENERATED_EXPRESSION) + +TABLE_CONSTRAINT_ONE = TableConstraint( + None, + TableConstraintType.UNIQUE, + name="uq_physical_quantity", + columns=["physical_value", "quantity"], +) +TABLE_CONSTRAINT_TWO = TableConstraint( + None, + TableConstraintType.CHECK, + name="ck_physical_quantity", + check_condition=Column(None, "physical_value") == "active", +) +TABLE_CONSTRAINTS = [TABLE_CONSTRAINT_ONE, TABLE_CONSTRAINT_TWO] +TABLE_INDEX_ONE = IndexDefinition( + None, + name="idx_table_physical", + columns=["physical_value"], +) +TABLE_INDEX_TWO = IndexDefinition( + None, + name="idx_table_quantity", + columns=["quantity"], +) +TABLE_INDEXES = [TABLE_INDEX_ONE, TABLE_INDEX_TWO] +TABLE_OPTIONS = ( + SnowflakeCreateTableOptions(None, or_replace=True), + SnowflakeCreateTableOptions(None, transient=True), +) +STORAGE_OPTIONS = StorageOptionsExpression(None, {"engine": "MergeTree"}) +TABLE_PARTITION = PartitionClause( + None, + PartitionStrategy.RANGE, + [Column(None, "physical_value")], +) +TABLE_INHERITS = ["parent_a", "parent_b"] +TABLE_TABLESPACE = "ts_data" +COMPOSITE_CONSTRAINT = TableConstraint( + None, + TableConstraintType.CHECK, + name="ck_composite", + columns=["tenant_id", "order_id"], +) + + +class TableDeclarationOverrides: + @classmethod + def table_options(cls): + return TABLE_OPTIONS + + @classmethod + def table_storage_options(cls): + return STORAGE_OPTIONS + + @classmethod + def table_partition(cls): + return TABLE_PARTITION + + @classmethod + def table_inherits(cls): + return TABLE_INHERITS + + @classmethod + def table_tablespace(cls): + return TABLE_TABLESPACE + + @classmethod + def create_table_statement_classes(cls): + return CreateTableExpression + + +class DeclarationModel(TableDeclarationOverrides, ActiveRecord): + __table_name__ = "ddl_source_declarations" + __schema_name__ = "analytics" + __primary_key__ = "record_id" + + id: Annotated[int, UseColumn("record_id")] + value: Annotated[ + Optional[str], + VALUE_COLUMN, + SQL_TYPE, + DEFAULT_CONSTRAINT, + UNIQUE_CONSTRAINT, + CHECK_CONSTRAINT, + FIRST_INDEX, + SECOND_INDEX, + VALUE_ATTRIBUTES_MARKER, + VALUE_COMMENT, + VALUE_GENERATED, + ] = None + quantity: Annotated[int, UseConstraint(ColumnConstraintType.DEFAULT, default_value=7)] = 1 + + __table_constraints__ = TABLE_CONSTRAINTS + __table_indexes__ = TABLE_INDEXES + + +class AsyncDeclarationModel(TableDeclarationOverrides, AsyncActiveRecord): + __table_name__ = "ddl_source_declarations_async" + __schema_name__ = "analytics" + __primary_key__ = "record_id" + + id: Annotated[int, UseColumn("record_id")] + value: Annotated[ + Optional[str], + VALUE_COLUMN, + SQL_TYPE, + DEFAULT_CONSTRAINT, + UNIQUE_CONSTRAINT, + CHECK_CONSTRAINT, + FIRST_INDEX, + SECOND_INDEX, + VALUE_ATTRIBUTES_MARKER, + VALUE_COMMENT, + VALUE_GENERATED, + ] = None + quantity: Annotated[int, UseConstraint(ColumnConstraintType.DEFAULT, default_value=7)] = 1 + + __table_constraints__ = TABLE_CONSTRAINTS + __table_indexes__ = TABLE_INDEXES + + +class PlainModel(ActiveRecord): + __table_name__ = "ddl_source_plain" + + id: int + value: Optional[str] = None + + +class AsyncPlainModel(AsyncActiveRecord): + __table_name__ = "ddl_source_plain_async" + + id: int + value: Optional[str] = None + + +class CompositeModel(TableDeclarationOverrides, ActiveRecord): + __table_name__ = "ddl_source_composite" + __primary_key__ = ("tenant_id", "order_id") + + tenant_ref: Annotated[int, UseColumn("tenant_id")] + order_ref: Annotated[int, UseColumn("order_id")] + + __table_constraints__ = [COMPOSITE_CONSTRAINT] + __table_indexes__ = TABLE_INDEXES + + +class AsyncCompositeModel(TableDeclarationOverrides, AsyncActiveRecord): + __table_name__ = "ddl_source_composite_async" + __primary_key__ = ("tenant_id", "order_id") + + tenant_ref: Annotated[int, UseColumn("tenant_id")] + order_ref: Annotated[int, UseColumn("order_id")] + + __table_constraints__ = [COMPOSITE_CONSTRAINT] + __table_indexes__ = TABLE_INDEXES + + +MODEL_CLASSES = (DeclarationModel, AsyncDeclarationModel) +COMPOSITE_MODEL_CLASSES = (CompositeModel, AsyncCompositeModel) +PLAIN_MODEL_CLASSES = (PlainModel, AsyncPlainModel) + + +def _same_objects(actual, expected): + return len(actual) == len(expected) and all( + actual[index] is expected[index] for index in range(len(expected)) + ) + + +def _assert_declaration_model(model): + assert isinstance(model, DDLSource) + assert model.table_name().startswith("ddl_source_declarations") + assert model.schema_name() == "analytics" + assert model.primary_key_columns() == ("record_id",) + assert model.primary_key_field() == "id" + assert model.is_composite_pk() is False + assert model.ddl_field_names() == ("id", "value", "quantity") + assert model.is_derived_field("value") is False + assert model.field_python_type("value") is str + assert model.field_is_optional("value") is True + assert model.field_python_type("quantity") is int + assert model.field_is_optional("quantity") is False + assert model.column_name("id") == "record_id" + assert model.columns_name() == { + "id": "record_id", + "value": "physical_value", + "quantity": "quantity", + } + + metadata = model.ddl_field_metadata("value") + assert metadata is model.__table_ddl_fields__["value"] + expected_annotations = ( + VALUE_COLUMN, + SQL_TYPE, + DEFAULT_CONSTRAINT, + UNIQUE_CONSTRAINT, + CHECK_CONSTRAINT, + FIRST_INDEX, + SECOND_INDEX, + VALUE_ATTRIBUTES_MARKER, + VALUE_COMMENT, + VALUE_GENERATED, + ) + assert metadata.annotations == expected_annotations + assert model.column_type("value") is SQL_TYPE + assert model.column_type("value").data_types == NATIVE_TYPES + assert _same_objects(model.column_type("value").data_types, NATIVE_TYPES) + + value_constraints = model.column_constraints("value") + assert [item.constraint_type for item in value_constraints] == [ + ColumnConstraintType.DEFAULT, + ColumnConstraintType.UNIQUE, + ColumnConstraintType.CHECK, + ] + assert value_constraints[0] is DEFAULT_CONSTRAINT.constraint + assert value_constraints[1] is UNIQUE_CONSTRAINT.constraint + assert value_constraints[2] is CHECK_CONSTRAINT.constraint + assert value_constraints[2].check_condition is CHECK_CONDITION + + value_indexes = model.column_indexes("value") + assert [item.name for item in value_indexes] == [ + "idx_value_first", + "idx_value_second", + ] + assert value_indexes[0].columns == ["physical_value"] + assert value_indexes[0].unique is True + assert value_indexes[0].type == "BTREE" + assert value_indexes[0].partial_condition is FIRST_INDEX_CONDITION + assert value_indexes[0].include_columns is FIRST_INCLUDE_COLUMNS + assert value_indexes[0].if_not_exists is True + assert value_indexes[0].tablespace == "ts_index" + assert value_indexes[0].concurrent is True + assert value_indexes[1].columns == ["physical_value"] + assert value_indexes[1].unique is False + + assert model.column_attributes("value") == list(VALUE_ATTRIBUTES) + assert _same_objects(model.column_attributes("value"), VALUE_ATTRIBUTES) + assert model.column_comment("value") == "physical value" + assert model.generated_column("value") is GENERATED_EXPRESSION + assert model.column_options("value") is None + + id_constraints = model.column_constraints("id") + assert [item.constraint_type for item in id_constraints] == [ + ColumnConstraintType.PRIMARY_KEY, + ColumnConstraintType.NOT_NULL, + ] + quantity_constraints = model.column_constraints("quantity") + assert model.model_fields["quantity"].default == 1 + assert quantity_constraints[0].constraint_type == ColumnConstraintType.DEFAULT + assert quantity_constraints[0].default_value == 7 + assert quantity_constraints[1].constraint_type == ColumnConstraintType.NOT_NULL + + assert model.table_options() is TABLE_OPTIONS + assert [item.transient for item in model.table_options()] == [False, True] + assert model.table_storage_options() is STORAGE_OPTIONS + assert model.table_partition() is TABLE_PARTITION + assert model.table_partition().keys[0].name == "physical_value" + assert model.table_inherits() is TABLE_INHERITS + assert model.table_tablespace() == TABLE_TABLESPACE + assert model.create_table_statement_classes() is CreateTableExpression + assert model.table_constraints() == TABLE_CONSTRAINTS + assert _same_objects(model.table_constraints(), TABLE_CONSTRAINTS) + assert model.table_indexes() == TABLE_INDEXES + assert _same_objects(model.table_indexes(), TABLE_INDEXES) + + +def _assert_batch_interfaces(model): + fields = ["value", "id", "quantity"] + assert list(model.columns_name()) == ["id", "value", "quantity"] + assert list(model.columns_name(fields)) == fields + assert model.columns_name(fields) == { + "value": "physical_value", + "id": "record_id", + "quantity": "quantity", + } + assert list(model.columns_type(fields)) == fields + assert model.columns_type(fields)["value"] is SQL_TYPE + assert model.columns_type(fields)["quantity"] is None + assert list(model.columns_constraints(fields)) == fields + assert model.columns_constraints(fields)["value"][0] is DEFAULT_CONSTRAINT.constraint + assert list(model.columns_attributes(fields)) == fields + assert model.columns_attributes(fields)["value"][0] is VALUE_ATTRIBUTES[0] + assert list(model.columns_indexes(fields)) == fields + assert model.columns_indexes(fields)["value"][0].name == "idx_value_first" + assert list(model.columns_comment(fields)) == fields + assert model.columns_comment(fields)["value"] == "physical value" + assert list(model.columns_generated(fields)) == fields + assert model.columns_generated(fields)["value"] is GENERATED_EXPRESSION + assert list(model.columns_options(fields)) == fields + assert model.columns_options(fields)["value"] is None + + +def _assert_plain_defaults(model): + assert isinstance(model, DDLSource) + assert model.ddl_field_names() == ("id", "value") + assert model.column_type("value") is None + assert model.column_constraints("value") == [] + assert model.column_attributes("value") == [] + assert model.column_indexes("value") == [] + assert model.column_comment("value") is None + assert model.generated_column("value") is None + assert model.column_options("value") is None + assert model.table_options() is None + assert model.table_storage_options() is None + assert model.table_partition() is None + assert model.table_inherits() is None + assert model.table_tablespace() is None + assert model.create_table_statement_classes() is None + assert model.table_indexes() == [] + assert model.table_constraints() == [] + + +def _assert_composite_model(model): + assert model.primary_key_columns() == ("tenant_id", "order_id") + assert model.primary_key_field() == ("tenant_ref", "order_ref") + assert model.is_composite_pk() is True + assert model.column_name("tenant_ref") == "tenant_id" + assert model.column_name("order_ref") == "order_id" + assert model.columns_name() == { + "tenant_ref": "tenant_id", + "order_ref": "order_id", + } + for field in ("tenant_ref", "order_ref"): + constraints = model.column_constraints(field) + assert [item.constraint_type for item in constraints] == [ + ColumnConstraintType.NOT_NULL, + ] + + constraints = model.table_constraints() + assert constraints[0] is COMPOSITE_CONSTRAINT + assert constraints[-1].constraint_type == TableConstraintType.PRIMARY_KEY + assert constraints[-1].columns == ["tenant_id", "order_id"] + assert all( + item.constraint_type != TableConstraintType.PRIMARY_KEY + for item in constraints[:-1] + ) + + +@pytest.mark.parametrize("model", MODEL_CLASSES, ids=("sync", "async")) +def test_ddl_source_collects_field_and_table_declarations(model): + _assert_declaration_model(model) + _assert_batch_interfaces(model) + + +@pytest.mark.parametrize("model", COMPOSITE_MODEL_CLASSES, ids=("sync", "async")) +def test_composite_primary_key_is_collected_at_table_level(model): + _assert_composite_model(model) + + +@pytest.mark.parametrize("model", PLAIN_MODEL_CLASSES, ids=("sync", "async")) +def test_ddl_source_defaults_are_empty_or_none(model): + _assert_plain_defaults(model) + + +async def test_sync_and_async_declarations_are_identical(): + sync_model, async_model = MODEL_CLASSES + assert sync_model.ddl_field_names() == async_model.ddl_field_names() + assert sync_model.columns_name() == async_model.columns_name() + assert sync_model.column_type("value") is async_model.column_type("value") + assert sync_model.column_constraints("value") == async_model.column_constraints("value") + assert sync_model.column_attributes("value") == async_model.column_attributes("value") + sync_indexes = sync_model.column_indexes("value") + async_indexes = async_model.column_indexes("value") + assert [ + (item.name, item.columns, item.unique, item.type, item.partial_condition) + for item in sync_indexes + ] == [ + (item.name, item.columns, item.unique, item.type, item.partial_condition) + for item in async_indexes + ] + assert sync_model.column_comment("value") == async_model.column_comment("value") + assert sync_model.generated_column("value") is async_model.generated_column("value") + assert sync_model.table_options() is async_model.table_options() + assert sync_model.table_storage_options() is async_model.table_storage_options() + assert sync_model.table_partition() is async_model.table_partition() + assert sync_model.table_inherits() is async_model.table_inherits() + assert sync_model.table_tablespace() == async_model.table_tablespace() + assert sync_model.columns_options() == async_model.columns_options() + + +def test_collected_snowflake_type_renders_minimal_column(): + dialect = SnowflakeDialect(version=(8, 0, 0)) + data_type = DeclarationModel.column_type("value").data_types[0] + assert isinstance(data_type, SnowflakeVarcharType) + data_type.dialect = dialect + try: + rendered = ColumnDefinition(dialect, "physical_value", data_type).to_sql() + finally: + data_type.dialect = None + assert rendered == ('"physical_value" VARCHAR(32)', ()) From 4e540e4c5f439eb3ea49aefac2f72a46f76c17e1 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 26 Sep 2026 09:20:47 +0800 Subject: [PATCH 24/28] fix(view): wire materialized-view expressions into the core expression hierarchy Snowflake declares supports_materialized_view() = True, but its formatter only read Snowflake-specific fields (as_query / column_list / name), so passing a core expression raised: AttributeError: 'CreateMaterializedViewExpression' object has no attribute 'as_query' A backend that advertises MV support yet cannot use the generic MV API is exactly the defect the new backend contract catches (python-activerecord-testsuite feature/basic/ddl/test_materialized_view_contract.py). Make SnowflakeCreateMaterializedViewExpression inherit the core CreateMaterializedViewExpression and normalise the field names (view_name / query / column_aliases), while keeping the existing Snowflake spellings name= / as_query= / column_list= as aliases, and or_replace / if_not_exists / cluster_by / comment as extensions. query now accepts either a QueryExpression or a raw SQL string. Also fixes two silent discards: - TABLESPACE / storage_options do not exist in Snowflake and now raise UnsupportedFeatureError instead of being quietly ignored - or_replace together with if_not_exists is rejected up front, since Snowflake forbids the combination, rather than emitting SQL the server will reject Snowflake has no WITH [NO] DATA (a view is always created empty and filled in the background), so the inherited with_data is not rendered, and a test pins that behaviour. Tests: the 15 existing MV cases stay green; 11 new cases cover generic expression interop, field aliases, the mutual-exclusion check, the TABLESPACE/storage_options rejection, and with_data not being rendered. (cherry picked from commit 26f409f53b971f94ba039c5b5f32af44c6fa64b9) (cherry picked from commit cbf9fd9a3031ee10ef14075f97af3db95ef7b243) --- changelog.d/20.fixed.md | 1 + .../expression/ddl/materialized_view.py | 88 +++++++++++--- .../snowflake/mixins/materialized_view.py | 78 +++++++++---- .../test_undrop_clone_materialized_view.py | 107 ++++++++++++++++++ 4 files changed, 239 insertions(+), 35 deletions(-) create mode 100644 changelog.d/20.fixed.md diff --git a/changelog.d/20.fixed.md b/changelog.d/20.fixed.md new file mode 100644 index 0000000..5f4b751 --- /dev/null +++ b/changelog.d/20.fixed.md @@ -0,0 +1 @@ +`SnowflakeCreateMaterializedViewExpression` now extends the core `CreateMaterializedViewExpression`, so the generic materialized view API works on Snowflake instead of raising `AttributeError: 'CreateMaterializedViewExpression' object has no attribute 'as_query'`. Existing `name=` / `as_query=` / `column_list=` usage is unchanged. `TABLESPACE` and `WITH (storage_parameter)` are now rejected with `UnsupportedFeatureError` rather than being silently dropped, and `OR REPLACE` together with `IF NOT EXISTS` is rejected up front as Snowflake requires. diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/materialized_view.py b/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/materialized_view.py index 62e1fbb..c414a80 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/expression/ddl/materialized_view.py @@ -6,14 +6,30 @@ CREATE [OR REPLACE] MATERIALIZED VIEW statements with optional column aliases, CLUSTER BY keys and COMMENT. +The expression extends the core +:class:`~rhosocial.activerecord.backend.expression.statements.ddl_view.CreateMaterializedViewExpression` +so the generic MV API works on Snowflake, and layers the Snowflake-specific +options on top. + +Snowflake divergence from the generic expression +------------------------------------------------ +* ``OR REPLACE`` and ``IF NOT EXISTS`` are mutually exclusive. +* There is no ``WITH [NO] DATA`` clause: Snowflake always creates the view + empty and fills it in the background, so the inherited ``with_data`` flag is + accepted but not rendered. +* ``TABLESPACE`` and ``WITH (storage_parameter)`` do not exist; passing them + raises ``UnsupportedFeatureError`` rather than silently dropping them. + Feature Source: Snowflake native (not SQL standard) Official Documentation: - CREATE MATERIALIZED VIEW: https://docs.snowflake.com/en/sql-reference/sql/create-materialized-view """ -from typing import List, Optional, Tuple, TYPE_CHECKING +from typing import Any, List, Optional, Tuple, TYPE_CHECKING -from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.statements.ddl_view import ( + CreateMaterializedViewExpression, +) if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase @@ -24,45 +40,85 @@ ] -class SnowflakeCreateMaterializedViewExpression(BaseExpression): +class SnowflakeCreateMaterializedViewExpression(CreateMaterializedViewExpression): """Snowflake CREATE [OR REPLACE] MATERIALIZED VIEW statement expression. + Accepts both the Snowflake field names (``name`` / ``as_query`` / + ``column_list``) and the core ones (``view_name`` / ``query`` / + ``column_aliases``), so callers can use either vocabulary. + Attributes: - name: Materialized view name. - or_replace: Emit ``OR REPLACE``. + view_name: Materialized view name. + query: Defining query — a ``QueryExpression`` or a raw SQL string. + column_aliases: Optional column alias list. + or_replace: Emit ``OR REPLACE`` (mutually exclusive with ``if_not_exists``). if_not_exists: Emit ``IF NOT EXISTS``. - column_list: Optional column alias list. cluster_by: ``CLUSTER BY`` column list. comment: ``COMMENT`` string literal. - as_query: Query SQL fragment (the ``SELECT`` body). """ def __init__( self, dialect: "SQLDialectBase", - name: str, + name: Optional[str] = None, + query: Any = None, *, - or_replace: bool = False, - if_not_exists: bool = False, + view_name: Optional[str] = None, + as_query: Optional[str] = None, column_list: Optional[List[str]] = None, + column_aliases: Optional[List[str]] = None, cluster_by: Optional[List[str]] = None, comment: Optional[str] = None, - as_query: Optional[str] = None, + or_replace: bool = False, + if_not_exists: bool = False, + **core_kwargs: Any, ): - super().__init__(dialect) - self.name = name + resolved_name = view_name if view_name is not None else name + if resolved_name is None: + raise ValueError("CREATE MATERIALIZED VIEW requires a view name") + if view_name is not None and name is not None and view_name != name: + raise ValueError("name and view_name disagree; pass only one") + + resolved_query = query if query is not None else as_query + resolved_columns = column_aliases if column_aliases is not None else column_list + if column_aliases is not None and column_list is not None: + raise ValueError("column_list and column_aliases disagree; pass only one") + + super().__init__( + dialect, + view_name=resolved_name, + query=resolved_query, + column_aliases=resolved_columns, + **core_kwargs, + ) + if or_replace and if_not_exists: + raise ValueError( + "Snowflake rejects OR REPLACE together with IF NOT EXISTS" + ) self.or_replace = or_replace self.if_not_exists = if_not_exists - self.column_list = column_list self.cluster_by = cluster_by self.comment = comment - self.as_query = as_query + + @property + def name(self) -> str: + """Alias for view_name (Snowflake field name).""" + return self.view_name + + @property + def as_query(self) -> Any: + """Alias for query (Snowflake field name).""" + return self.query + + @property + def column_list(self) -> List[str]: + """Alias for column_aliases (Snowflake field name).""" + return self.column_aliases def to_sql(self) -> "Tuple[str, tuple]": """Generate CREATE MATERIALIZED VIEW SQL statement. Returns: Tuple of (SQL string, empty params tuple). - """ return self.dialect.format_create_materialized_view_statement(self) diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/materialized_view.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/materialized_view.py index 26dc47d..d11cf43 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/materialized_view.py @@ -1,7 +1,9 @@ # src/rhosocial/activerecord/backend/impl/snowflake/mixins/materialized_view.py """SnowflakeMaterializedViewMixin — materialized view DDL support.""" -from typing import Tuple, TYPE_CHECKING +from typing import Any, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError if TYPE_CHECKING: from ..expression.ddl.materialized_view import ( @@ -10,7 +12,11 @@ class SnowflakeMaterializedViewMixin: - """Mixin for Snowflake materialized view support.""" + """Mixin for Snowflake materialized view support. + + Must stay before the core ``ViewMixin`` in ``SnowflakeDialect`` so this + formatter takes precedence. + """ def supports_materialized_view(self) -> bool: """Snowflake supports native materialized views.""" @@ -21,35 +27,69 @@ def format_create_materialized_view_statement( ) -> Tuple[str, tuple]: """Format CREATE [OR REPLACE] MATERIALIZED VIEW statement. + Accepts both ``SnowflakeCreateMaterializedViewExpression`` and the core + ``CreateMaterializedViewExpression``; the defining query may be a + ``QueryExpression`` or a raw SQL string. + Args: - expr: :class:`SnowflakeCreateMaterializedViewExpression`. + expr: Materialized view create expression. Returns: Tuple of (SQL string, empty params tuple). Raises: - ValueError: when ``as_query`` is not specified. + ValueError: when no defining query is supplied. + UnsupportedFeatureError: for TABLESPACE / storage parameters, which + Snowflake does not have. """ - if expr.as_query is None: - raise ValueError( - "CREATE MATERIALIZED VIEW requires an as_query" + if expr.tablespace: + raise UnsupportedFeatureError(self.name, "MATERIALIZED VIEW TABLESPACE") + if expr.storage_options: + raise UnsupportedFeatureError( + self.name, "MATERIALIZED VIEW STORAGE PARAMETERS" ) + + query_sql = self._materialized_view_query_sql(expr) + if query_sql is None: + raise ValueError("CREATE MATERIALIZED VIEW requires a query") + parts = ["CREATE"] - if expr.or_replace: + if getattr(expr, "or_replace", False): parts.append("OR REPLACE") parts.append("MATERIALIZED VIEW") - if expr.if_not_exists: + if getattr(expr, "if_not_exists", False): parts.append("IF NOT EXISTS") - parts.append(self.format_identifier(expr.name)) - if expr.column_list: - cols = ", ".join(self.format_identifier(c) for c in expr.column_list) + parts.append(self.format_identifier(expr.view_name)) + + column_aliases = getattr(expr, "column_aliases", None) + if column_aliases: + cols = ", ".join(self.format_identifier(col) for col in column_aliases) parts.append(f"({cols})") - if expr.cluster_by: - cols = ", ".join(self.format_identifier(c) for c in expr.cluster_by) + + cluster_by = getattr(expr, "cluster_by", None) + if cluster_by: + cols = ", ".join(self.format_identifier(col) for col in cluster_by) parts.append(f"CLUSTER BY ({cols})") - if expr.comment is not None: - parts.append( - f"COMMENT = '{self._escape_sql_string(expr.comment)}'" - ) - parts.extend(["AS", str(expr.as_query)]) + + comment = getattr(expr, "comment", None) + if comment is not None: + parts.append(f"COMMENT = '{self._escape_sql_string(comment)}'") + + # Snowflake has no WITH [NO] DATA: the view is created empty and filled + # in the background, so the inherited ``with_data`` flag is not rendered. + parts.extend(["AS", query_sql]) return " ".join(parts), () + + def _materialized_view_query_sql(self, expr: Any): + """Return the defining query as SQL text, or None when absent. + + Snowflake's own expression carries a raw ``as_query`` string; the core + expression carries a ``QueryExpression``. + """ + query = getattr(expr, "query", None) + if query is None: + return None + if isinstance(query, str): + return query + sql, _params = query.to_sql() + return sql diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/backend/extensions/test_undrop_clone_materialized_view.py b/tests/rhosocial/activerecord_snowflake_test/feature/backend/extensions/test_undrop_clone_materialized_view.py index 74e3185..910bc8f 100644 --- a/tests/rhosocial/activerecord_snowflake_test/feature/backend/extensions/test_undrop_clone_materialized_view.py +++ b/tests/rhosocial/activerecord_snowflake_test/feature/backend/extensions/test_undrop_clone_materialized_view.py @@ -6,6 +6,11 @@ import pytest +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression import Column, QueryExpression, TableExpression +from rhosocial.activerecord.backend.expression.statements.ddl_view import ( + CreateMaterializedViewExpression, +) from rhosocial.activerecord.backend.impl.snowflake.dialect import SnowflakeDialect from rhosocial.activerecord.backend.impl.snowflake.protocols import ( SnowflakeCloneSupport, @@ -136,3 +141,105 @@ def test_create_materialized_view_requires_as_query(self, dialect): expr = SnowflakeCreateMaterializedViewExpression(dialect, "mv") with pytest.raises(ValueError): expr.to_sql() + + +class TestSnowflakeGenericMaterializedViewInterop: + """The core MV expression must render on Snowflake. + + ``SnowflakeCreateMaterializedViewExpression`` extends the core expression, so + a backend advertising ``supports_materialized_view()`` has to work with the + generic API too. Previously the formatter read ``expr.as_query`` only, and + the generic expression raised ``AttributeError``. + """ + + def _query(self, dialect): + return QueryExpression( + dialect=dialect, + select=[Column(dialect, "c1")], + from_=TableExpression(dialect, "t"), + ) + + def test_generic_expression_renders(self, dialect): + expr = CreateMaterializedViewExpression( + dialect=dialect, view_name="mv", query=self._query(dialect) + ) + sql, params = expr.to_sql() + assert sql.startswith('CREATE MATERIALIZED VIEW "mv" AS SELECT') + assert params == () + + def test_generic_expression_with_column_aliases(self, dialect): + expr = CreateMaterializedViewExpression( + dialect=dialect, + view_name="mv", + query=self._query(dialect), + column_aliases=["alias_c1"], + ) + sql, _ = expr.to_sql() + assert '("alias_c1")' in sql + + def test_generic_expression_requires_query(self, dialect): + expr = CreateMaterializedViewExpression( + dialect=dialect, view_name="mv", query=None + ) + with pytest.raises(ValueError): + expr.to_sql() + + def test_snowflake_expression_accepts_core_field_names(self, dialect): + """The core vocabulary (view_name/query) works on the Snowflake expression.""" + expr = SnowflakeCreateMaterializedViewExpression( + dialect, view_name="mv", query=self._query(dialect) + ) + sql, _ = expr.to_sql() + assert sql.startswith('CREATE MATERIALIZED VIEW "mv" AS SELECT') + + def test_snowflake_expression_accepts_raw_query_string(self, dialect): + expr = SnowflakeCreateMaterializedViewExpression( + dialect, "mv", as_query="SELECT 1" + ) + sql, _ = expr.to_sql() + assert sql == 'CREATE MATERIALIZED VIEW "mv" AS SELECT 1' + + def test_field_name_aliases(self, dialect): + expr = SnowflakeCreateMaterializedViewExpression( + dialect, "mv", as_query="SELECT 1", column_list=["a"] + ) + assert expr.name == expr.view_name == "mv" + assert expr.as_query == "SELECT 1" + assert expr.column_list == expr.column_aliases == ["a"] + + def test_or_replace_and_if_not_exists_are_exclusive(self, dialect): + with pytest.raises(ValueError): + SnowflakeCreateMaterializedViewExpression( + dialect, "mv", as_query="SELECT 1", or_replace=True, if_not_exists=True + ) + + def test_view_name_is_required(self, dialect): + with pytest.raises(ValueError): + SnowflakeCreateMaterializedViewExpression(dialect, as_query="SELECT 1") + + def test_tablespace_is_rejected(self, dialect): + """Snowflake has no TABLESPACE — refuse instead of dropping the clause.""" + expr = SnowflakeCreateMaterializedViewExpression( + dialect, "mv", as_query="SELECT 1", tablespace="fast_ssd" + ) + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() + + def test_storage_options_are_rejected(self, dialect): + expr = SnowflakeCreateMaterializedViewExpression( + dialect, "mv", as_query="SELECT 1", storage_options={"fillfactor": 70} + ) + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() + + def test_with_data_is_not_rendered(self, dialect): + """Snowflake has no WITH [NO] DATA; the inherited flag must not leak out.""" + expr = CreateMaterializedViewExpression( + dialect=dialect, + view_name="mv", + query=self._query(dialect), + with_data=False, + ) + sql, _ = expr.to_sql() + assert "WITH DATA" not in sql + assert "WITH NO DATA" not in sql From 24e89abf461f0a19ba45671a37a423b6317a6ce3 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 23 May 2026 15:55:03 +0800 Subject: [PATCH 25/28] chore: add .gitattributes to enforce LF line endings (cherry picked from commit 5fdd917a725ad89221da2f122ca518e23ddc746c) (cherry picked from commit 7594b33cbb94486663e3512915ced11c8906e7ef) (cherry picked from commit 310a77ffa80b9b0480c7b54eef2c289ce816888a) (cherry picked from commit 141309e93287f440905b7988201c072593c9a641) --- .gitattributes | 11 +++++++++++ .github/workflows/publish.yml | 8 ++++---- 2 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..bb5f490 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf +*.py text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.toml text eol=lf +*.cfg text eol=lf +*.ini text eol=lf +*.sh text eol=lf +*.bat text eol=crlf +*.ps1 text eol=lf diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3cf40f7..74409a2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -39,10 +39,10 @@ jobs: steps: - name: Check out the repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.13" @@ -71,10 +71,10 @@ jobs: steps: - name: Check out the repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.13" From 9307aefb6a485ad7ad884599f651943fef5b696b Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 27 Sep 2026 15:19:25 +0800 Subject: [PATCH 26/28] fix(scripts): stop the pre-commit hook from blocking commits on older revisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The installed hook exec'd scripts/preflight.sh unconditionally. That hook is a local, untracked artifact, so it survives a checkout of any commit that predates the script, and on such a commit exec failed with a bare "No such file or directory" — blocking the commit with nothing to act on. In this repository scripts/preflight.sh first appears at 33ce3d1, so every earlier commit is affected. The failure is also silent in a dangerous way: it swallowed a `git commit --amend`, so a commit-message rewrite reported as applied when it had not been, and had to be redone. The hook now checks the script is executable and exits 0 otherwise, so a revision without the pre-flight simply skips it. Normal operation is unchanged. Also honours core.hooksPath. git ignores .git/hooks entirely once that is set, so the hook was being written to a location that would never run while this script still reported success. No repository sets it today, so that part is latent. --- scripts/install-git-hooks.sh | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh index e06f0cb..81b3dda 100644 --- a/scripts/install-git-hooks.sh +++ b/scripts/install-git-hooks.sh @@ -4,21 +4,42 @@ # Installs an optional pre-commit hook that runs scripts/preflight.sh so a # broken import/lint is caught before the commit (and before a CI round-trip). # Bypass a single commit with: git commit --no-verify +# +# Re-run this after pulling, and re-run it from the repository root. To remove +# the hook, delete .git/hooks/pre-commit (or the core.hooksPath directory). set -euo pipefail cd "$(dirname "$0")/.." -HOOK=".git/hooks/pre-commit" -if [ ! -d .git/hooks ]; then - echo "install-git-hooks: .git/hooks not found (not a git repo?)" >&2 - exit 2 +# git ignores .git/hooks entirely once core.hooksPath is set, so a hook written +# there would never run while this script still reported success. +HOOKS_PATH="$(git config core.hooksPath || true)" +if [ -n "$HOOKS_PATH" ]; then + echo "install-git-hooks: core.hooksPath is set to '$HOOKS_PATH'." >&2 + echo "install-git-hooks: .git/hooks is ignored by git; writing the hook to" >&2 + echo " '$HOOKS_PATH/pre-commit' instead." >&2 + HOOK="$HOOKS_PATH/pre-commit" + mkdir -p "$HOOKS_PATH" +else + HOOK=".git/hooks/pre-commit" + if [ ! -d .git/hooks ]; then + echo "install-git-hooks: .git/hooks not found (not a git repo?)" >&2 + exit 2 + fi fi cat > "$HOOK" <<'HOOK_BODY' #!/usr/bin/env bash # Installed by scripts/install-git-hooks.sh — runs the local pre-flight. # Skip with: git commit --no-verify -exec "$(git rev-parse --show-toplevel)/scripts/preflight.sh" +# +# The guard below matters on older revisions: this hook is a local, untracked +# artifact, so it survives a checkout of a commit that predates +# scripts/preflight.sh. Without the guard, exec fails with a bare +# "No such file or directory" and blocks the commit with nothing to act on. +PREFLIGHT="$(git rev-parse --show-toplevel)/scripts/preflight.sh" +[ -x "$PREFLIGHT" ] || exit 0 +exec "$PREFLIGHT" HOOK_BODY chmod +x "$HOOK" From 9e7e282851a4c69d74f76a79ce5764e4c18275e6 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 27 Sep 2026 15:49:41 +0800 Subject: [PATCH 27/28] ci: install core and testsuite from release branches Both dependencies were pinned to feature/activerecord-derived-ddl, a branch that exists only to carry the derived-DDL work. Now that the core change has landed in release/v1.0.0.dev30 and the matching testsuite change in release/v1.0.0.dev17, pinning to the feature branch means CI keeps resolving a branch that will be deleted once the backend PRs merge. This is a prerequisite for merging: leaving the old ref in place means the first run after that branch disappears fails to resolve, not the code. --- .github/workflows/test.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a72c2e2..02d0990 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,8 +33,8 @@ jobs: run: | pip install -r requirements.txt pip install -r requirements-dev.txt - pip install git+https://github.com/rhosocial/python-activerecord.git@feature/activerecord-derived-ddl#egg=rhosocial-activerecord - pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl#egg=rhosocial-activerecord-testsuite + pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30#egg=rhosocial-activerecord + pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17#egg=rhosocial-activerecord-testsuite - name: Install package run: | @@ -77,8 +77,8 @@ jobs: run: | pip install -r requirements.txt pip install -r requirements-dev.txt - pip install git+https://github.com/rhosocial/python-activerecord.git@feature/activerecord-derived-ddl#egg=rhosocial-activerecord - pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl#egg=rhosocial-activerecord-testsuite + pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30#egg=rhosocial-activerecord + pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17#egg=rhosocial-activerecord-testsuite - name: Install package run: | @@ -109,8 +109,8 @@ jobs: run: | pip install -r requirements.txt pip install -r requirements-dev.txt - pip install git+https://github.com/rhosocial/python-activerecord.git@feature/activerecord-derived-ddl#egg=rhosocial-activerecord - pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl#egg=rhosocial-activerecord-testsuite + pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30#egg=rhosocial-activerecord + pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17#egg=rhosocial-activerecord-testsuite - name: Install package run: | From 8ef9b7f3df00263f9d3116df872c5f719e12e45f Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 27 Sep 2026 19:37:25 +0800 Subject: [PATCH 28/28] chore: add changelog fragment for PR #3 --- changelog.d/3.added.md | 1 + changelog.d/3.fixed.md | 1 + changelog.d/3.internal.md | 1 + changelog.d/3.removed.md | 1 + 4 files changed, 4 insertions(+) create mode 100644 changelog.d/3.added.md create mode 100644 changelog.d/3.fixed.md create mode 100644 changelog.d/3.internal.md create mode 100644 changelog.d/3.removed.md diff --git a/changelog.d/3.added.md b/changelog.d/3.added.md new file mode 100644 index 0000000..8760505 --- /dev/null +++ b/changelog.d/3.added.md @@ -0,0 +1 @@ +Support standalone `COMMENT ON` in the Snowflake backend and advertise comment capabilities deliberately, rendering a table comment inline the way Snowflake expects. Add `SnowflakeCreateTableOptions` that renders `TRANSIENT`, support `IDENTITY(start, step)` and quoted column collation, add typed database and insert-overwrite expressions, add type DDL support, drop an index by bare name without `ON
`, wire `SnowflakeDatabaseMixin` into the dialect, and wire materialized-view expressions into the core expression hierarchy. diff --git a/changelog.d/3.fixed.md b/changelog.d/3.fixed.md new file mode 100644 index 0000000..131e068 --- /dev/null +++ b/changelog.d/3.fixed.md @@ -0,0 +1 @@ +Stop advertising Snowflake `ALTER SCHEMA` capabilities that cannot actually be rendered, and correct partition semantics to the clustering and external forms Snowflake supports. diff --git a/changelog.d/3.internal.md b/changelog.d/3.internal.md new file mode 100644 index 0000000..dc1f68c --- /dev/null +++ b/changelog.d/3.internal.md @@ -0,0 +1 @@ +Install core and testsuite from release branches in CI instead of the derived-DDL feature branch, register the pytest markers the suite already uses, add a `.gitattributes` file to enforce LF line endings, and stop the pre-commit hook from blocking commits on revisions that predate `scripts/preflight.sh`. diff --git a/changelog.d/3.removed.md b/changelog.d/3.removed.md new file mode 100644 index 0000000..45847c6 --- /dev/null +++ b/changelog.d/3.removed.md @@ -0,0 +1 @@ +Remove the reserved `dialect_options` bag from data-type value objects and from partition clauses in the Snowflake backend.