From bc44c38e1b44ad12887e2cb8352f909f307bbc3e Mon Sep 17 00:00:00 2001 From: vistart Date: Mon, 21 Sep 2026 21:53:33 +0800 Subject: [PATCH 01/24] refactor(ddl): drop legacy identity fields from column classes Remove identity/identity_start/identity_increment/identity_clause from XxxColumnDefinition and XxxColumnOptions to match core's column-attribute migration; XxxColumnDefinition now forwards attributes. --- .../impl/clickhouse/expression/column.py | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/column.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/column.py index 4dbf702..5b72362 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/column.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/column.py @@ -47,10 +47,7 @@ def __init__( constraints=None, comment: Optional[str] = None, generated_expression=None, - identity: Optional[str] = None, - identity_start: Optional[int] = None, - identity_increment: Optional[int] = None, - identity_clause=None, + attributes=None, *, codec: Optional[Sequence[str]] = None, materialized: Optional[BaseExpression] = None, @@ -64,10 +61,7 @@ def __init__( constraints=constraints, comment=comment, generated_expression=generated_expression, - identity=identity, - identity_start=identity_start, - identity_increment=identity_increment, - identity_clause=identity_clause, + attributes=attributes, ) if materialized is not None and not isinstance(materialized, BaseExpression): raise TypeError("materialized must be a BaseExpression") @@ -87,17 +81,12 @@ class ClickHouseColumnOptions(ColumnOptions): def __init__( self, *, - identity_start: Optional[int] = None, - identity_increment: Optional[int] = None, codec: Optional[Sequence[str]] = None, materialized: Optional[BaseExpression] = None, alias: Optional[BaseExpression] = None, ttl: Optional[BaseExpression] = None, ): - super().__init__( - identity_start=identity_start, - identity_increment=identity_increment, - ) + super().__init__() if materialized is not None and not isinstance(materialized, BaseExpression): raise TypeError("materialized must be a BaseExpression") if alias is not None and not isinstance(alias, BaseExpression): From 82affddf5abb94808b69f0301841a8cad7366684 Mon Sep 17 00:00:00 2001 From: vistart Date: Mon, 21 Sep 2026 22:16:17 +0800 Subject: [PATCH 02/24] feat(ddl): render column attributes; backend identity syntax Render the dialect-free column attributes channel in each backend's format_column_definition (it was silently dropped), and give backends whose identity syntax differs from the SQL-standard form a format_identity_clause override (MySQL/MariaDB AUTO_INCREMENT, SQL Server IDENTITY(seed, inc)). Oracle gates column COLLATE to 12.2+; MariaDB drops the duplicate dialect formatter so the attribute-aware mixin is authoritative. --- .../activerecord/backend/impl/clickhouse/mixins/ddl_table.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_table.py index e02bacb..b14d4e0 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_table.py @@ -154,6 +154,11 @@ def format_column_definition(self, col_def: "ColumnDefinition") -> Tuple[str, tu suggestion="ClickHouse does not support AUTO_INCREMENT; use UUID or an explicit value." ) + attr_sql, attr_params = self.format_column_attributes(col_def) + if attr_sql: + parts.append(attr_sql.strip()) + params.extend(attr_params) + if isinstance(col_def, ClickHouseColumnDefinition): if col_def.materialized is not None: mat_sql, mat_params = col_def.materialized.to_sql() From db9c7d05012f4c5cc6045cfa485b1e50ffd2a67b Mon Sep 17 00:00:00 2001 From: vistart Date: Mon, 21 Sep 2026 22:31:42 +0800 Subject: [PATCH 03/24] 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. --- .../feature/backend/protocol/test_protocol_conformance.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/protocol/test_protocol_conformance.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/protocol/test_protocol_conformance.py index e38bff3..1d167aa 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/protocol/test_protocol_conformance.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/protocol/test_protocol_conformance.py @@ -66,6 +66,7 @@ def get_own_protocol_methods(proto: type) -> set: CLICKHOUSE_PROTOCOLS = [ dialect_protocols.CollationSupport, dialect_protocols.CTESupport, + dialect_protocols.ColumnAttributeSupport, dialect_protocols.FilterClauseSupport, dialect_protocols.WindowFunctionSupport, dialect_protocols.JSONSupport, From 9d5081103a2fa80e7f5692ff91197e429a0c813d Mon Sep 17 00:00:00 2001 From: vistart Date: Tue, 22 Sep 2026 00:31:42 +0800 Subject: [PATCH 04/24] fix(clickhouse): declare no per-column collation support ClickHouse parses COLLATE in column definitions only for MySQL migration compatibility and otherwise rejects it (NOT_IMPLEMENTED); SHOW COLUMNS reports the collation as always NULL. Return False from supports_column_collation so the attribute is not emitted. --- .../backend/impl/clickhouse/mixins/collation.py | 10 ++++++++++ .../backend/dialect/test_dialect_capabilities.py | 1 + 2 files changed, 11 insertions(+) diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/collation.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/collation.py index f697ee2..96bcd8e 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/collation.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/collation.py @@ -10,6 +10,16 @@ class ClickHouseCollationMixin: """ClickHouse collation validation.""" + def supports_column_collation(self) -> bool: + """ClickHouse has no per-column collations. + + ``COLLATE`` in a column definition is parsed only for MySQL + migration compatibility and is otherwise rejected with + ``NOT_IMPLEMENTED`` unless ``compatibility_ignore_collation_in_create_table`` + is set; ``SHOW COLUMNS`` reports the collation as always ``NULL``. + """ + return False + def validate_collation_name(self, expr: "CollateExpression") -> str: """Validate ClickHouse collation names and return their SQL representation.""" if expr.collation_options: diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_dialect_capabilities.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_dialect_capabilities.py index a64c061..6254a83 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_dialect_capabilities.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_dialect_capabilities.py @@ -105,6 +105,7 @@ def test_other_unsupported(self, dialect): assert dialect.supports_lateral_join() is False assert dialect.supports_fulltext_index() is False assert dialect.supports_collate_expression() is False + assert dialect.supports_column_collation() is False class TestClickHouseSQLGeneration: From 7a5aaac79d1e198d2a926f560c203ea754e249d8 Mon Sep 17 00:00:00 2001 From: vistart Date: Wed, 23 Sep 2026 18:05:59 +0800 Subject: [PATCH 05/24] feat(clickhouse): advertise inline table and column comment capabilities ClickHouse renders the table comment as an inline clause and the column comment inside the column definition; declare supports_table_comment() and supports_column_comment() as True so the capability bits match the actual rendering behavior. --- .../backend/impl/clickhouse/mixins/ddl_table.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_table.py index b14d4e0..85a775e 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_table.py @@ -30,6 +30,20 @@ def supports_create_or_replace_table(self) -> bool: def supports_inline_index(self) -> bool: return True + def supports_table_comment(self) -> bool: + """Whether inline ``COMMENT 'text'`` on ``CREATE TABLE`` is supported. + + ClickHouse renders the table comment as an inline clause (and the + column comment inside the column definition), so both capabilities + advertise True and the inline path is the rendering path. + """ + return True + + def supports_column_comment(self) -> bool: + """Whether inline ``COMMENT 'text'`` in a column definition is + supported. ClickHouse renders it natively.""" + return True + def supports_storage_engine_option(self) -> bool: return True From 5027c40848e4c4d386288d4cc9f32fec873ba8ea Mon Sep 17 00:00:00 2001 From: vistart Date: Wed, 23 Sep 2026 21:52:12 +0800 Subject: [PATCH 06/24] refactor(ddl): use core typed comment clause nodes Inline table/column comments are now TableCommentClause / ColumnCommentClause nodes; render them through format_table_comment_clause / format_column_comment_clause. Tests updated to build the clause nodes. --- .../backend/impl/clickhouse/mixins/ddl_table.py | 12 ++++++------ .../feature/backend/dialect/test_dialect_security.py | 7 ++++--- .../backend/test_clickhouse_column_definition.py | 3 ++- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_table.py index 85a775e..de64a5d 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_table.py @@ -96,9 +96,9 @@ def format_create_table_statement(self, expr: "CreateTableExpression") -> Tuple[ all_params.extend(storage_params) table_options = getattr(expr, "table_options", None) - if table_options is not None and getattr(table_options, "comment", None): - comment_sql, _ = self.format_table_comment(table_options.comment) - parts.append(comment_sql) + if table_options is not None and getattr(table_options, "comment", None) is not None: + comment_sql, _ = self.format_table_comment_clause(table_options.comment) + parts.append(comment_sql.strip()) if expr.partition is not None: partition_sql, partition_params = expr.partition.to_sql() @@ -189,9 +189,9 @@ def format_column_definition(self, col_def: "ColumnDefinition") -> Tuple[str, tu parts.append(f"TTL {ttl_sql}") params.extend(ttl_params) - if col_def.comment: - escaped_comment = self._escape_sql_string(col_def.comment) - parts.append(f"COMMENT '{escaped_comment}'") + if col_def.comment is not None: + comment_sql, _ = self.format_column_comment_clause(col_def.comment) + parts.append(comment_sql.strip()) return " ".join(parts), tuple(params) diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_dialect_security.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_dialect_security.py index f0d073e..80be920 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_dialect_security.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_dialect_security.py @@ -15,6 +15,7 @@ ColumnConstraint, ColumnConstraintType, ) +from rhosocial.activerecord.backend.expression import ColumnCommentClause, TableCommentClause from rhosocial.activerecord.backend.expression.types import VarCharType from rhosocial.activerecord.backend.impl.clickhouse.expression.json_table import ( ClickHouseJSONTableExpression, @@ -53,7 +54,7 @@ def test_clickhouse_format_column_definition_comment_string_escaping(dialect): dialect, "test_col", VarCharType(length=255, dialect=dialect), - comment="Comment with 'single quote'", + comment=ColumnCommentClause(dialect, "Comment with 'single quote'"), ) sql, params = dialect.format_column_definition(col_def) @@ -422,7 +423,7 @@ def test_create_table_comment_escaped(self, dialect): table="test_table", columns=[], table_options=CreateTableOptions( - dialect, comment="Table's comment with 'quotes'" + dialect, comment=TableCommentClause(dialect, "Table's comment with 'quotes'") ), ) @@ -443,7 +444,7 @@ def test_create_table_comment_with_backslash(self, dialect): dialect=dialect, table="test_table", columns=[], - table_options=CreateTableOptions(dialect, comment="Test\\value"), + table_options=CreateTableOptions(dialect, comment=TableCommentClause(dialect, "Test\\value")), ) sql, params = dialect.format_create_table_statement(expr) diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_column_definition.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_column_definition.py index f9a8e3f..19c23fe 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_column_definition.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_column_definition.py @@ -4,6 +4,7 @@ import pytest from rhosocial.activerecord.backend.expression import ColumnDefinition, FunctionCall +from rhosocial.activerecord.backend.expression import ColumnCommentClause from rhosocial.activerecord.backend.expression.types import IntegerType from rhosocial.activerecord.backend.impl.clickhouse.expression import ( ClickHouseColumnDefinition, @@ -47,7 +48,7 @@ def test_ttl(dialect): def test_generic_column_still_renders_on_clickhouse(dialect): - generic = ColumnDefinition(dialect, "x", IntegerType(dialect), comment="c") + generic = ColumnDefinition(dialect, "x", IntegerType(dialect), comment=ColumnCommentClause(dialect, "c")) sql, _ = generic.to_sql() assert sql == "`x` Int32 COMMENT 'c'" From 033766a1ca695f4394770081f44165c810bc9515 Mon Sep 17 00:00:00 2001 From: vistart Date: Wed, 23 Sep 2026 22:30:57 +0800 Subject: [PATCH 07/24] fix(ddl): declare supports_column_comment in ClickHouseTableSupport --- .../activerecord/backend/impl/clickhouse/protocols.py | 4 ++++ .../feature/backend/protocol/test_protocol_conformance.py | 3 +++ 2 files changed, 7 insertions(+) diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols.py b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols.py index f5e1832..f8d6177 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols.py @@ -335,6 +335,10 @@ def format_column_definition(self, col_def: Any) -> Tuple[str, tuple]: """Format a column definition with ClickHouse-specific syntax (AUTO_INCREMENT, etc.).""" ... + def supports_column_comment(self) -> bool: + """Whether an inline column COMMENT is supported (ClickHouse: yes).""" + ... + def format_table_constraint(self, t_const: Any) -> Tuple[str, tuple]: """Format a table-level constraint.""" ... diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/protocol/test_protocol_conformance.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/protocol/test_protocol_conformance.py index 1d167aa..e24f78a 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/protocol/test_protocol_conformance.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/protocol/test_protocol_conformance.py @@ -154,6 +154,9 @@ def test_implements_protocol(self, dialect, protocol): # decision (move to CLICKHOUSE_PROTOCOLS or revert). CLICKHOUSE_NOT_IMPLEMENTED = [ # --- Intentional non-support --- + # ClickHouse has no standalone COMMENT ON statement; inline table/column + # comments are rendered by CREATE TABLE instead. + dialect_protocols.CommentSupport, # ClickHouse dialect does not compose the generic DatabaseMixin. dialect_protocols.DatabaseSupport, # ClickHouse has no SQL/XML support. From 64093ede5a22e258c30dc73ea224e738d155c979 Mon Sep 17 00:00:00 2001 From: vistart Date: Thu, 24 Sep 2026 00:27:13 +0800 Subject: [PATCH 08/24] test: drop redundant __primary_key__ = "id" from fixtures Model base already defaults to "id"; fixtures should exercise the unspecified-PK default path. --- tests/providers/partition.py | 2 -- .../feature/basic/fixtures/models.py | 15 --------------- .../feature/basic/fixtures/models_py310.py | 14 -------------- .../feature/basic/fixtures/models_py311.py | 14 -------------- .../feature/basic/fixtures/models_py312.py | 14 -------------- 5 files changed, 59 deletions(-) diff --git a/tests/providers/partition.py b/tests/providers/partition.py index 737fd59..5e0b6aa 100644 --- a/tests/providers/partition.py +++ b/tests/providers/partition.py @@ -232,7 +232,6 @@ def _event_model(self, backend): class PartitionEvent(ActiveRecord): __table_name__ = table_name - __primary_key__ = "id" __backend__ = backend c: ClassVar[FieldProxy] = FieldProxy() @@ -249,7 +248,6 @@ def _async_event_model(self, backend): class AsyncPartitionEvent(AsyncActiveRecord): __table_name__ = table_name - __primary_key__ = "id" __backend__ = backend c: ClassVar[FieldProxy] = FieldProxy() diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/basic/fixtures/models.py b/tests/rhosocial/activerecord_clickhouse_test/feature/basic/fixtures/models.py index b8ab8ef..ba5e9f0 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/basic/fixtures/models.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/basic/fixtures/models.py @@ -313,8 +313,6 @@ def _do_from_database(self, value: str, target_type: Type, options: Optional[Dic class TypeAdapterTest(ActiveRecord): """Model for testing various type adapter scenarios.""" __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' - id: Optional[int] = None name: str @@ -332,7 +330,6 @@ class TypeAdapterTest(ActiveRecord): class AsyncTypeAdapterTest(AsyncActiveRecord): """Model for testing various type adapter scenarios.""" __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -354,7 +351,6 @@ class MappedUser(IntegerPKMixin, TimestampMixin, ActiveRecord): """User model with custom column name mappings for testing in basic feature.""" __table_name__ = "users" - __primary_key__ = "id" # Python field: user_id, Database column: id user_id: Annotated[Optional[int], UseColumn("id")] = None @@ -373,7 +369,6 @@ class AsyncMappedUser(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): """User model with custom column name mappings for testing in basic feature.""" __table_name__ = "users" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() # Python field: user_id, Database column: id @@ -394,7 +389,6 @@ class MappedPost(IntegerPKMixin, TimestampMixin, ActiveRecord): """Post model with custom column name mappings for testing in basic feature.""" __table_name__ = "posts" - __primary_key__ = "id" # Python field: post_id, Database column: id post_id: Annotated[Optional[int], UseColumn("id")] = None @@ -418,7 +412,6 @@ class AsyncMappedPost(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): """Post model with custom column name mappings for testing in basic feature.""" __table_name__ = "posts" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() # Python field: post_id, Database column: id @@ -444,7 +437,6 @@ class MappedComment(IntegerPKMixin, TimestampMixin, ActiveRecord): """Comment model with custom column name mappings for testing in basic feature.""" __table_name__ = "comments" - __primary_key__ = "id" # Python field: comment_id, Database column: id comment_id: Annotated[Optional[int], UseColumn("id")] = None @@ -469,7 +461,6 @@ class AsyncMappedComment(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): """Comment model with custom column name mappings for testing in basic feature.""" __table_name__ = "comments" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() # Python field: comment_id, Database column: id @@ -514,7 +505,6 @@ class ColumnMappingModel(ActiveRecord): including mapping the primary key attribute. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' # Primary key defaults to 'id'. item_id attribute maps to this 'id' column. item_id: Annotated[Optional[int], UseColumn("id")] = Field(default=None) @@ -528,7 +518,6 @@ class AsyncColumnMappingModel(AsyncActiveRecord): including mapping the primary key attribute. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() # Primary key defaults to 'id'. item_id attribute maps to this 'id' column. @@ -615,7 +604,6 @@ def _do_from_database(self, value: str, target_type: Type, options: Optional[Dic class MixedAnnotationModel(ActiveRecord): """A model with various combinations of annotations to test field mapping and adapters.""" __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" # Corrected from "item_id" # 1. Standard Python type name: str @@ -639,7 +627,6 @@ class MixedAnnotationModel(ActiveRecord): class AsyncMixedAnnotationModel(AsyncActiveRecord): """A model with various combinations of annotations to test field mapping and adapters.""" __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" # Corrected from "item_id" c: ClassVar[FieldProxy] = FieldProxy() # 1. Standard Python type @@ -737,7 +724,6 @@ class AsyncStoreInventory(CompositePKMixin, AsyncActiveRecord): class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): """Single-column auto-increment PK — backward compatibility control group.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -747,7 +733,6 @@ class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): class AsyncOrder(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): """Async variant of Order.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/basic/fixtures/models_py310.py b/tests/rhosocial/activerecord_clickhouse_test/feature/basic/fixtures/models_py310.py index a6c6df9..900034b 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/basic/fixtures/models_py310.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/basic/fixtures/models_py310.py @@ -338,7 +338,6 @@ class TypeAdapterTest(ActiveRecord): Python 3.10+ version using | syntax instead of Optional and Union. """ __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' id: int | None = None name: str @@ -360,7 +359,6 @@ class AsyncTypeAdapterTest(AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional and Union. """ __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -383,7 +381,6 @@ class MappedUser(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "users" - __primary_key__ = "id" # Python field: user_id, Database column: id user_id: Annotated[int | None, UseColumn("id")] = None @@ -404,7 +401,6 @@ class AsyncMappedUser(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "users" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() # Python field: user_id, Database column: id @@ -426,7 +422,6 @@ class MappedPost(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "posts" - __primary_key__ = "id" # Python field: post_id, Database column: id post_id: Annotated[int | None, UseColumn("id")] = None @@ -453,7 +448,6 @@ class AsyncMappedPost(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "posts" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() # Python field: post_id, Database column: id @@ -481,7 +475,6 @@ class MappedComment(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "comments" - __primary_key__ = "id" # Python field: comment_id, Database column: id comment_id: Annotated[int | None, UseColumn("id")] = None @@ -508,7 +501,6 @@ class AsyncMappedComment(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "comments" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() # Python field: comment_id, Database column: id @@ -551,7 +543,6 @@ class ColumnMappingModel(ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' # Primary key defaults to 'id'. item_id attribute maps to this 'id' column. item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) @@ -567,7 +558,6 @@ class AsyncColumnMappingModel(AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() # Primary key defaults to 'id'. item_id attribute maps to this 'id' column. @@ -647,7 +637,6 @@ class MixedAnnotationModel(ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" # 1. Standard Python type name: str @@ -674,7 +663,6 @@ class AsyncMixedAnnotationModel(AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() # 1. Standard Python type @@ -750,7 +738,6 @@ class AsyncStoreInventory(CompositePKMixin, AsyncActiveRecord): class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): """Single-column auto-increment PK -- backward compatibility control group.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -760,7 +747,6 @@ class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): class AsyncOrder(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): """Async variant of Order.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/basic/fixtures/models_py311.py b/tests/rhosocial/activerecord_clickhouse_test/feature/basic/fixtures/models_py311.py index f912576..c348093 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/basic/fixtures/models_py311.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/basic/fixtures/models_py311.py @@ -385,7 +385,6 @@ class TypeAdapterTest(ActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' id: int | None = None name: str @@ -404,7 +403,6 @@ class AsyncTypeAdapterTest(AsyncActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -424,7 +422,6 @@ class MappedUser(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.11+ version using | syntax and Self type. """ __table_name__ = "users" - __primary_key__ = "id" user_id: Annotated[int | None, UseColumn("id")] = None user_name: Annotated[str, UseColumn("username")] @@ -443,7 +440,6 @@ class AsyncMappedUser(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "users" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() user_id: Annotated[int | None, UseColumn("id")] = None @@ -458,7 +454,6 @@ class MappedPost(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "posts" - __primary_key__ = "id" post_id: Annotated[int | None, UseColumn("id")] = None author_id: Annotated[int, UseColumn("author")] @@ -474,7 +469,6 @@ class AsyncMappedPost(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "posts" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() post_id: Annotated[int | None, UseColumn("id")] = None @@ -491,7 +485,6 @@ class MappedComment(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "comments" - __primary_key__ = "id" comment_id: Annotated[int | None, UseColumn("id")] = None post_id: Annotated[int, UseColumn("post_ref")] @@ -507,7 +500,6 @@ class AsyncMappedComment(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "comments" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() comment_id: Annotated[int | None, UseColumn("id")] = None @@ -538,7 +530,6 @@ class ColumnMappingModel(ActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) name: str @@ -552,7 +543,6 @@ class AsyncColumnMappingModel(AsyncActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) @@ -631,7 +621,6 @@ class MixedAnnotationModel(ActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" name: str item_id: Annotated[int, UseColumn("id")] @@ -656,7 +645,6 @@ class AsyncMixedAnnotationModel(AsyncActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() name: str @@ -721,7 +709,6 @@ class AsyncStoreInventory(CompositePKMixin, AsyncActiveRecord): class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): """Single-column auto-increment PK -- backward compatibility control group.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -731,7 +718,6 @@ class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): class AsyncOrder(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): """Async variant of Order.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/basic/fixtures/models_py312.py b/tests/rhosocial/activerecord_clickhouse_test/feature/basic/fixtures/models_py312.py index c017805..d0e8ce2 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/basic/fixtures/models_py312.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/basic/fixtures/models_py312.py @@ -372,7 +372,6 @@ class TypeAdapterTest(ActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' id: int | None = None name: str @@ -391,7 +390,6 @@ class AsyncTypeAdapterTest(AsyncActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -411,7 +409,6 @@ class MappedUser(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ __table_name__ = "users" - __primary_key__ = "id" user_id: Annotated[int | None, UseColumn("id")] = None user_name: Annotated[str, UseColumn("username")] @@ -430,7 +427,6 @@ class AsyncMappedUser(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = "users" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() user_id: Annotated[int | None, UseColumn("id")] = None @@ -445,7 +441,6 @@ class MappedPost(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = "posts" - __primary_key__ = "id" post_id: Annotated[int | None, UseColumn("id")] = None author_id: Annotated[int, UseColumn("author")] @@ -461,7 +456,6 @@ class AsyncMappedPost(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = "posts" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() post_id: Annotated[int | None, UseColumn("id")] = None @@ -478,7 +472,6 @@ class MappedComment(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = "comments" - __primary_key__ = "id" comment_id: Annotated[int | None, UseColumn("id")] = None post_id: Annotated[int, UseColumn("post_ref")] @@ -494,7 +487,6 @@ class AsyncMappedComment(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = "comments" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() comment_id: Annotated[int | None, UseColumn("id")] = None @@ -525,7 +517,6 @@ class ColumnMappingModel(ActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) name: str @@ -539,7 +530,6 @@ class AsyncColumnMappingModel(AsyncActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) @@ -618,7 +608,6 @@ class MixedAnnotationModel(ActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" name: str item_id: Annotated[int, UseColumn("id")] @@ -640,7 +629,6 @@ class AsyncMixedAnnotationModel(AsyncActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() name: str @@ -755,7 +743,6 @@ class AsyncStoreInventory(CompositePKMixin, AsyncActiveRecord): class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): """Single-column auto-increment PK -- backward compatibility control group.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -765,7 +752,6 @@ class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): class AsyncOrder(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): """Async variant of Order.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None From 2c74166abf6b4c1f7b8c48aea0af1858ca5bd98b Mon Sep 17 00:00:00 2001 From: vistart Date: Thu, 24 Sep 2026 01:14:48 +0800 Subject: [PATCH 09/24] fix(ddl): stop silently dropping TABLESPACE / INHERITS --- .../backend/impl/clickhouse/mixins/ddl_table.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_table.py index de64a5d..aa24400 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_table.py @@ -52,6 +52,18 @@ def supports_charset_option(self) -> bool: def format_create_table_statement(self, expr: "CreateTableExpression") -> Tuple[str, tuple]: """Format CREATE TABLE statement for ClickHouse.""" + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + if expr.tablespace: + raise UnsupportedFeatureError( + self.name, "TABLESPACE", + "ClickHouse does not support table tablespaces.", + ) + if expr.inherits: + raise UnsupportedFeatureError( + self.name, "table INHERITS", + "ClickHouse does not support table inheritance.", + ) all_params: List[Any] = [] options_part = "" From 6a4f1138c7e733573a9931dee89a6aa58b5ae27f Mon Sep 17 00:00:00 2001 From: vistart Date: Thu, 24 Sep 2026 09:41:58 +0800 Subject: [PATCH 10/24] feat(clickhouse): add type domain negative contract --- .../backend/impl/clickhouse/dialect.py | 17 +- .../backend/impl/clickhouse/mixins/types.py | 33 ++- .../backend/ddl/test_type_domain_negative.py | 240 ++++++++++++++++++ .../protocol/test_protocol_conformance.py | 13 +- 4 files changed, 285 insertions(+), 18 deletions(-) create mode 100644 tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/test_type_domain_negative.py diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/dialect.py b/src/rhosocial/activerecord/backend/impl/clickhouse/dialect.py index b89dbee..e7fad52 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/dialect.py @@ -6,7 +6,7 @@ based on the ClickHouse version provided at initialization. """ -from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING +from typing import Optional, Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.base import SQLDialectBase from rhosocial.activerecord.backend.dialect.protocols import ( @@ -36,7 +36,9 @@ TruncateSupport, TransactionControlSupport, SQLFunctionSupport, - DDLTypeSupport, + DataTypeSupport, + UserDefinedTypeSupport, + DomainSupport, ) from rhosocial.activerecord.backend.dialect.mixins import ( CollationMixin, @@ -72,6 +74,8 @@ DQLMixin, DMLMixin, DDLColumnMixin, + UserDefinedTypeMixin, + DomainMixin, TransactionControlMixin, SetOperationMixin, ) @@ -146,6 +150,9 @@ from .reserved_words import CLICKHOUSE_RESERVED_WORDS from .show.dialect import ClickHouseShowDialectMixin +if TYPE_CHECKING: + from rhosocial.activerecord.backend.expression.statements.ddl_alter import ModifyColumn + class ClickHouseDialect( SQLDialectBase, @@ -205,6 +212,8 @@ class ClickHouseDialect( ClickHouseModifyColumnMixin, ClickHouseJsonDualityViewMixin, ClickHouseTypeSupportMixin, + UserDefinedTypeMixin, + DomainMixin, ClickHouseOptimizerHintMixin, ClickHouseTableStatementMixin, ClickHouseMaintenanceMixin, @@ -273,7 +282,9 @@ class ClickHouseDialect( ClickHouseLoadXMLSupport, ClickHouseAdminCommandSupport, SQLFunctionSupport, - DDLTypeSupport, + DataTypeSupport, + UserDefinedTypeSupport, + DomainSupport, ): """ ClickHouse dialect implementation that adapts to the ClickHouse version. diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/types.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/types.py index 1e6c9e8..30f9750 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/types.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/types.py @@ -6,8 +6,8 @@ import re from typing import Dict, Tuple -from rhosocial.activerecord.backend.dialect.mixins.ddl_type import DDLTypeMixin -from rhosocial.activerecord.backend.dialect.protocols import DDLTypeSupport +from rhosocial.activerecord.backend.dialect.mixins.data_type import DataTypeMixin +from rhosocial.activerecord.backend.dialect.protocols import DataTypeSupport from rhosocial.activerecord.backend.expression.types import ( ArrayType, BigIntType, @@ -77,21 +77,21 @@ ) -class ClickHouseTypeSupportMixin(DDLTypeMixin, DDLTypeSupport): +class ClickHouseTypeSupportMixin(DataTypeMixin, DataTypeSupport): """ClickHouse DataType formatting and parsing. - Implements ``DDLTypeSupport`` so the dialect can render ``DataType`` + Implements ``DataTypeSupport`` so the dialect can render ``DataType`` expressions to SQL strings and parse raw SQL type strings back into ``DataType`` instances. Formatting dispatches by the type instance's ``name`` through the naming-convention ``format_data_type_`` methods (see - ``DDLTypeMixin``). ClickHouse-specific types carry ``clickhouse_``-prefixed + ``DataTypeMixin``). ClickHouse-specific types carry ``clickhouse_``-prefixed names; core types render their real ClickHouse SQL. """ # ------------------------------------------------------------------ - # DDLTypeSupport — formatting + # DataTypeSupport — formatting # ------------------------------------------------------------------ # --- ClickHouse-specific type formatters (dispatch key = type name) --- @@ -208,7 +208,10 @@ def format_data_type_clickhouse_low_cardinality(self, data_type: ClickHouseLowCa def format_data_type_clickhouse_json(self, data_type: ClickHouseJSONType) -> Tuple[str, tuple]: return "JSON", () - def format_data_type_clickhouse_aggregate_function(self, data_type: ClickHouseAggregateFunctionType) -> Tuple[str, tuple]: + def format_data_type_clickhouse_aggregate_function( + self, + data_type: ClickHouseAggregateFunctionType, + ) -> Tuple[str, tuple]: args = ", ".join(self.format_data_type(t)[0] for t in data_type.arg_types) return f"AggregateFunction({data_type.function_name}, {args})", () @@ -243,7 +246,10 @@ def format_data_type_clickhouse_multipoint(self, data_type: ClickHouseMultiPoint return f"MULTIPOINT SRID {data_type.srid}", () return "MULTIPOINT", () - def format_data_type_clickhouse_multilinestring(self, data_type: ClickHouseMultiLineStringType) -> Tuple[str, tuple]: + def format_data_type_clickhouse_multilinestring( + self, + data_type: ClickHouseMultiLineStringType, + ) -> Tuple[str, tuple]: if data_type.srid is not None: return f"MULTILINESTRING SRID {data_type.srid}", () return "MULTILINESTRING", () @@ -253,7 +259,10 @@ def format_data_type_clickhouse_multipolygon(self, data_type: ClickHouseMultiPol return f"MULTIPOLYGON SRID {data_type.srid}", () return "MULTIPOLYGON", () - def format_data_type_clickhouse_geometrycollection(self, data_type: ClickHouseGeometryCollectionType) -> Tuple[str, tuple]: + def format_data_type_clickhouse_geometrycollection( + self, + data_type: ClickHouseGeometryCollectionType, + ) -> Tuple[str, tuple]: if data_type.srid is not None: return f"GEOMETRYCOLLECTION SRID {data_type.srid}", () return "GEOMETRYCOLLECTION", () @@ -262,7 +271,7 @@ def format_data_type_clickhouse_vector(self, data_type: ClickHouseVectorType) -> return f"VECTOR({data_type.dim})", () # ------------------------------------------------------------------ - # DDLTypeSupport — supports_data_type_* (1:1 with format_data_type_*) + # DataTypeSupport — supports_data_type_* (1:1 with format_data_type_*) # ------------------------------------------------------------------ def supports_data_type_clickhouse_int8(self) -> bool: @@ -454,7 +463,7 @@ def supports_data_type_array(self) -> bool: return True # ------------------------------------------------------------------ - # DDLTypeSupport — suggested_data_types() + # DataTypeSupport — suggested_data_types() # ------------------------------------------------------------------ def suggested_data_types(self) -> Dict[str, type]: @@ -532,7 +541,7 @@ def format_data_type_array(self, data_type: ArrayType) -> Tuple[str, tuple]: return f"Array({inner_sql})", inner_params # ------------------------------------------------------------------ - # DDLTypeSupport — parsing + # DataTypeSupport — parsing # ------------------------------------------------------------------ _CLICKHOUSE_INTEGER_TYPES = re.compile( diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/test_type_domain_negative.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/test_type_domain_negative.py new file mode 100644 index 0000000..5a0e74d --- /dev/null +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/test_type_domain_negative.py @@ -0,0 +1,240 @@ +# tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/test_type_domain_negative.py +"""Negative TYPE and DOMAIN DDL contracts for ClickHouse.""" + +from __future__ import annotations + +import pytest + +from rhosocial.activerecord.backend.dialect import ( + DataTypeMixin, + DataTypeSupport, + DomainMixin, + DomainSupport, + UserDefinedTypeMixin, + UserDefinedTypeSupport, +) +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression import Literal +from rhosocial.activerecord.backend.expression.statements import ( + AlterDomainExpression, + AlterTypeExpression, + CreateDomainExpression, + CreateTypeExpression, + DomainCheckConstraint, + DomainNullability, + DomainValueExpression, + DropDomainDefaultAction, + DropDomainExpression, + DropTypeExpression, +) +from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + TypeAlterAction, + TypeDefinition, +) +from rhosocial.activerecord.backend.expression.types import DataType, IntegerType +from rhosocial.activerecord.backend.impl.clickhouse.dialect import ClickHouseDialect +from rhosocial.activerecord.backend.impl.clickhouse.expression.types import ( + ClickHouseEnum8Type, + ClickHouseEnum16Type, +) +from rhosocial.activerecord.backend.impl.clickhouse.mixins.types import ( + ClickHouseTypeSupportMixin, +) +from rhosocial.activerecord.backend.impl.clickhouse.protocols import ( + ClickHouseSetTypeSupport, +) + + +class _NegativeTypeDefinition(TypeDefinition): + @property + def definition_kind(self) -> str: + return "negative" + + +class _NegativeTypeAlterAction(TypeAlterAction): + @property + def action_kind(self) -> str: + return "negative" + + +TYPE_DOMAIN_SUPPORT_METHODS = ( + "supports_type_objects", + "supports_create_type", + "supports_alter_type", + "supports_drop_type", + "supports_create_type_if_not_exists", + "supports_create_type_or_replace", + "supports_alter_type_if_exists", + "supports_drop_type_if_exists", + "supports_multiple_type_alter_actions", + "supports_domains", + "supports_create_domain", + "supports_alter_domain", + "supports_drop_domain", + "supports_domain_default", + "supports_domain_checks", + "supports_named_domain_checks", + "supports_multiple_domain_checks", + "supports_domain_collation", + "supports_multiple_domain_alter_actions", + "supports_drop_domain_if_exists", + "supports_drop_domain_cascade", + "supports_drop_domain_restrict", + "supports_unnamed_domain_check_drop", +) + +TYPE_FORMATTERS = ( + "format_create_type_statement", + "format_alter_type_statement", + "format_drop_type_statement", + "format_type_definition", + "format_type_alter_action", +) + +DOMAIN_FORMATTERS = ( + "format_create_domain_statement", + "format_alter_domain_statement", + "format_drop_domain_statement", + "format_domain_value_expression", + "format_domain_check_constraint", + "format_domain_alter_action", +) + + +@pytest.fixture +def dialect() -> ClickHouseDialect: + return ClickHouseDialect(version=(26, 7, 1)) + + +def test_type_and_domain_protocols_and_mixins_are_composed( + dialect: ClickHouseDialect, +) -> None: + assert isinstance(dialect, DataTypeSupport) + assert isinstance(dialect, UserDefinedTypeSupport) + assert isinstance(dialect, DomainSupport) + assert isinstance(dialect, UserDefinedTypeMixin) + assert isinstance(dialect, DomainMixin) + + mro = ClickHouseDialect.__mro__ + for mixin, protocol in ( + (UserDefinedTypeMixin, UserDefinedTypeSupport), + (DomainMixin, DomainSupport), + ): + assert mixin in mro + assert protocol in mro + assert mro.index(mixin) < mro.index(protocol) + + assert issubclass(ClickHouseTypeSupportMixin, DataTypeMixin) + assert issubclass(ClickHouseTypeSupportMixin, DataTypeSupport) + assert mro.index(ClickHouseTypeSupportMixin) < mro.index(DataTypeSupport) + assert not issubclass(ClickHouseTypeSupportMixin, UserDefinedTypeMixin) + assert not issubclass(ClickHouseTypeSupportMixin, DomainMixin) + + +def test_core_type_and_domain_formatters_own_the_mro() -> None: + for method_name in TYPE_FORMATTERS: + assert getattr(ClickHouseDialect, method_name) is getattr( + UserDefinedTypeMixin, + method_name, + ) + for method_name in DOMAIN_FORMATTERS: + assert getattr(ClickHouseDialect, method_name) is getattr( + DomainMixin, + method_name, + ) + + +def test_schema_level_type_and_domain_support_is_false( + dialect: ClickHouseDialect, +) -> None: + for method_name in TYPE_DOMAIN_SUPPORT_METHODS: + assert getattr(dialect, method_name)() is False, method_name + + for nullability in DomainNullability: + assert dialect.supports_domain_nullability(nullability) is False + + assert dialect.supported_type_definitions() == () + assert dialect.supports_type_definition(_NegativeTypeDefinition) is False + assert dialect.supports_type_alter_action(_NegativeTypeAlterAction) is False + assert dialect.supports_alter_domain_action(DropDomainDefaultAction) is False + + +def test_type_formatters_and_expressions_fail_fast(dialect: ClickHouseDialect) -> None: + definition = _NegativeTypeDefinition(dialect) + action = _NegativeTypeAlterAction(dialect) + expressions = ( + CreateTypeExpression( + dialect, + "status", + definition, + if_not_exists=True, + ), + AlterTypeExpression( + dialect, + "status", + [action], + if_exists=True, + ), + DropTypeExpression(dialect, "status", if_exists=True), + definition, + action, + ) + + for method_name, expression in zip(TYPE_FORMATTERS, expressions, strict=True): + with pytest.raises(UnsupportedFeatureError): + getattr(dialect, method_name)(expression) + with pytest.raises(UnsupportedFeatureError): + expression.to_sql() + + +def test_domain_formatters_and_expressions_fail_fast(dialect: ClickHouseDialect) -> None: + condition = DomainValueExpression(dialect) > Literal( + dialect, + 0, + inline_literals=True, + ) + check = DomainCheckConstraint(dialect, condition, name="positive") + action = DropDomainDefaultAction(dialect) + expressions = ( + CreateDomainExpression( + dialect, + "positive", + IntegerType(dialect), + checks=[check], + collation="en_US", + ), + AlterDomainExpression(dialect, "positive", [action]), + DropDomainExpression(dialect, "positive"), + DomainValueExpression(dialect), + check, + action, + ) + + for method_name, expression in zip(DOMAIN_FORMATTERS, expressions, strict=True): + with pytest.raises(UnsupportedFeatureError): + getattr(dialect, method_name)(expression) + with pytest.raises(UnsupportedFeatureError): + expression.to_sql() + + +def test_clickhouse_enum_types_remain_data_types(dialect: ClickHouseDialect) -> None: + enum8 = ClickHouseEnum8Type(dialect, values=[("active", 1), ("inactive", 0)]) + enum16 = ClickHouseEnum16Type(dialect, values=[("active", 1), ("inactive", 0)]) + + for data_type, support_name, sql in ( + (enum8, "supports_data_type_clickhouse_enum8", "Enum8"), + (enum16, "supports_data_type_clickhouse_enum16", "Enum16"), + ): + assert isinstance(data_type, DataType) + assert not isinstance(data_type, TypeDefinition) + assert getattr(dialect, support_name)() is True + rendered, params = data_type.to_sql() + assert rendered.startswith(sql) + assert params == () + + +def test_set_protocol_is_not_used_as_a_type_domain_contract( + dialect: ClickHouseDialect, +) -> None: + assert ClickHouseSetTypeSupport not in ClickHouseDialect.__mro__ + assert not isinstance(dialect, ClickHouseSetTypeSupport) diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/protocol/test_protocol_conformance.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/protocol/test_protocol_conformance.py index e24f78a..199f2a7 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/protocol/test_protocol_conformance.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/protocol/test_protocol_conformance.py @@ -96,7 +96,9 @@ def get_own_protocol_methods(proto: type) -> set: dialect_protocols.SQLFunctionSupport, # Generic protocols ClickHouse also satisfies (previously omitted from this list). dialect_protocols.AlterTableModifierSupport, - dialect_protocols.DDLTypeSupport, + dialect_protocols.DataTypeSupport, + dialect_protocols.UserDefinedTypeSupport, + dialect_protocols.DomainSupport, dialect_protocols.SetOperationSupport, dialect_protocols.TriggerSupport, dialect_protocols.TruncateSupport, @@ -190,8 +192,13 @@ def get_all_generic_protocols() -> dict: discovered = {} for name, obj in inspect.getmembers(dialect_protocols, inspect.isclass): - if Protocol in getattr(obj, "__mro__", []) and name.endswith("Support"): - discovered[name] = obj + if Protocol not in getattr(obj, "__mro__", []) or not name.endswith("Support"): + continue + if name == "DDLTypeSupport": + assert obj is dialect_protocols.DataTypeSupport + continue + assert name == obj.__name__, f"unexpected protocol alias: {name}" + discovered[name] = obj return discovered From 20fc085743c69f6650abc3aae0cbf98f4479d1bb Mon Sep 17 00:00:00 2001 From: vistart Date: Thu, 24 Sep 2026 10:20:49 +0800 Subject: [PATCH 11/24] test(ddl): cover table inheritance and tablespace --- .../test_clickhouse_ddl_improvements.py | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_ddl_improvements.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_ddl_improvements.py index bfb70c2..5603d5f 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_ddl_improvements.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_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, @@ -67,3 +69,51 @@ def test_drop_schema_not_supported(self): """ClickHouse does not support DROP SCHEMA.""" dialect = ClickHouseDialect() assert dialect.supports_drop_schema() is False + + +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 TestClickHouseTableDeclarationGating: + def test_table_declaration_defaults_are_absent(self): + class Plain(ActiveRecord): + __table_name__ = "plain_table_defaults" + + id: int + + expression = TableDDLDeriver(Plain, ClickHouseDialect()).create_table() + assert expression.inherits == [] + assert expression.tablespace is None + + def test_table_inherits_is_propagated_and_rejected(self): + dialect = ClickHouseDialect(version=(26, 7, 3)) + 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 = ClickHouseDialect(version=(26, 7, 3)) + 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 227f2bdff806b0c2d5e383c47898bd2d7bd525fa Mon Sep 17 00:00:00 2001 From: vistart Date: Fri, 25 Sep 2026 18:11:20 +0800 Subject: [PATCH 12/24] refactor(clickhouse): use direct DDL expressions --- .../impl/clickhouse/expression/column.py | 2 +- .../test_clickhouse_ddl_improvements.py | 69 +++++++++---------- 2 files changed, 34 insertions(+), 37 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/column.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/column.py index 5b72362..8a52d55 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/column.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/column.py @@ -20,7 +20,7 @@ from rhosocial.activerecord.backend.expression.bases import BaseExpression from rhosocial.activerecord.backend.expression.statements import ColumnDefinition -from rhosocial.activerecord.base.ddl.options import ColumnOptions +from rhosocial.activerecord.base import ColumnOptions if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.dialect import SQLDialectBase diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_ddl_improvements.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_ddl_improvements.py index 5603d5f..890d71d 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_ddl_improvements.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_ddl_improvements.py @@ -1,20 +1,23 @@ # tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_ddl_improvements.py """Tests for ClickHouse DDL improvements: capability gating, UnsupportedFeatureError.""" import pytest -from unittest.mock import patch, PropertyMock +from unittest.mock import patch -from rhosocial.activerecord.base.ddl import TableDDLDeriver -from rhosocial.activerecord.model import ActiveRecord +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression import ( Column, - TableExpression, - QueryExpression, + CreateTableExpression, CreateViewExpression, - DropViewExpression, + QueryExpression, + TableExpression, +) +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ViewCheckOption, + ViewOptions, ) -from rhosocial.activerecord.backend.expression.statements import ViewOptions, ViewCheckOption +from rhosocial.activerecord.backend.expression.types import IntegerType from rhosocial.activerecord.backend.impl.clickhouse.dialect import ClickHouseDialect -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError class TestClickHouseViewCapabilityGating: @@ -71,41 +74,30 @@ def test_drop_schema_not_supported(self): assert dialect.supports_drop_schema() is False -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 TestClickHouseTableDeclarationGating: def test_table_declaration_defaults_are_absent(self): - class Plain(ActiveRecord): - __table_name__ = "plain_table_defaults" - - id: int - - expression = TableDDLDeriver(Plain, ClickHouseDialect()).create_table() + dialect = ClickHouseDialect(version=(26, 7, 3)) + 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 = ClickHouseDialect(version=(26, 7, 3)) 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() @@ -113,7 +105,12 @@ def test_table_inherits_is_propagated_and_rejected(self): def test_table_tablespace_is_propagated_and_rejected(self): dialect = ClickHouseDialect(version=(26, 7, 3)) 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 0fd1afd03e54bcec226fbebdb676aee31986e697 Mon Sep 17 00:00:00 2001 From: vistart Date: Fri, 25 Sep 2026 18:43:01 +0800 Subject: [PATCH 13/24] docs: document repository search scope --- CLAUDE.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 39f5b7b..3b9e61d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,3 +86,9 @@ are bridged under `tests/rhosocial/activerecord_clickhouse_test/feature/`. ## 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 934617207b112af65991db561bcc0f1acd9f8246 Mon Sep 17 00:00:00 2001 From: vistart Date: Fri, 25 Sep 2026 18:55:45 +0800 Subject: [PATCH 14/24] fix(protocol): declare partition support capabilities --- .../backend/impl/clickhouse/protocols.py | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols.py b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols.py index f8d6177..4fa9ec7 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols.py @@ -10,10 +10,10 @@ the ClickHouse-specific protocol - isinstance checks for the generic protocol will still work. """ -from typing import Protocol, runtime_checkable, Tuple, Any, List, TYPE_CHECKING +from typing import Protocol, runtime_checkable, Tuple, Any, TYPE_CHECKING if TYPE_CHECKING: - from rhosocial.activerecord.backend.expression.statements import OnConflictClause + from rhosocial.activerecord.backend.expression.statements import InsertExpression, OnConflictClause from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( ModifyColumn, ChangeColumn, @@ -394,6 +394,30 @@ def supports_partition_value_maxvalue(self) -> bool: """Whether MAXVALUE partition boundary token is supported.""" ... + def supports_add_partition(self) -> bool: + """Whether ADD PARTITION is supported.""" + ... + + def supports_drop_partition(self) -> bool: + """Whether DROP PARTITION is supported.""" + ... + + def supports_truncate_partition(self) -> bool: + """Whether TRUNCATE PARTITION is supported.""" + ... + + def supports_reorganize_partition(self) -> bool: + """Whether REORGANIZE PARTITION is supported.""" + ... + + def supports_attach_partition(self) -> bool: + """Whether ATTACH PARTITION is supported.""" + ... + + def supports_detach_partition(self) -> bool: + """Whether DETACH PARTITION is supported.""" + ... + def supports_remove_partitioning(self) -> bool: """Whether ALTER TABLE ... REMOVE PARTITIONING is supported.""" ... From 8741410e5f04c0d393e716e9929242a760d03e0d Mon Sep 17 00:00:00 2001 From: vistart Date: Fri, 25 Sep 2026 19:15:10 +0800 Subject: [PATCH 15/24] fix(clickhouse): make generated IDs process-aware --- .../backend/impl/clickhouse/id_generator.py | 3 ++- .../feature/backend/test_id_generator.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_id_generator.py diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/id_generator.py b/src/rhosocial/activerecord/backend/impl/clickhouse/id_generator.py index bdd5591..2af7a16 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/id_generator.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/id_generator.py @@ -13,6 +13,7 @@ - 12 bits: per-millisecond sequence """ +import os import threading import time from typing import Optional @@ -34,7 +35,7 @@ class SnowflakeIDGenerator: def __init__(self, machine_id: Optional[int] = None) -> None: if machine_id is None: - machine_id = int(hash(threading.current_thread().name)) % (self._MACHINE_MAX + 1) + machine_id = (os.getpid() ^ threading.get_ident()) % (self._MACHINE_MAX + 1) if not 0 <= machine_id <= self._MACHINE_MAX: raise ValueError(f"machine_id out of range [0, {self._MACHINE_MAX}]") self._machine_id: int = machine_id diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_id_generator.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_id_generator.py new file mode 100644 index 0000000..16b1f4c --- /dev/null +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_id_generator.py @@ -0,0 +1,18 @@ +# tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_id_generator.py +import multiprocessing + +from rhosocial.activerecord.backend.impl.clickhouse.id_generator import SnowflakeIDGenerator + + +def _generate_ids_in_process(count: int) -> list[int]: + generator = SnowflakeIDGenerator() + return [generator.next_id() for _ in range(count)] + + +def test_default_generator_ids_are_unique_across_processes(): + context = multiprocessing.get_context("spawn") + with context.Pool(processes=4) as pool: + batches = pool.map(_generate_ids_in_process, [5] * 4) + + ids = [identifier for batch in batches for identifier in batch] + assert len(set(ids)) == len(ids) From 777f1c111db2111b28483e78e852b9781d2657cd Mon Sep 17 00:00:00 2001 From: vistart Date: Sat, 26 Sep 2026 01:24:24 +0800 Subject: [PATCH 16/24] test(ddl): cover DDLSource collection --- .../backend/ddl/test_ddl_source_mixin.py | 559 ++++++++++++++++++ 1 file changed, 559 insertions(+) create mode 100644 tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/test_ddl_source_mixin.py diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/test_ddl_source_mixin.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/test_ddl_source_mixin.py new file mode 100644 index 0000000..4572bbe --- /dev/null +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/test_ddl_source_mixin.py @@ -0,0 +1,559 @@ +# tests/rhosocial/activerecord_clickhouse_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, + CreateTableExpression, + CreateTableOptions, + IndexDefinition, + StorageOptionsExpression, + TableConstraint, + TableConstraintType, +) +from rhosocial.activerecord.backend.expression.statements.ddl_partition import ( + PartitionClause, + PartitionStrategy, +) +from rhosocial.activerecord.backend.impl.clickhouse.dialect import ClickHouseDialect +from rhosocial.activerecord.backend.impl.clickhouse.expression import ( + ClickHouseColumnDefinition, + ClickHouseColumnOptions, + ClickHouseIndexDefinition, + ClickHouseInt64Type, + ClickHouseStringType, +) +from rhosocial.activerecord.base import ( + CollationAttribute, + DDLAnnotationHandler, + DDLSource, + IdentityAttribute, + UseColumn, + UseColumnAttributes, + UseComment, + UseConstraint, + UseGeneratedColumn, + UseIndex, + UseSqlType, +) +from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord + +NATIVE_TYPES = (ClickHouseStringType(), ClickHouseInt64Type()) +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="minmax", + 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="set") +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) +CLICKHOUSE_OPTIONS = ( + ClickHouseColumnOptions(codec=("ZSTD",)), + ClickHouseColumnOptions(codec=("LZ4",)), +) + +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 = ClickHouseIndexDefinition( + None, + name="idx_table_physical", + columns=["physical_value"], + type="minmax", + granularity=4, +) +TABLE_INDEX_TWO = IndexDefinition( + None, + name="idx_table_quantity", + columns=["quantity"], +) +TABLE_INDEXES = [TABLE_INDEX_ONE, TABLE_INDEX_TWO] +TABLE_OPTIONS = ( + CreateTableOptions(None, or_replace=True), + CreateTableOptions(None), +) +STORAGE_OPTIONS = StorageOptionsExpression(None, {"engine": "MergeTree"}) +TABLE_PARTITION = PartitionClause( + None, + PartitionStrategy.RANGE, + [Column(None, "physical_value")], +) +TABLE_INHERITS = ["parent_a", "parent_b"] +TABLE_TABLESPACE = "ts_data" +COMPOSITE_CONSTRAINT = TableConstraint( + None, + TableConstraintType.CHECK, + name="ck_composite", + columns=["tenant_id", "order_id"], +) + + +class ClickHouseOptionsHandler(DDLAnnotationHandler): + annotation_types = (ClickHouseColumnOptions,) + + @classmethod + def apply(cls, new_class, field_name, annotation, metadata): + metadata.add_column_options(annotation) + + +class ClickHouseOptionsMixin: + _feature_handlers = [ClickHouseOptionsHandler] + + +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( + ClickHouseOptionsMixin, + 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, + CLICKHOUSE_OPTIONS[0], + CLICKHOUSE_OPTIONS[1], + ] = None + quantity: Annotated[int, UseConstraint(ColumnConstraintType.DEFAULT, default_value=7)] = 1 + + __table_constraints__ = TABLE_CONSTRAINTS + __table_indexes__ = TABLE_INDEXES + + +class AsyncDeclarationModel( + ClickHouseOptionsMixin, + 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, + CLICKHOUSE_OPTIONS[0], + CLICKHOUSE_OPTIONS[1], + ] = 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( + ClickHouseOptionsMixin, + 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( + ClickHouseOptionsMixin, + TableDeclarationOverrides, + AsyncActiveRecord, +): + __table_name__ = "ddl_source_composite_async" + __primary_key__ = ("tenant_id", "order_id") + + tenant_ref: Annotated[int, UseColumn("tenant_id")] + order_ref: Annotated[int, UseColumn("order_id")] + + __table_constraints__ = [COMPOSITE_CONSTRAINT] + __table_indexes__ = TABLE_INDEXES + + +MODEL_CLASSES = (DeclarationModel, AsyncDeclarationModel) +COMPOSITE_MODEL_CLASSES = (CompositeModel, AsyncCompositeModel) +PLAIN_MODEL_CLASSES = (PlainModel, AsyncPlainModel) + + +def _assert_declaration_model(model): + assert isinstance(model, DDLSource) + assert model.table_name().startswith("ddl_source_declarations") + assert model.schema_name() == "analytics" + assert model.primary_key_columns() == ("record_id",) + assert model.primary_key_field() == "id" + assert model.is_composite_pk() is False + assert model.ddl_field_names() == ("id", "value", "quantity") + assert model.is_derived_field("value") is False + assert model.field_python_type("value") is str + assert model.field_is_optional("value") is True + assert model.field_python_type("quantity") is int + assert model.field_is_optional("quantity") is False + assert model.column_name("id") == "record_id" + assert model.columns_name() == { + "id": "record_id", + "value": "physical_value", + "quantity": "quantity", + } + + metadata = model.ddl_field_metadata("value") + assert metadata is model.__table_ddl_fields__["value"] + expected_annotations = ( + VALUE_COLUMN, + SQL_TYPE, + DEFAULT_CONSTRAINT, + UNIQUE_CONSTRAINT, + CHECK_CONSTRAINT, + FIRST_INDEX, + SECOND_INDEX, + VALUE_ATTRIBUTES_MARKER, + VALUE_COMMENT, + VALUE_GENERATED, + CLICKHOUSE_OPTIONS[0], + CLICKHOUSE_OPTIONS[1], + ) + assert metadata.annotations == expected_annotations + assert model.column_type("value") is SQL_TYPE + assert model.column_type("value").data_types == NATIVE_TYPES + assert all( + actual is expected + for actual, expected in zip(model.column_type("value").data_types, NATIVE_TYPES, strict=True) + ) + + value_constraints = model.column_constraints("value") + assert [item.constraint_type for item in value_constraints] == [ + ColumnConstraintType.DEFAULT, + ColumnConstraintType.UNIQUE, + ColumnConstraintType.CHECK, + ] + assert value_constraints[0] is DEFAULT_CONSTRAINT.constraint + assert value_constraints[1] is UNIQUE_CONSTRAINT.constraint + assert value_constraints[2] is CHECK_CONSTRAINT.constraint + assert value_constraints[2].check_condition is CHECK_CONDITION + + value_indexes = model.column_indexes("value") + assert [item.name for item in value_indexes] == [ + "idx_value_first", + "idx_value_second", + ] + assert value_indexes[0].columns == ["physical_value"] + assert value_indexes[0].unique is True + assert value_indexes[0].type == "minmax" + assert value_indexes[0].partial_condition is FIRST_INDEX_CONDITION + assert value_indexes[0].include_columns is FIRST_INCLUDE_COLUMNS + assert value_indexes[0].if_not_exists is True + assert value_indexes[0].tablespace == "ts_index" + assert value_indexes[0].concurrent is True + assert value_indexes[1].columns == ["physical_value"] + assert value_indexes[1].unique is False + + assert model.column_attributes("value") == list(VALUE_ATTRIBUTES) + assert all( + actual is expected + for actual, expected in zip(model.column_attributes("value"), VALUE_ATTRIBUTES, strict=True) + ) + assert model.column_comment("value") == "physical value" + assert model.generated_column("value") is GENERATED_EXPRESSION + options = model.column_options("value") + assert isinstance(options, list) + assert [option.column_definition_class() for option in options] == [ + ClickHouseColumnDefinition, + ClickHouseColumnDefinition, + ] + assert options[0] is CLICKHOUSE_OPTIONS[0] + assert options[1] is CLICKHOUSE_OPTIONS[1] + + id_constraints = model.column_constraints("id") + assert [item.constraint_type for item in id_constraints] == [ + ColumnConstraintType.PRIMARY_KEY, + ColumnConstraintType.NOT_NULL, + ] + quantity_constraints = model.column_constraints("quantity") + assert model.model_fields["quantity"].default == 1 + assert quantity_constraints[0].constraint_type == ColumnConstraintType.DEFAULT + assert quantity_constraints[0].default_value == 7 + assert quantity_constraints[1].constraint_type == ColumnConstraintType.NOT_NULL + + assert model.table_options() is TABLE_OPTIONS + assert [item.or_replace for item in model.table_options()] == [True, False] + assert model.table_storage_options() is STORAGE_OPTIONS + assert model.table_partition() is TABLE_PARTITION + assert model.table_partition().keys[0].name == "physical_value" + assert model.table_inherits() is TABLE_INHERITS + assert model.table_tablespace() == TABLE_TABLESPACE + assert model.create_table_statement_classes() is CreateTableExpression + assert model.table_constraints() == TABLE_CONSTRAINTS + assert all( + actual is expected + for actual, expected in zip(model.table_constraints(), TABLE_CONSTRAINTS, strict=True) + ) + assert model.table_indexes() == TABLE_INDEXES + assert all( + actual is expected + for actual, expected in zip(model.table_indexes(), TABLE_INDEXES, strict=True) + ) + assert model.table_indexes()[0].granularity == 4 + + +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 + batch_options = model.columns_options(fields)["value"] + assert [option.column_definition_class() for option in batch_options] == [ + ClickHouseColumnDefinition, + ClickHouseColumnDefinition, + ] + assert batch_options[0] is CLICKHOUSE_OPTIONS[0] + assert batch_options[1] is CLICKHOUSE_OPTIONS[1] + + +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() + sync_options = sync_model.column_options("value") + async_options = async_model.column_options("value") + assert [option.codec for option in sync_options] == [option.codec for option in async_options] + assert sync_options[0] is async_options[0] + assert sync_options[1] is async_options[1] + + +def test_clickhouse_column_options_are_collected_with_explicit_handler(): + options = DeclarationModel.column_options("value") + assert [option.codec for option in options] == [["ZSTD"], ["LZ4"]] + assert options[0].column_definition_class() is ClickHouseColumnDefinition + + +def test_collected_clickhouse_type_and_options_render_minimal_column(): + dialect = ClickHouseDialect(version=(26, 7, 3)) + data_type = DeclarationModel.column_type("value").data_types[0] + assert isinstance(data_type, ClickHouseStringType) + option = DeclarationModel.column_options("value")[0] + data_type.dialect = dialect + try: + column = option.column_definition_class()(dialect, "physical_value", data_type) + option.apply_to(column) + expression = CreateTableExpression(dialect, "rendered", [column]) + rendered = expression.to_sql() + finally: + data_type.dialect = None + assert rendered == ( + "CREATE TABLE `rendered` (`physical_value` String CODEC(ZSTD))", + (), + ) From 553f1aeb8a0022e2bbd895683cbcb004f8f8a2ff Mon Sep 17 00:00:00 2001 From: vistart Date: Sat, 26 Sep 2026 09:19:40 +0800 Subject: [PATCH 17/24] fix(view): stop the MV capability probe from claiming unsupported DDL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit supports_materialized_view() returned True while the backend implemented no MV formatter, so it inherited the core ViewMixin and generated: CREATE MATERIALIZED VIEW `mv1` AS SELECT ... WITH DATA ClickHouse's documented grammar requires an ENGINE when TO is omitted, has no WITH DATA / WITH NO DATA clause at all (the equivalents are POPULATE and EMPTY), and removes views with DROP VIEW rather than DROP MATERIALIZED VIEW. All three are invalid, yet the probe claimed support — the same class of defect as the postgres MRO bug. Now returns False: the generic expressions fail fast with UnsupportedFeatureError instead of emitting SQL the server cannot execute. Real ClickHouse materialized views need a ClickHouse-specific expression (TO / ENGINE / POPULATE / REFRESH / EMPTY, with insert-trigger rather than query-cache semantics), tracked as a separate feature. Tests: adds test_materialized_view_support.py locking the boundary (including the concrete regression: never render WITH DATA); the case in test_dialect_capabilities that asserted True now asserts False with the rationale; the real MV case in query/test_query_features.py is kept (it uses raw SQL, which was already the correct usage) and now explains why it does not go through the expression layer. --- .../backend/impl/clickhouse/mixins/view.py | 23 ++++- .../dialect/test_dialect_capabilities.py | 12 ++- .../dialect/test_materialized_view_support.py | 94 +++++++++++++++++++ .../backend/query/test_query_features.py | 9 ++ 4 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_materialized_view_support.py diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/view.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/view.py index 9f54e20..57fd027 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/view.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/view.py @@ -20,8 +20,27 @@ def supports_temporary_view(self) -> bool: return True def supports_materialized_view(self) -> bool: - """Whether materialized views are supported.""" - return True + """Whether materialized views are supported through the generic API. + + ClickHouse *does* have materialized views, but their DDL shares almost + nothing with the SQL-standard statement the generic + ``CreateMaterializedViewExpression`` renders: + + * without a ``TO`` clause an ``ENGINE`` is mandatory; + * there is no ``WITH DATA`` / ``WITH NO DATA`` clause — the equivalents + are ``POPULATE`` and ``EMPTY``; + * deletion uses ``DROP VIEW``, not ``DROP MATERIALIZED VIEW``; + * refreshable views add ``REFRESH EVERY|AFTER``, ``APPEND``, + ``DEPENDS ON``, ``SETTINGS``; + * semantics are insert-trigger based, not query-result caching. + + Rendering the generic form would emit SQL the server rejects, so the + capability reports ``False`` and callers get a fast, explicit + ``UnsupportedFeatureError`` instead. ClickHouse materialized views are + tracked as a dedicated feature (needs a ClickHouse-specific expression + carrying ``TO`` / ``ENGINE`` / ``POPULATE`` / ``REFRESH`` / ``EMPTY``). + """ + return False def supports_if_exists_view(self) -> bool: """Whether DROP VIEW IF EXISTS is supported.""" diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_dialect_capabilities.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_dialect_capabilities.py index 6254a83..45c1a6d 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_dialect_capabilities.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_dialect_capabilities.py @@ -61,9 +61,19 @@ def test_joins_supported(self, dialect): assert dialect.supports_cross_join() is True def test_views_supported(self, dialect): - assert dialect.supports_materialized_view() is True assert dialect.supports_or_replace_view() is True + def test_materialized_view_not_exposed_through_generic_api(self, dialect): + """ClickHouse MVs exist but the generic DDL expression cannot express them. + + ClickHouse requires ``ENGINE``/``TO``, has no ``WITH [NO] DATA`` clause + and drops views with ``DROP VIEW``. Rendering the SQL-standard form would + emit statements the server rejects, so the capability must stay False and + fail fast rather than lie. + """ + assert dialect.supports_materialized_view() is False + assert dialect.supports_refresh_materialized_view() is False + def test_qualify_and_ilike_supported(self, dialect): assert dialect.supports_qualify_clause() is True assert dialect.supports_ilike() is True diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_materialized_view_support.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_materialized_view_support.py new file mode 100644 index 0000000..00352a2 --- /dev/null +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_materialized_view_support.py @@ -0,0 +1,94 @@ +# tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_materialized_view_support.py +""" +ClickHouse materialized view support boundary. + +ClickHouse *has* materialized views, but its DDL shares almost nothing with the +SQL-standard statement the generic ``CreateMaterializedViewExpression`` renders: + +* without a ``TO`` clause an ``ENGINE`` is mandatory; +* there is no ``WITH DATA`` / ``WITH NO DATA`` clause (``POPULATE`` / ``EMPTY``); +* deletion uses ``DROP VIEW``, not ``DROP MATERIALIZED VIEW``; +* refreshable views add ``REFRESH EVERY|AFTER``, ``APPEND``, ``DEPENDS ON``; +* semantics are insert-trigger based, not query-result caching. + +So the generic API must report the capability as unsupported and fail fast, +rather than emit SQL the server rejects. These tests lock that boundary; the +raw-SQL usage test lives in ``query/test_query_features.py``. +""" + +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression import Column, QueryExpression, TableExpression +from rhosocial.activerecord.backend.expression.statements.ddl_view import ( + CreateMaterializedViewExpression, + DropMaterializedViewExpression, + RefreshMaterializedViewExpression, +) +from rhosocial.activerecord.backend.impl.clickhouse.dialect import ClickHouseDialect + + +def _source_query(clickhouse_dialect): + return QueryExpression( + dialect=clickhouse_dialect, + select=[Column(clickhouse_dialect, "grp")], + from_=TableExpression(clickhouse_dialect, "src"), + ) + + + +class TestMaterializedViewCapabilityBoundary: + """The capability must not be advertised while the DDL cannot be rendered.""" + + def test_probe_reports_unsupported(self, clickhouse_dialect): + assert clickhouse_dialect.supports_materialized_view() is False + + def test_refresh_probe_reports_unsupported(self, clickhouse_dialect): + assert clickhouse_dialect.supports_refresh_materialized_view() is False + + def test_probe_is_stable(self, clickhouse_dialect): + """Repeated calls must agree — a lying probe is worse than a missing one.""" + results = {clickhouse_dialect.supports_materialized_view() for _ in range(3)} + assert results == {False} + + +class TestGenericMaterializedViewFailsFast: + """Using the generic expressions must raise, not emit invalid SQL.""" + + def test_create_raises(self, clickhouse_dialect): + expression = CreateMaterializedViewExpression( + dialect=clickhouse_dialect, view_name="mv_ch", query=_source_query(clickhouse_dialect) + ) + with pytest.raises(UnsupportedFeatureError) as exc: + expression.to_sql() + assert "CREATE MATERIALIZED VIEW" in str(exc.value) + + def test_refresh_raises(self, clickhouse_dialect): + expression = RefreshMaterializedViewExpression(dialect=clickhouse_dialect, view_name="mv_ch") + with pytest.raises(UnsupportedFeatureError): + expression.to_sql() + + def test_drop_raises(self, clickhouse_dialect): + expression = DropMaterializedViewExpression( + dialect=clickhouse_dialect, view_name="mv_ch", if_exists=True + ) + with pytest.raises(UnsupportedFeatureError): + expression.to_sql() + + def test_no_clickhouse_specific_with_data_clause_is_emitted(self, clickhouse_dialect): + """Guard the exact regression: the generic form leaked ``WITH DATA``.""" + expression = CreateMaterializedViewExpression( + dialect=clickhouse_dialect, view_name="mv_ch", query=_source_query(clickhouse_dialect) + ) + try: + sql, _ = expression.to_sql() + except UnsupportedFeatureError: + return + pytest.fail(f"generic CREATE MATERIALIZED VIEW must not render, got {sql!r}") + + +class TestDialectInstantiation: + """A freshly constructed dialect reports the same boundary.""" + + def test_default_dialect_reports_unsupported(self): + assert ClickHouseDialect().supports_materialized_view() is False diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/query/test_query_features.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/query/test_query_features.py index 8642aed..9f9344c 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/query/test_query_features.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/query/test_query_features.py @@ -152,6 +152,15 @@ def test_aggregate_functions(self, clickhouse_backend, sample_table): class TestClickHouseDDLFeatures: def test_materialized_view(self, clickhouse_backend, sample_table): + """ClickHouse materialized views via raw SQL. + + Deliberately not routed through ``CreateMaterializedViewExpression``: + ClickHouse's MV DDL (``ENGINE``/``TO``/``POPULATE``/``REFRESH``/``EMPTY``, + insert-trigger semantics, ``DROP VIEW`` for removal) diverges from the + SQL-standard statement, so the dialect reports + ``supports_materialized_view() is False`` and needs a ClickHouse-specific + expression before the typed API can be used. + """ backend = clickhouse_backend backend.execute("DROP TABLE IF EXISTS test_ch_mv") backend.execute("DROP VIEW IF EXISTS test_ch_mv_agg") From 54c663921a270a6e2b36b8286812cbb2629beb80 Mon Sep 17 00:00:00 2001 From: vistart Date: Sat, 26 Sep 2026 09:46:17 +0800 Subject: [PATCH 18/24] test(view): align the second case that asserted the old probe behaviour test_materialized_view_supported in test_clickhouse_ddl_improvements also asserted supports_materialized_view() is True (surfaced by CI run 36208939214). Changed to assert False alongside its sibling in test_dialect_capabilities, with the same rationale. --- .../backend/test_clickhouse_ddl_improvements.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_ddl_improvements.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_ddl_improvements.py index 890d71d..df159be 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_ddl_improvements.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_ddl_improvements.py @@ -54,10 +54,16 @@ def test_drop_view_if_exists_supported(self): dialect = ClickHouseDialect() assert dialect.supports_if_exists_view() is True - def test_materialized_view_supported(self): - """ClickHouse supports materialized views.""" + def test_materialized_view_not_exposed_through_generic_api(self): + """ClickHouse has materialized views, but not expressible generically. + + The ClickHouse DDL requires ``ENGINE`` (or ``TO``), has no + ``WITH [NO] DATA`` clause and drops views with ``DROP VIEW``, so the + capability must report ``False`` and the generic expressions must fail + fast rather than emit SQL the server rejects. + """ dialect = ClickHouseDialect() - assert dialect.supports_materialized_view() is True + assert dialect.supports_materialized_view() is False class TestClickHouseSchemaCapabilityGating: From fe52b885f79639ec407689b6b7d1ea71b15c9f15 Mon Sep 17 00:00:00 2001 From: vistart Date: Sat, 26 Sep 2026 11:19:25 +0800 Subject: [PATCH 19/24] feat(view): support real ClickHouse materialized views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flipping supports_materialized_view() to False last round stopped the lying; it was not the destination. ClickHouse's materialized view semantics share almost nothing with the SQL-standard statement, so both flavours are now implemented against its native grammar. Two flavours (per the official CREATE VIEW documentation): - Incremental (insert trigger): TO or ENGINE (ENGINE is mandatory without TO), optionally backfilled with POPULATE - Refreshable: REFRESH EVERY|AFTER [OFFSET] [RANDOMIZE FOR] [DEPENDS ON] [SETTINGS] [APPEND [INCREMENTAL]] [EMPTY] Operations: - CREATE MATERIALIZED VIEW [IF NOT EXISTS] ... AS SELECT - DROP VIEW [IF EXISTS] (ClickHouse removes MVs with DROP VIEW) - SYSTEM REFRESH VIEW (optionally SYSTEM WAIT VIEW to block until done) - ALTER TABLE ... MODIFY REFRESH (change the schedule; the statement replaces all refresh parameters at once) Server rules are validated in the constructor *and* the formatter: ENGINE/TO is mandatory; REFRESH needs at least one of EVERY/AFTER/DEPENDS ON; OFFSET requires EVERY; APPEND INCREMENTAL requires APPEND; POPULATE conflicts with REFRESH. The formatter validates too because a generic expression never reaches the constructor — which was a real hole: the generic CreateMaterializedViewExpression has neither TO nor ENGINE and previously produced SQL the server rejects. Clauses the generic expressions carry that ClickHouse lacks are refused rather than dropped: column aliases, TABLESPACE, storage parameters, WITH [NO] DATA (the error points at POPULATE/EMPTY), DROP ... CASCADE; refresh with_data and concurrent likewise. Kept as well: the backend-side assertion that a dialect claiming True must own the CREATE formatter and be ordered ahead of the core ViewMixin. Also fixed: the probe left in ClickHouseViewMixin last round is removed (it would shadow the new mixin), the base order is ClickHouseMaterializedViewMixin -> ClickHouseViewMixin -> ViewMixin, and ON CLUSTER goes through format_identifier per the existing format_drop_database_statement convention. Tests: 41 new cases covering both flavours, the schedule clauses, every invalid combination, clause order, and the concrete regression "never render WITH DATA". dialect+expression groups: 199 passed. --- changelog.d/20.added.md | 1 + .../backend/impl/clickhouse/dialect.py | 2 + .../impl/clickhouse/expression/__init__.py | 15 + .../expression/materialized_view.py | 325 +++++++++++++++ .../impl/clickhouse/mixins/__init__.py | 2 + .../clickhouse/mixins/materialized_view.py | 238 +++++++++++ .../backend/impl/clickhouse/mixins/view.py | 23 - .../dialect/test_dialect_capabilities.py | 15 +- .../backend/dialect/test_materialized_view.py | 393 ++++++++++++++++++ .../dialect/test_materialized_view_support.py | 94 ----- .../test_clickhouse_ddl_improvements.py | 12 +- 11 files changed, 986 insertions(+), 134 deletions(-) create mode 100644 changelog.d/20.added.md create mode 100644 src/rhosocial/activerecord/backend/impl/clickhouse/expression/materialized_view.py create mode 100644 src/rhosocial/activerecord/backend/impl/clickhouse/mixins/materialized_view.py create mode 100644 tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_materialized_view.py delete mode 100644 tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_materialized_view_support.py diff --git a/changelog.d/20.added.md b/changelog.d/20.added.md new file mode 100644 index 0000000..f143160 --- /dev/null +++ b/changelog.d/20.added.md @@ -0,0 +1 @@ +Add real ClickHouse materialized view support: `ClickHouseCreateMaterializedViewExpression` covering both ClickHouse flavours (incremental with `TO
` or a mandatory `ENGINE`, plus `POPULATE`; and refreshable with `REFRESH EVERY|AFTER`, `OFFSET`, `RANDOMIZE FOR`, `DEPENDS ON`, `SETTINGS`, `APPEND [INCREMENTAL]` and `EMPTY`), `ClickHouseDropMaterializedViewExpression` (`DROP VIEW`), `ClickHouseRefreshMaterializedViewExpression` (`SYSTEM REFRESH VIEW`, optionally `SYSTEM WAIT VIEW`) and `ClickHouseModifyMaterializedViewRefreshExpression` (`ALTER TABLE ... MODIFY REFRESH`). Replaces the previous state where `supports_materialized_view()` reported `False` because the SQL-standard form could not be rendered. Server-side rules are validated in the constructor *and* the formatter: `ENGINE`/`TO` is mandatory, `REFRESH` needs at least one of `EVERY`/`AFTER`/`DEPENDS ON`, `OFFSET` requires `EVERY`, `APPEND INCREMENTAL` requires `APPEND`, and `POPULATE` conflicts with `REFRESH`. diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/dialect.py b/src/rhosocial/activerecord/backend/impl/clickhouse/dialect.py index e7fad52..1495b4e 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/dialect.py @@ -138,6 +138,7 @@ ClickHouseJoinMixin, ClickHouseSetOperationMixin, ClickHouseDQLMixin, + ClickHouseMaterializedViewMixin, # MV DDL overrides ClickHouseViewMixin and ViewMixin ClickHouseViewMixin, ClickHouseSchemaMixin, ClickHouseDatabaseMixin, @@ -184,6 +185,7 @@ class ClickHouseDialect( LateralJoinMixin, ClickHouseJoinMixin, JoinMixin, + ClickHouseMaterializedViewMixin, # MV DDL must precede ClickHouseViewMixin and ViewMixin ClickHouseViewMixin, ViewMixin, ClickHouseSchemaMixin, diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/__init__.py index 5f65e9b..78ea8fe 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/__init__.py @@ -30,6 +30,14 @@ ClickHouseDropDatabaseExpression, ) from .dml import ClickHouseInsertExpression +from .materialized_view import ( + ClickHouseCreateMaterializedViewExpression, + ClickHouseDropMaterializedViewExpression, + ClickHouseIntervalUnit, + ClickHouseModifyMaterializedViewRefreshExpression, + ClickHouseRefreshMaterializedViewExpression, + ClickHouseRefreshSchedule, +) from .index import ClickHouseIndexDefinition from .json import ( ClickHouseJSONExtractExpression, @@ -240,4 +248,11 @@ "ClickHouseUInt64Type", "ClickHouseUInt8Type", "ClickHouseUUIDType", + # Materialized views + "ClickHouseCreateMaterializedViewExpression", + "ClickHouseDropMaterializedViewExpression", + "ClickHouseRefreshMaterializedViewExpression", + "ClickHouseModifyMaterializedViewRefreshExpression", + "ClickHouseRefreshSchedule", + "ClickHouseIntervalUnit", ] diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/materialized_view.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/materialized_view.py new file mode 100644 index 0000000..ff4d0a8 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/materialized_view.py @@ -0,0 +1,325 @@ +# src/rhosocial/activerecord/backend/impl/clickhouse/expression/materialized_view.py +"""ClickHouse MATERIALIZED VIEW expressions. + +ClickHouse has two kinds of materialized view, and their DDL shares almost +nothing with the SQL-standard statement: + +**Incremental (classic) MV** — an insert trigger. Rows written to the source +table are transformed by the ``SELECT`` and pushed into a target table:: + + CREATE MATERIALIZED VIEW [IF NOT EXISTS] [db.]name [ON CLUSTER cluster] + [TO [db.]target [(columns)]] + [ENGINE = engine] + [POPULATE] + AS SELECT ... + +* Without ``TO`` an ``ENGINE`` is **mandatory**. +* ``POPULATE`` backfills existing rows; it cannot be combined with ``REFRESH``. + +**Refreshable MV** — periodically re-runs the query and replaces the result:: + + CREATE MATERIALIZED VIEW [IF NOT EXISTS] [db.]name [ON CLUSTER cluster] + REFRESH [EVERY|AFTER interval [OFFSET interval]] + [RANDOMIZE FOR interval] + [DEPENDS ON [db.]name, ...] + [SETTINGS name = value, ...] + [APPEND [INCREMENTAL]] + [TO [db.]name] [ENGINE = engine] + [EMPTY] + AS SELECT ... + +* ``REFRESH`` must carry at least one of ``EVERY``, ``AFTER`` or ``DEPENDS ON``; + a bare ``REFRESH`` is rejected by the server. +* ``OFFSET`` is only valid with ``EVERY``. +* ``EMPTY`` skips the first refresh (the ``POPULATE`` equivalent for + refreshable views, and mutually exclusive with it). + +Removal uses ``DROP VIEW``, not ``DROP MATERIALIZED VIEW``. + +Reference: +https://clickhouse.com/docs/sql-reference/statements/create/view +""" +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Dict, List, Optional, Sequence, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: # pragma: no cover + from ..dialect import ClickHouseDialect + + +__all__ = [ + "ClickHouseCreateMaterializedViewExpression", + "ClickHouseDropMaterializedViewExpression", + "ClickHouseIntervalUnit", + "ClickHouseModifyMaterializedViewRefreshExpression", + "ClickHouseRefreshMaterializedViewExpression", + "ClickHouseRefreshSchedule", +] + + +class ClickHouseIntervalUnit(Enum): + """Units accepted in a ClickHouse refresh interval.""" + + SECOND = "SECOND" + MINUTE = "MINUTE" + HOUR = "HOUR" + DAY = "DAY" + WEEK = "WEEK" + MONTH = "MONTH" + YEAR = "YEAR" + + +def _validate_name(value: Optional[str], field_name: str) -> None: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field_name} must be a non-empty string") + + +@dataclass +class ClickHouseRefreshSchedule: + """The ``REFRESH`` clause of a refreshable materialized view. + + Args: + every: ``EVERY interval`` — a sequence of `` `` parts. + after: ``AFTER interval`` — fires relative to the previous completion. + offset: ``OFFSET interval``, only valid together with ``every``. + randomize_for: ``RANDOMIZE FOR interval``. + depends_on: ``DEPENDS ON`` materialized view names. + settings: ``SETTINGS name = value, ...`` refresh settings. + append: ``APPEND`` — each refresh inserts without deleting. + incremental: ``APPEND INCREMENTAL`` — refresh only rows committed since + the previous run. + + At least one of ``every`` / ``after`` / ``depends_on`` is required: the + server rejects a bare ``REFRESH``. + """ + + every: Optional[str] = None + after: Optional[str] = None + offset: Optional[str] = None + randomize_for: Optional[str] = None + depends_on: Sequence[str] = field(default_factory=tuple) + settings: Optional[Dict[str, Any]] = None + append: bool = False + incremental: bool = False + + def validate(self) -> None: + """Validate the schedule the way the server does. + + Raises: + ValueError: on a bare ``REFRESH``, ``OFFSET`` without ``EVERY``, + ``APPEND INCREMENTAL`` without ``APPEND``, or an empty + ``DEPENDS ON`` list. + """ + if not self.every and not self.after and not self.depends_on: + raise ValueError( + "REFRESH requires at least one of EVERY, AFTER or DEPENDS ON" + ) + if self.offset and not self.every: + raise ValueError("OFFSET is only valid with EVERY") + if self.incremental and not self.append: + raise ValueError("INCREMENTAL requires APPEND") + if self.depends_on is not None and not isinstance(self.depends_on, (list, tuple)): + raise TypeError("depends_on must be a sequence of view names") + if self.depends_on: + for name in self.depends_on: + _validate_name(name, "depends_on entry") + + +class ClickHouseCreateMaterializedViewExpression(BaseExpression): + """``CREATE MATERIALIZED VIEW`` for both ClickHouse MV flavours. + + Args: + dialect: the ClickHouse dialect instance. + view_name: materialized view name. + query: defining query, an expression or raw SQL. + database: optional database qualifier. + on_cluster: optional ``ON CLUSTER`` name. + to_table: target table for the ``TO`` form; its columns come from the + query unless ``to_columns`` is given. + engine: table engine, e.g. ``"MergeTree ORDER BY id"``. Mandatory + without ``to_table``. + populate: backfill existing rows (incremental MVs only). + refresh: a :class:`ClickHouseRefreshSchedule` making it refreshable. + empty: skip the first refresh (refreshable MVs only). + comment: ``COMMENT`` string. + or_replace: emit ``OR REPLACE`` (requires an Atomic/Replicated database). + if_not_exists: emit ``IF NOT EXISTS``. + + Raises: + ValueError: on an invalid combination — see ``validate``. + """ + + def __init__( + self, + dialect: "ClickHouseDialect", + view_name: str, + query: Any, + database: Optional[str] = None, + on_cluster: Optional[str] = None, + to_table: Optional[str] = None, + to_columns: Optional[Sequence[str]] = None, + engine: Optional[str] = None, + populate: bool = False, + refresh: Optional[ClickHouseRefreshSchedule] = None, + empty: bool = False, + comment: Optional[str] = None, + or_replace: bool = False, + if_not_exists: bool = False, + ): + super().__init__(dialect) + _validate_name(view_name, "view_name") + if query is None: + raise ValueError("CREATE MATERIALIZED VIEW requires a defining query") + if or_replace and if_not_exists: + raise ValueError( + "ClickHouse rejects OR REPLACE together with IF NOT EXISTS" + ) + if database is not None: + _validate_name(database, "database") + if to_table is not None: + _validate_name(to_table, "to_table") + if to_columns is not None and ( + isinstance(to_columns, str) or not to_columns + ): + raise ValueError("to_columns must be a non-empty sequence of columns") + + self.view_name = view_name + self.query = query + self.database = database + self.on_cluster = on_cluster + self.to_table = to_table + self.to_columns = list(to_columns) if to_columns else None + self.engine = engine + self.populate = populate + self.refresh = refresh + self.empty = empty + self.comment = comment + self.or_replace = or_replace + self.if_not_exists = if_not_exists + self.validate() + + def validate(self) -> None: + """Validate the MV form the way the server does. + + Raises: + ValueError: when neither ``TO`` nor ``ENGINE`` is given, or when + ``POPULATE``/``EMPTY`` are combined with ``REFRESH``. + """ + if not self.to_table and not self.engine: + raise ValueError( + "a ClickHouse materialized view requires a TO target table or an " + "ENGINE (the engine is mandatory when TO is omitted)" + ) + if self.populate and self.refresh is not None: + raise ValueError( + "POPULATE cannot be combined with REFRESH; use EMPTY to skip the " + "first refresh of a refreshable materialized view" + ) + if self.empty and self.refresh is None: + raise ValueError("EMPTY is only meaningful for a refreshable materialized view") + if self.refresh is not None: + self.refresh.validate() + + @property + def is_refreshable(self) -> bool: + """Whether this is a refreshable (rather than incremental) MV.""" + return self.refresh is not None + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_create_materialized_view_statement" + + +class ClickHouseDropMaterializedViewExpression(BaseExpression): + """``DROP VIEW [IF EXISTS]`` — ClickHouse removes MVs with ``DROP VIEW``.""" + + def __init__( + self, + dialect: "ClickHouseDialect", + view_name: str, + database: Optional[str] = None, + if_exists: bool = True, + ): + super().__init__(dialect) + _validate_name(view_name, "view_name") + if database is not None: + _validate_name(database, "database") + self.view_name = view_name + self.database = database + self.if_exists = if_exists + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_drop_materialized_view_statement" + + +class ClickHouseRefreshMaterializedViewExpression(BaseExpression): + """``SYSTEM REFRESH VIEW`` — trigger a refreshable MV refresh now. + + ClickHouse has no ``REFRESH MATERIALIZED VIEW`` statement; an immediate + refresh is a ``SYSTEM`` command, and ``SYSTEM WAIT VIEW`` blocks until the + running refresh completes. + + Args: + dialect: the ClickHouse dialect instance. + view_name: materialized view name. + database: optional database qualifier. + wait: also emit ``SYSTEM WAIT VIEW`` so the call blocks until done. + """ + + def __init__( + self, + dialect: "ClickHouseDialect", + view_name: str, + database: Optional[str] = None, + wait: bool = False, + ): + super().__init__(dialect) + _validate_name(view_name, "view_name") + if database is not None: + _validate_name(database, "database") + self.view_name = view_name + self.database = database + self.wait = wait + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_refresh_materialized_view_statement" + + +class ClickHouseModifyMaterializedViewRefreshExpression(BaseExpression): + """``ALTER TABLE ... MODIFY REFRESH`` — change an existing schedule. + + The statement replaces **all** refresh parameters: whatever is omitted is + reset to its default or removed. + """ + + def __init__( + self, + dialect: "ClickHouseDialect", + view_name: str, + schedule: ClickHouseRefreshSchedule, + database: Optional[str] = None, + if_exists: bool = False, + ): + super().__init__(dialect) + _validate_name(view_name, "view_name") + if database is not None: + _validate_name(database, "database") + if not isinstance(schedule, ClickHouseRefreshSchedule): + raise TypeError("schedule must be a ClickHouseRefreshSchedule") + schedule.validate() + self.view_name = view_name + self.schedule = schedule + self.database = database + self.if_exists = if_exists + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_modify_materialized_view_refresh_statement" diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/__init__.py index 9aabc98..a8cbad0 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/__init__.py @@ -44,6 +44,7 @@ from .set_operation import ClickHouseSetOperationMixin from .dql import ClickHouseDQLMixin from .view import ClickHouseViewMixin +from .materialized_view import ClickHouseMaterializedViewMixin from .ddl_database import ClickHouseDatabaseMixin from .schema import ClickHouseSchemaMixin from .index import ClickHouseIndexMixin @@ -97,6 +98,7 @@ "ClickHouseSetOperationMixin", "ClickHouseDQLMixin", "ClickHouseViewMixin", + "ClickHouseMaterializedViewMixin", "ClickHouseSchemaMixin", "ClickHouseDatabaseMixin", "ClickHouseIndexMixin", diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/materialized_view.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/materialized_view.py new file mode 100644 index 0000000..518e07a --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/materialized_view.py @@ -0,0 +1,238 @@ +# src/rhosocial/activerecord/backend/impl/clickhouse/mixins/materialized_view.py +"""ClickHouse materialized view formatters. + +ClickHouse's materialized view DDL diverges from the SQL-standard statement in +ways that must not be papered over — see +:mod:`..expression.materialized_view` for the two MV flavours and their grammar. +Anything the generic expressions carry that ClickHouse cannot express is +rejected with ``UnsupportedFeatureError`` instead of being silently dropped. +""" +from typing import Any, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +if TYPE_CHECKING: # pragma: no cover + from ..expression.materialized_view import ( + ClickHouseCreateMaterializedViewExpression, + ClickHouseModifyMaterializedViewRefreshExpression, + ClickHouseRefreshMaterializedViewExpression, + ClickHouseRefreshSchedule, + ) + + +class ClickHouseMaterializedViewMixin: + """ClickHouse MATERIALIZED VIEW support. + + Must be listed before the core ``ViewMixin`` in ``ClickHouseDialect`` so + these formatters take precedence. + """ + + def supports_materialized_view(self) -> bool: + """ClickHouse supports incremental and refreshable materialized views.""" + return True + + def supports_refresh_materialized_view(self) -> bool: + """Refreshable MVs are refreshed through ``SYSTEM REFRESH VIEW``.""" + return True + + def supports_materialized_view_refresh_schedule(self) -> bool: + """Whether a ``REFRESH`` schedule can be declared / modified.""" + return True + + def supports_materialized_view_populate(self) -> bool: + """``POPULATE`` backfills an incremental MV from existing rows.""" + return True + + # ------------------------------------------------------------------ + # Formatters + # ------------------------------------------------------------------ + + def format_create_materialized_view_statement( + self, expr: "ClickHouseCreateMaterializedViewExpression" + ) -> Tuple[str, tuple]: + """Format ``CREATE MATERIALIZED VIEW`` for both ClickHouse MV flavours. + + Args: + expr: ClickHouse create expression (or a generic one, whose + unsupported clauses are rejected). + + Returns: + Tuple of (SQL string, params tuple from the defining query). + + Raises: + UnsupportedFeatureError: for clauses ClickHouse does not have. + """ + self._reject_unsupported_clauses(expr, "CREATE MATERIALIZED VIEW") + # The validation also runs in the ClickHouse expression constructor, but a + # generic CreateMaterializedViewExpression never goes through it — and it + # carries neither TO nor ENGINE, which ClickHouse requires. + to_table = getattr(expr, "to_table", None) + engine = getattr(expr, "engine", None) + if not to_table and not engine: + raise UnsupportedFeatureError( + self.name, + "CREATE MATERIALIZED VIEW", + "ClickHouse requires a TO target table or an ENGINE; use " + "ClickHouseCreateMaterializedViewExpression.", + ) + + parts = ["CREATE"] + if getattr(expr, "or_replace", False): + parts.append("OR REPLACE") + parts.append("MATERIALIZED VIEW") + if getattr(expr, "if_not_exists", False): + parts.append("IF NOT EXISTS") + parts.append(self._qualified(expr.view_name, getattr(expr, "database", None))) + + on_cluster = getattr(expr, "on_cluster", None) + if on_cluster: + parts.append(f"ON CLUSTER {self.format_identifier(str(on_cluster))}") + + if to_table: + target = self._qualified(to_table, getattr(expr, "database", None)) + to_columns = getattr(expr, "to_columns", None) + if to_columns: + target = f"{target} ({', '.join(str(c) for c in to_columns)})" + parts.append(f"TO {target}") + + engine = getattr(expr, "engine", None) + if engine: + parts.append(f"ENGINE = {engine}") + + if getattr(expr, "populate", False): + parts.append("POPULATE") + + refresh = getattr(expr, "refresh", None) + if refresh is not None: + parts.append(self._format_refresh_clause(refresh)) + + if getattr(expr, "empty", False): + parts.append("EMPTY") + + query_sql, query_params = self._materialized_view_query_sql(expr) + parts.append(f"AS {query_sql}") + + comment = getattr(expr, "comment", None) + if comment is not None: + parts.append(f"COMMENT '{self._escape_sql_string(str(comment))}'") + + return " ".join(parts), query_params + + def format_drop_materialized_view_statement(self, expr: Any) -> Tuple[str, tuple]: + """Format ``DROP VIEW [IF EXISTS]`` — ClickHouse has no ``DROP MATERIALIZED VIEW``.""" + if getattr(expr, "cascade", False): + raise UnsupportedFeatureError(self.name, "DROP MATERIALIZED VIEW CASCADE") + parts = ["DROP", "VIEW"] + if getattr(expr, "if_exists", False): + parts.append("IF EXISTS") + parts.append(self._qualified(expr.view_name, getattr(expr, "database", None))) + return " ".join(parts), () + + def format_refresh_materialized_view_statement( + self, expr: "ClickHouseRefreshMaterializedViewExpression" + ) -> Tuple[str, tuple]: + """Format ``SYSTEM REFRESH VIEW`` (optionally followed by ``SYSTEM WAIT VIEW``).""" + if getattr(expr, "concurrent", False): + raise UnsupportedFeatureError( + self.name, + "REFRESH MATERIALIZED VIEW CONCURRENTLY", + "ClickHouse refreshes through SYSTEM REFRESH VIEW; use wait=True to " + "block until the refresh completes.", + ) + if getattr(expr, "with_data", None) is not None: + raise UnsupportedFeatureError( + self.name, + "REFRESH MATERIALIZED VIEW WITH [NO] DATA", + "A ClickHouse refresh always repopulates the target.", + ) + target = self._qualified(expr.view_name, getattr(expr, "database", None)) + statement = f"SYSTEM REFRESH VIEW {target}" + if getattr(expr, "wait", False): + statement = f"{statement}; SYSTEM WAIT VIEW {target}" + return statement, () + + def format_modify_materialized_view_refresh_statement( + self, expr: "ClickHouseModifyMaterializedViewRefreshExpression" + ) -> Tuple[str, tuple]: + """Format ``ALTER TABLE ... MODIFY REFRESH``.""" + parts = ["ALTER TABLE"] + if getattr(expr, "if_exists", False): + parts.append("IF EXISTS") + parts.append(self._qualified(expr.view_name, getattr(expr, "database", None))) + clause = self._format_refresh_clause(expr.schedule) + # ALTER TABLE ... MODIFY REFRESH requires the schedule keyword; the + # SYSTEM form spells it the same way. + parts.append(f"MODIFY {clause}") + return " ".join(parts), () + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _format_refresh_clause(self, schedule: "ClickHouseRefreshSchedule") -> str: + """Render a ``REFRESH ...`` clause body (without the leading keyword).""" + parts = ["REFRESH"] + if schedule.every: + parts.append(f"EVERY {schedule.every}") + if schedule.offset: + parts.append(f"OFFSET {schedule.offset}") + elif schedule.after: + parts.append(f"AFTER {schedule.after}") + if schedule.randomize_for: + parts.append(f"RANDOMIZE FOR {schedule.randomize_for}") + if schedule.depends_on: + deps = ", ".join( + self.format_identifier(str(name)) for name in schedule.depends_on + ) + parts.append(f"DEPENDS ON {deps}") + if schedule.settings: + rendered = ", ".join( + f"{key} = {value}" for key, value in schedule.settings.items() + ) + parts.append(f"SETTINGS {rendered}") + if schedule.append: + parts.append("APPEND INCREMENTAL" if schedule.incremental else "APPEND") + return " ".join(parts) + + def _qualified(self, name: str, database: Optional[str]) -> str: + """Render a possibly database-qualified ClickHouse object name.""" + if database: + return f"{self.format_identifier(database)}.{self.format_identifier(name)}" + return self.format_identifier(name) + + def _materialized_view_query_sql(self, expr: Any) -> Tuple[str, tuple]: + """Return the defining query SQL, accepting an expression or raw SQL.""" + query = getattr(expr, "query", None) + if query is None: + raise ValueError("CREATE MATERIALIZED VIEW requires a defining query") + if isinstance(query, str): + return query, () + return query.to_sql() + + def _reject_unsupported_clauses(self, expr: Any, feature: str) -> None: + """Reject generic-expression clauses ClickHouse cannot express.""" + if getattr(expr, "column_aliases", None): + raise UnsupportedFeatureError( + self.name, + f"{feature} COLUMN ALIASES", + "ClickHouse materialized views take column names from the query.", + ) + if getattr(expr, "tablespace", None): + raise UnsupportedFeatureError( + self.name, + f"{feature} TABLESPACE", + "ClickHouse materializes into a target table or an ENGINE.", + ) + if getattr(expr, "storage_options", None): + raise UnsupportedFeatureError( + self.name, + f"{feature} STORAGE PARAMETERS", + "ClickHouse materializes into a target table or an ENGINE.", + ) + with_data = getattr(expr, "with_data", True) + if with_data is False: + raise UnsupportedFeatureError( + self.name, + f"{feature} WITH NO DATA", + "ClickHouse uses POPULATE (incremental) or EMPTY (refreshable) instead.", + ) diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/view.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/view.py index 57fd027..45c8a02 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/view.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/view.py @@ -19,29 +19,6 @@ def supports_temporary_view(self) -> bool: """Whether CREATE TEMPORARY VIEW is supported.""" return True - def supports_materialized_view(self) -> bool: - """Whether materialized views are supported through the generic API. - - ClickHouse *does* have materialized views, but their DDL shares almost - nothing with the SQL-standard statement the generic - ``CreateMaterializedViewExpression`` renders: - - * without a ``TO`` clause an ``ENGINE`` is mandatory; - * there is no ``WITH DATA`` / ``WITH NO DATA`` clause — the equivalents - are ``POPULATE`` and ``EMPTY``; - * deletion uses ``DROP VIEW``, not ``DROP MATERIALIZED VIEW``; - * refreshable views add ``REFRESH EVERY|AFTER``, ``APPEND``, - ``DEPENDS ON``, ``SETTINGS``; - * semantics are insert-trigger based, not query-result caching. - - Rendering the generic form would emit SQL the server rejects, so the - capability reports ``False`` and callers get a fast, explicit - ``UnsupportedFeatureError`` instead. ClickHouse materialized views are - tracked as a dedicated feature (needs a ClickHouse-specific expression - carrying ``TO`` / ``ENGINE`` / ``POPULATE`` / ``REFRESH`` / ``EMPTY``). - """ - return False - def supports_if_exists_view(self) -> bool: """Whether DROP VIEW IF EXISTS is supported.""" return True diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_dialect_capabilities.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_dialect_capabilities.py index 45c1a6d..c9d0d36 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_dialect_capabilities.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_dialect_capabilities.py @@ -63,16 +63,15 @@ def test_joins_supported(self, dialect): def test_views_supported(self, dialect): assert dialect.supports_or_replace_view() is True - def test_materialized_view_not_exposed_through_generic_api(self, dialect): - """ClickHouse MVs exist but the generic DDL expression cannot express them. + def test_materialized_view_supported(self, dialect): + """ClickHouse materialized views are supported via a dedicated expression. - ClickHouse requires ``ENGINE``/``TO``, has no ``WITH [NO] DATA`` clause - and drops views with ``DROP VIEW``. Rendering the SQL-standard form would - emit statements the server rejects, so the capability must stay False and - fail fast rather than lie. + The SQL-standard form cannot express them (ENGINE/TO mandatory, no + WITH [NO] DATA, DROP VIEW), so the generic expression is rejected and + ClickHouseCreateMaterializedViewExpression is the entry point. """ - assert dialect.supports_materialized_view() is False - assert dialect.supports_refresh_materialized_view() is False + assert dialect.supports_materialized_view() is True + assert dialect.supports_refresh_materialized_view() is True def test_qualify_and_ilike_supported(self, dialect): assert dialect.supports_qualify_clause() is True diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_materialized_view.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_materialized_view.py new file mode 100644 index 0000000..5a28e0d --- /dev/null +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_materialized_view.py @@ -0,0 +1,393 @@ +# tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_materialized_view.py +"""ClickHouse MATERIALIZED VIEW DDL tests (SQL generation only, no database). + +ClickHouse has two MV flavours whose DDL shares almost nothing with the +SQL-standard statement: + +* incremental — insert trigger, materialized into ``TO
`` or an + ``ENGINE``, optionally backfilled with ``POPULATE``; +* refreshable — ``REFRESH EVERY|AFTER`` schedule, ``APPEND``, ``EMPTY``. + +Removal uses ``DROP VIEW`` and refresh uses ``SYSTEM REFRESH VIEW``. +""" + +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression import Column, QueryExpression, TableExpression +from rhosocial.activerecord.backend.expression.statements.ddl_view import ( + CreateMaterializedViewExpression, + DropMaterializedViewExpression, + RefreshMaterializedViewExpression, +) +from rhosocial.activerecord.backend.impl.clickhouse.dialect import ClickHouseDialect +from rhosocial.activerecord.backend.impl.clickhouse.expression.materialized_view import ( + ClickHouseCreateMaterializedViewExpression, + ClickHouseDropMaterializedViewExpression, + ClickHouseModifyMaterializedViewRefreshExpression, + ClickHouseRefreshMaterializedViewExpression, + ClickHouseRefreshSchedule, +) + +QUERY = "SELECT grp, sum(v) AS s FROM src GROUP BY grp" + + +@pytest.fixture +def dialect(): + return ClickHouseDialect() + + +def _generic_query(dialect): + return QueryExpression( + dialect=dialect, + select=[Column(dialect, "grp")], + from_=TableExpression(dialect, "src"), + ) + + +class TestCapabilities: + def test_probes(self, dialect): + assert dialect.supports_materialized_view() is True + assert dialect.supports_refresh_materialized_view() is True + assert dialect.supports_materialized_view_refresh_schedule() is True + assert dialect.supports_materialized_view_populate() is True + + def test_formatter_ownership(self, dialect): + """The CH MV mixin must precede ClickHouseViewMixin and the core ViewMixin.""" + mro = ClickHouseDialect.__mro__ + names = [c.__name__ for c in mro] + assert names.index("ClickHouseMaterializedViewMixin") < names.index( + "ClickHouseViewMixin" + ) + assert names.index("ClickHouseMaterializedViewMixin") < names.index("ViewMixin") + owner = dialect.format_create_materialized_view_statement.__qualname__ + assert owner.startswith("ClickHouseMaterializedViewMixin.") + + +class TestIncrementalMaterializedView: + def test_engine_form(self, dialect): + """Without TO an ENGINE is mandatory — that is ClickHouse's rule.""" + expr = ClickHouseCreateMaterializedViewExpression( + dialect, "mv_agg", QUERY, engine="MergeTree ORDER BY grp" + ) + sql, params = expr.to_sql() + assert sql == ( + "CREATE MATERIALIZED VIEW `mv_agg` " + "ENGINE = MergeTree ORDER BY grp AS " + "SELECT grp, sum(v) AS s FROM src GROUP BY grp" + ) + assert params == () + + def test_to_table_form(self, dialect): + expr = ClickHouseCreateMaterializedViewExpression( + dialect, "mv_to", QUERY, to_table="target_table" + ) + assert "TO `target_table`" in expr.to_sql()[0] + + def test_to_table_with_columns(self, dialect): + expr = ClickHouseCreateMaterializedViewExpression( + dialect, "mv_to", QUERY, to_table="target", to_columns=["grp", "s"] + ) + assert "TO `target` (grp, s)" in expr.to_sql()[0] + + def test_populate(self, dialect): + expr = ClickHouseCreateMaterializedViewExpression( + dialect, "mv", QUERY, to_table="target", populate=True + ) + assert "POPULATE" in expr.to_sql()[0] + + def test_database_qualified(self, dialect): + expr = ClickHouseCreateMaterializedViewExpression( + dialect, "mv_agg", QUERY, database="analytics", engine="MergeTree ORDER BY grp" + ) + assert "`analytics`.`mv_agg`" in expr.to_sql()[0] + + def test_on_cluster_and_comment(self, dialect): + expr = ClickHouseCreateMaterializedViewExpression( + dialect, + "mv", + QUERY, + to_table="target", + on_cluster="cluster_1", + comment="nightly rollup", + ) + sql, _ = expr.to_sql() + # cluster name goes through format_identifier, matching format_drop_database_statement + assert "ON CLUSTER `cluster_1`" in sql + assert "COMMENT 'nightly rollup'" in sql + + def test_or_replace_and_if_not_exists(self, dialect): + expr = ClickHouseCreateMaterializedViewExpression( + dialect, "mv", QUERY, to_table="t", or_replace=True + ) + assert expr.to_sql()[0].startswith("CREATE OR REPLACE MATERIALIZED VIEW") + expr = ClickHouseCreateMaterializedViewExpression( + dialect, "mv", QUERY, to_table="t", if_not_exists=True + ) + assert "IF NOT EXISTS" in expr.to_sql()[0] + + def test_or_replace_and_if_not_exists_are_exclusive(self, dialect): + with pytest.raises(ValueError, match="OR REPLACE"): + ClickHouseCreateMaterializedViewExpression( + dialect, "mv", QUERY, to_table="t", or_replace=True, if_not_exists=True + ) + + def test_engine_or_to_is_mandatory(self, dialect): + """The server rejects a materialized view with neither.""" + with pytest.raises(ValueError, match="TO target table or an"): + ClickHouseCreateMaterializedViewExpression(dialect, "mv", QUERY) + + def test_clause_order(self, dialect): + expr = ClickHouseCreateMaterializedViewExpression( + dialect, + "mv", + QUERY, + on_cluster="c1", + to_table="target", + populate=True, + comment="c", + ) + sql, _ = expr.to_sql() + assert sql.index("ON CLUSTER") < sql.index("TO ") + assert sql.index("TO ") < sql.index("POPULATE") + assert sql.index("POPULATE") < sql.index(" AS ") + assert sql.index(" AS ") < sql.index("COMMENT") + + def test_expression_query_accepted(self, dialect): + expr = ClickHouseCreateMaterializedViewExpression( + dialect, "mv", _generic_query(dialect), to_table="t" + ) + sql, _ = expr.to_sql() + assert "AS SELECT `grp` FROM `src`" in sql + + +class TestRefreshableMaterializedView: + def test_every(self, dialect): + expr = ClickHouseCreateMaterializedViewExpression( + dialect, + "mv", + QUERY, + to_table="t", + refresh=ClickHouseRefreshSchedule(every="1 DAY"), + ) + sql, _ = expr.to_sql() + assert "REFRESH EVERY 1 DAY" in sql + assert expr.is_refreshable is True + + def test_every_with_offset(self, dialect): + expr = ClickHouseCreateMaterializedViewExpression( + dialect, + "mv", + QUERY, + to_table="t", + refresh=ClickHouseRefreshSchedule(every="1 MONTH", offset="5 DAY 2 HOUR"), + ) + assert "REFRESH EVERY 1 MONTH OFFSET 5 DAY 2 HOUR" in expr.to_sql()[0] + + def test_after(self, dialect): + expr = ClickHouseCreateMaterializedViewExpression( + dialect, + "mv", + QUERY, + to_table="t", + refresh=ClickHouseRefreshSchedule(after="30 MINUTE"), + ) + assert "REFRESH AFTER 30 MINUTE" in expr.to_sql()[0] + + def test_randomize_for_and_settings(self, dialect): + expr = ClickHouseCreateMaterializedViewExpression( + dialect, + "mv", + QUERY, + to_table="t", + refresh=ClickHouseRefreshSchedule( + every="1 HOUR", randomize_for="1 HOUR", settings={"refresh_retries": 5} + ), + ) + sql, _ = expr.to_sql() + assert "RANDOMIZE FOR 1 HOUR" in sql + assert "SETTINGS refresh_retries = 5" in sql + + def test_depends_on(self, dialect): + expr = ClickHouseCreateMaterializedViewExpression( + dialect, + "mv", + QUERY, + to_table="t", + refresh=ClickHouseRefreshSchedule(depends_on=["upstream_mv"]), + ) + assert "DEPENDS ON `upstream_mv`" in expr.to_sql()[0] + + def test_append_and_append_incremental(self, dialect): + base = dict(dialect=dialect, view_name="mv", query=QUERY, to_table="t") + append = ClickHouseCreateMaterializedViewExpression( + **base, refresh=ClickHouseRefreshSchedule(every="1 HOUR", append=True) + ) + assert "APPEND" in append.to_sql()[0] + assert "APPEND INCREMENTAL" not in append.to_sql()[0] + incremental = ClickHouseCreateMaterializedViewExpression( + **base, + refresh=ClickHouseRefreshSchedule( + every="1 HOUR", append=True, incremental=True + ), + ) + assert "APPEND INCREMENTAL" in incremental.to_sql()[0] + + def test_empty_skips_first_refresh(self, dialect): + expr = ClickHouseCreateMaterializedViewExpression( + dialect, + "mv", + QUERY, + to_table="t", + refresh=ClickHouseRefreshSchedule(every="1 DAY"), + empty=True, + ) + assert "EMPTY" in expr.to_sql()[0] + + def test_bare_refresh_rejected(self, dialect): + """The server rejects REFRESH without EVERY/AFTER/DEPENDS ON.""" + with pytest.raises(ValueError, match="at least one of EVERY"): + ClickHouseCreateMaterializedViewExpression( + dialect, + "mv", + QUERY, + to_table="t", + refresh=ClickHouseRefreshSchedule(), + ) + + def test_offset_requires_every(self, dialect): + with pytest.raises(ValueError, match="OFFSET is only valid with EVERY"): + ClickHouseRefreshSchedule(after="1 HOUR", offset="1 HOUR").validate() + + def test_incremental_requires_append(self, dialect): + with pytest.raises(ValueError, match="INCREMENTAL requires APPEND"): + ClickHouseRefreshSchedule(every="1 HOUR", incremental=True).validate() + + def test_populate_conflicts_with_refresh(self, dialect): + """POPULATE and REFRESH are mutually exclusive per the docs.""" + with pytest.raises(ValueError, match="POPULATE cannot be combined"): + ClickHouseCreateMaterializedViewExpression( + dialect, + "mv", + QUERY, + to_table="t", + populate=True, + refresh=ClickHouseRefreshSchedule(every="1 HOUR"), + ) + + def test_empty_requires_refresh(self, dialect): + with pytest.raises(ValueError, match="EMPTY is only meaningful"): + ClickHouseCreateMaterializedViewExpression( + dialect, "mv", QUERY, to_table="t", empty=True + ) + + +class TestRefreshAndDrop: + def test_drop_uses_drop_view(self, dialect): + """ClickHouse removes materialized views with DROP VIEW.""" + expr = ClickHouseDropMaterializedViewExpression(dialect, "mv_agg") + assert expr.to_sql()[0] == "DROP VIEW IF EXISTS `mv_agg`" + + def test_drop_without_if_exists(self, dialect): + expr = ClickHouseDropMaterializedViewExpression( + dialect, "mv_agg", if_exists=False + ) + assert expr.to_sql()[0] == "DROP VIEW `mv_agg`" + + def test_drop_database_qualified(self, dialect): + expr = ClickHouseDropMaterializedViewExpression( + dialect, "mv", database="analytics" + ) + assert "`analytics`.`mv`" in expr.to_sql()[0] + + def test_system_refresh_view(self, dialect): + expr = ClickHouseRefreshMaterializedViewExpression(dialect, "mv_ref") + assert expr.to_sql()[0] == "SYSTEM REFRESH VIEW `mv_ref`" + + def test_system_refresh_and_wait(self, dialect): + expr = ClickHouseRefreshMaterializedViewExpression(dialect, "mv_ref", wait=True) + assert expr.to_sql()[0] == ( + "SYSTEM REFRESH VIEW `mv_ref`; SYSTEM WAIT VIEW `mv_ref`" + ) + + def test_modify_refresh_schedule(self, dialect): + expr = ClickHouseModifyMaterializedViewRefreshExpression( + dialect, "mv_ref", ClickHouseRefreshSchedule(every="30 MINUTE") + ) + assert expr.to_sql()[0] == ( + "ALTER TABLE `mv_ref` MODIFY REFRESH EVERY 30 MINUTE" + ) + + def test_modify_refresh_requires_schedule(self, dialect): + with pytest.raises(ValueError, match="at least one of EVERY"): + ClickHouseModifyMaterializedViewRefreshExpression( + dialect, "mv_ref", ClickHouseRefreshSchedule() + ) + + def test_generic_refresh_routes_here(self, dialect): + expr = RefreshMaterializedViewExpression(dialect=dialect, view_name="mv_ref") + assert expr.to_sql()[0] == "SYSTEM REFRESH VIEW `mv_ref`" + + def test_generic_concurrent_is_rejected(self, dialect): + expr = RefreshMaterializedViewExpression( + dialect=dialect, view_name="mv_ref", concurrent=True + ) + with pytest.raises(UnsupportedFeatureError) as exc: + expr.to_sql() + assert "CONCURRENTLY" in str(exc.value) + + def test_generic_with_data_is_rejected(self, dialect): + expr = RefreshMaterializedViewExpression( + dialect=dialect, view_name="mv_ref", with_data=False + ) + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() + + +class TestRejectsNonClickHouseClauses: + def test_generic_column_aliases(self, dialect): + expr = CreateMaterializedViewExpression( + dialect=dialect, view_name="mv", query=QUERY, column_aliases=["a"] + ) + with pytest.raises(UnsupportedFeatureError) as exc: + expr.to_sql() + assert "COLUMN ALIASES" in str(exc.value) + + def test_generic_tablespace(self, dialect): + expr = CreateMaterializedViewExpression( + dialect=dialect, view_name="mv", query=QUERY, tablespace="fast_ssd" + ) + with pytest.raises(UnsupportedFeatureError) as exc: + expr.to_sql() + assert "TABLESPACE" in str(exc.value) + + def test_generic_storage_options(self, dialect): + expr = CreateMaterializedViewExpression( + dialect=dialect, view_name="mv", query=QUERY, storage_options={"fillfactor": 70} + ) + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() + + def test_generic_with_no_data(self, dialect): + """The regression that started all this: no WITH DATA in ClickHouse.""" + expr = CreateMaterializedViewExpression( + dialect=dialect, view_name="mv", query=QUERY, with_data=False + ) + with pytest.raises(UnsupportedFeatureError) as exc: + expr.to_sql() + assert "WITH NO DATA" in str(exc.value) + assert "POPULATE" in str(exc.value) # points at the ClickHouse alternative + + def test_generic_drop_cascade(self, dialect): + expr = DropMaterializedViewExpression( + dialect=dialect, view_name="mv", if_exists=True, cascade=True + ) + with pytest.raises(UnsupportedFeatureError) as exc: + expr.to_sql() + assert "CASCADE" in str(exc.value) + + def test_generic_create_without_engine_or_to_is_rejected(self, dialect): + """The generic expression carries neither, so it cannot satisfy the rule.""" + expr = CreateMaterializedViewExpression(dialect=dialect, view_name="mv", query=QUERY) + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_materialized_view_support.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_materialized_view_support.py deleted file mode 100644 index 00352a2..0000000 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_materialized_view_support.py +++ /dev/null @@ -1,94 +0,0 @@ -# tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_materialized_view_support.py -""" -ClickHouse materialized view support boundary. - -ClickHouse *has* materialized views, but its DDL shares almost nothing with the -SQL-standard statement the generic ``CreateMaterializedViewExpression`` renders: - -* without a ``TO`` clause an ``ENGINE`` is mandatory; -* there is no ``WITH DATA`` / ``WITH NO DATA`` clause (``POPULATE`` / ``EMPTY``); -* deletion uses ``DROP VIEW``, not ``DROP MATERIALIZED VIEW``; -* refreshable views add ``REFRESH EVERY|AFTER``, ``APPEND``, ``DEPENDS ON``; -* semantics are insert-trigger based, not query-result caching. - -So the generic API must report the capability as unsupported and fail fast, -rather than emit SQL the server rejects. These tests lock that boundary; the -raw-SQL usage test lives in ``query/test_query_features.py``. -""" - -import pytest - -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError -from rhosocial.activerecord.backend.expression import Column, QueryExpression, TableExpression -from rhosocial.activerecord.backend.expression.statements.ddl_view import ( - CreateMaterializedViewExpression, - DropMaterializedViewExpression, - RefreshMaterializedViewExpression, -) -from rhosocial.activerecord.backend.impl.clickhouse.dialect import ClickHouseDialect - - -def _source_query(clickhouse_dialect): - return QueryExpression( - dialect=clickhouse_dialect, - select=[Column(clickhouse_dialect, "grp")], - from_=TableExpression(clickhouse_dialect, "src"), - ) - - - -class TestMaterializedViewCapabilityBoundary: - """The capability must not be advertised while the DDL cannot be rendered.""" - - def test_probe_reports_unsupported(self, clickhouse_dialect): - assert clickhouse_dialect.supports_materialized_view() is False - - def test_refresh_probe_reports_unsupported(self, clickhouse_dialect): - assert clickhouse_dialect.supports_refresh_materialized_view() is False - - def test_probe_is_stable(self, clickhouse_dialect): - """Repeated calls must agree — a lying probe is worse than a missing one.""" - results = {clickhouse_dialect.supports_materialized_view() for _ in range(3)} - assert results == {False} - - -class TestGenericMaterializedViewFailsFast: - """Using the generic expressions must raise, not emit invalid SQL.""" - - def test_create_raises(self, clickhouse_dialect): - expression = CreateMaterializedViewExpression( - dialect=clickhouse_dialect, view_name="mv_ch", query=_source_query(clickhouse_dialect) - ) - with pytest.raises(UnsupportedFeatureError) as exc: - expression.to_sql() - assert "CREATE MATERIALIZED VIEW" in str(exc.value) - - def test_refresh_raises(self, clickhouse_dialect): - expression = RefreshMaterializedViewExpression(dialect=clickhouse_dialect, view_name="mv_ch") - with pytest.raises(UnsupportedFeatureError): - expression.to_sql() - - def test_drop_raises(self, clickhouse_dialect): - expression = DropMaterializedViewExpression( - dialect=clickhouse_dialect, view_name="mv_ch", if_exists=True - ) - with pytest.raises(UnsupportedFeatureError): - expression.to_sql() - - def test_no_clickhouse_specific_with_data_clause_is_emitted(self, clickhouse_dialect): - """Guard the exact regression: the generic form leaked ``WITH DATA``.""" - expression = CreateMaterializedViewExpression( - dialect=clickhouse_dialect, view_name="mv_ch", query=_source_query(clickhouse_dialect) - ) - try: - sql, _ = expression.to_sql() - except UnsupportedFeatureError: - return - pytest.fail(f"generic CREATE MATERIALIZED VIEW must not render, got {sql!r}") - - -class TestDialectInstantiation: - """A freshly constructed dialect reports the same boundary.""" - - def test_default_dialect_reports_unsupported(self): - assert ClickHouseDialect().supports_materialized_view() is False diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_ddl_improvements.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_ddl_improvements.py index df159be..6e20531 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_ddl_improvements.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_ddl_improvements.py @@ -54,16 +54,10 @@ def test_drop_view_if_exists_supported(self): dialect = ClickHouseDialect() assert dialect.supports_if_exists_view() is True - def test_materialized_view_not_exposed_through_generic_api(self): - """ClickHouse has materialized views, but not expressible generically. - - The ClickHouse DDL requires ``ENGINE`` (or ``TO``), has no - ``WITH [NO] DATA`` clause and drops views with ``DROP VIEW``, so the - capability must report ``False`` and the generic expressions must fail - fast rather than emit SQL the server rejects. - """ + def test_materialized_view_supported(self): + """ClickHouse materialized views (incremental and refreshable).""" dialect = ClickHouseDialect() - assert dialect.supports_materialized_view() is False + assert dialect.supports_materialized_view() is True class TestClickHouseSchemaCapabilityGating: From b377232384887f7801708bf690461695d9e673a5 Mon Sep 17 00:00:00 2001 From: vistart Date: Sat, 26 Sep 2026 11:21:07 +0800 Subject: [PATCH 20/24] fix(view): drop the unused typing.List import --- .../backend/impl/clickhouse/expression/materialized_view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/materialized_view.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/materialized_view.py index ff4d0a8..919ebca 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/materialized_view.py @@ -41,7 +41,7 @@ """ from dataclasses import dataclass, field from enum import Enum -from typing import Any, Dict, List, Optional, Sequence, TYPE_CHECKING +from typing import Any, Dict, Optional, Sequence, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression From 6d8d67fd4cf2034a553d01d3a59ba054da5891fc Mon Sep 17 00:00:00 2001 From: vistart Date: Sun, 27 Sep 2026 11:04:56 +0800 Subject: [PATCH 21/24] fix(backend): repair unresolvable TYPE_CHECKING imports The relative import depth in show/expressions.py resolved to impl.clickhouse.show.dialect, which does not exist; the dialect module lives one level up at impl.clickhouse.dialect. Type checkers cannot resolve the import. Verified statically: the TYPE_CHECKING import resolves to an existing module and an existing top-level symbol, and no string annotation in the touched file references a name absent from the workspace. --- .../activerecord/backend/impl/clickhouse/show/expressions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/show/expressions.py b/src/rhosocial/activerecord/backend/impl/clickhouse/show/expressions.py index 13c03a4..7b0a9ff 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/show/expressions.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/show/expressions.py @@ -27,7 +27,7 @@ from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLQueryAndParams if TYPE_CHECKING: - from ...dialect import ClickHouseDialect + from ..dialect import ClickHouseDialect class ShowExpression(BaseExpression): From 7e23db00d77f2d003599a58fe8af4e321d04a0f2 Mon Sep 17 00:00:00 2001 From: vistart Date: Sun, 27 Sep 2026 15:17:53 +0800 Subject: [PATCH 22/24] 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 7ae8f16, 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 03c6d8c0b62dbfbd6ca72dccbd6aa3580f2b5cf5 Mon Sep 17 00:00:00 2001 From: vistart Date: Sun, 27 Sep 2026 15:48:08 +0800 Subject: [PATCH 23/24] 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 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ee07547..c162b9e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -70,14 +70,14 @@ jobs: uses: actions/checkout@v6 with: repository: rhosocial/python-activerecord - ref: feature/activerecord-derived-ddl + ref: release/v1.0.0.dev30 path: _activerecord - name: Check out testsuite (feature branch) uses: actions/checkout@v6 with: repository: rhosocial/python-activerecord-testsuite - ref: feature/activerecord-derived-ddl + ref: release/v1.0.0.dev17 path: _testsuite - name: Install activerecord and testsuite (editable) From fe4875f1b7c68f1e2b0ab450bd871c6e483c1ff2 Mon Sep 17 00:00:00 2001 From: vistart Date: Sun, 27 Sep 2026 19:35:39 +0800 Subject: [PATCH 24/24] chore: add changelog fragment for PR #1 --- changelog.d/1.added.md | 1 + changelog.d/1.fixed.md | 1 + changelog.d/1.internal.md | 1 + 3 files changed, 3 insertions(+) create mode 100644 changelog.d/1.added.md create mode 100644 changelog.d/1.fixed.md create mode 100644 changelog.d/1.internal.md diff --git a/changelog.d/1.added.md b/changelog.d/1.added.md new file mode 100644 index 0000000..1ed648a --- /dev/null +++ b/changelog.d/1.added.md @@ -0,0 +1 @@ +Support real ClickHouse materialized views, advertise table and column comment capabilities with the core typed comment clause nodes, add a type domain negative contract, render DDL through direct DDL expressions, and cover `DDLSource` collection, table inheritance and tablespace. diff --git a/changelog.d/1.fixed.md b/changelog.d/1.fixed.md new file mode 100644 index 0000000..d68dfaf --- /dev/null +++ b/changelog.d/1.fixed.md @@ -0,0 +1 @@ +Stop the ClickHouse capability probe from claiming support for DDL it cannot emit, and stop silently dropping `TABLESPACE` and `INHERITS` from table declarations. Per-column collation support is now declared absent, generated IDs are made process-aware, and partition support capabilities are declared. diff --git a/changelog.d/1.internal.md b/changelog.d/1.internal.md new file mode 100644 index 0000000..8859895 --- /dev/null +++ b/changelog.d/1.internal.md @@ -0,0 +1 @@ +Install core and testsuite from release branches in CI instead of the derived-DDL feature branch, repair TYPE_CHECKING imports broken by the expression namespace split, drop an unused `typing.List` import, and stop the pre-commit hook from blocking commits on revisions that predate `scripts/preflight.sh`.