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) 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. 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`. 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/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh index e06f0cb..81b3dda 100644 --- a/scripts/install-git-hooks.sh +++ b/scripts/install-git-hooks.sh @@ -4,21 +4,42 @@ # Installs an optional pre-commit hook that runs scripts/preflight.sh so a # broken import/lint is caught before the commit (and before a CI round-trip). # Bypass a single commit with: git commit --no-verify +# +# Re-run this after pulling, and re-run it from the repository root. To remove +# the hook, delete .git/hooks/pre-commit (or the core.hooksPath directory). set -euo pipefail cd "$(dirname "$0")/.." -HOOK=".git/hooks/pre-commit" -if [ ! -d .git/hooks ]; then - echo "install-git-hooks: .git/hooks not found (not a git repo?)" >&2 - exit 2 +# git ignores .git/hooks entirely once core.hooksPath is set, so a hook written +# there would never run while this script still reported success. +HOOKS_PATH="$(git config core.hooksPath || true)" +if [ -n "$HOOKS_PATH" ]; then + echo "install-git-hooks: core.hooksPath is set to '$HOOKS_PATH'." >&2 + echo "install-git-hooks: .git/hooks is ignored by git; writing the hook to" >&2 + echo " '$HOOKS_PATH/pre-commit' instead." >&2 + HOOK="$HOOKS_PATH/pre-commit" + mkdir -p "$HOOKS_PATH" +else + HOOK=".git/hooks/pre-commit" + if [ ! -d .git/hooks ]; then + echo "install-git-hooks: .git/hooks not found (not a git repo?)" >&2 + exit 2 + fi fi cat > "$HOOK" <<'HOOK_BODY' #!/usr/bin/env bash # Installed by scripts/install-git-hooks.sh — runs the local pre-flight. # Skip with: git commit --no-verify -exec "$(git rev-parse --show-toplevel)/scripts/preflight.sh" +# +# The guard below matters on older revisions: this hook is a local, untracked +# artifact, so it survives a checkout of a commit that predates +# scripts/preflight.sh. Without the guard, exec fails with a bare +# "No such file or directory" and blocks the commit with nothing to act on. +PREFLIGHT="$(git rev-parse --show-toplevel)/scripts/preflight.sh" +[ -x "$PREFLIGHT" ] || exit 0 +exec "$PREFLIGHT" HOOK_BODY chmod +x "$HOOK" diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/dialect.py b/src/rhosocial/activerecord/backend/impl/clickhouse/dialect.py index b89dbee..1495b4e 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, ) @@ -134,6 +138,7 @@ ClickHouseJoinMixin, ClickHouseSetOperationMixin, ClickHouseDQLMixin, + ClickHouseMaterializedViewMixin, # MV DDL overrides ClickHouseViewMixin and ViewMixin ClickHouseViewMixin, ClickHouseSchemaMixin, ClickHouseDatabaseMixin, @@ -146,6 +151,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, @@ -177,6 +185,7 @@ class ClickHouseDialect( LateralJoinMixin, ClickHouseJoinMixin, JoinMixin, + ClickHouseMaterializedViewMixin, # MV DDL must precede ClickHouseViewMixin and ViewMixin ClickHouseViewMixin, ViewMixin, ClickHouseSchemaMixin, @@ -205,6 +214,8 @@ class ClickHouseDialect( ClickHouseModifyColumnMixin, ClickHouseJsonDualityViewMixin, ClickHouseTypeSupportMixin, + UserDefinedTypeMixin, + DomainMixin, ClickHouseOptimizerHintMixin, ClickHouseTableStatementMixin, ClickHouseMaintenanceMixin, @@ -273,7 +284,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/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/column.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/column.py index 4dbf702..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 @@ -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): 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..919ebca --- /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, 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/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/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/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/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_table.py index e02bacb..aa24400 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 @@ -38,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 = "" @@ -82,9 +108,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() @@ -154,6 +180,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() @@ -170,9 +201,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/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/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/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/view.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/view.py index 9f54e20..45c8a02 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/view.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/view.py @@ -19,10 +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.""" - return True - def supports_if_exists_view(self) -> bool: """Whether DROP VIEW IF EXISTS is supported.""" return True diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols.py b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols.py index f5e1832..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, @@ -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.""" ... @@ -390,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.""" ... 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): 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/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))", + (), + ) 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/dialect/test_dialect_capabilities.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_dialect_capabilities.py index a64c061..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 @@ -61,9 +61,18 @@ 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_supported(self, dialect): + """ClickHouse materialized views are supported via a dedicated expression. + + 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 True + assert dialect.supports_refresh_materialized_view() is True + def test_qualify_and_ilike_supported(self, dialect): assert dialect.supports_qualify_clause() is True assert dialect.supports_ilike() is True @@ -105,6 +114,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: 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/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/protocol/test_protocol_conformance.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/protocol/test_protocol_conformance.py index e38bff3..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 @@ -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, @@ -95,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, @@ -153,6 +156,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. @@ -186,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 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") 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'" 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..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 @@ -1,18 +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.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 ViewOptions, ViewCheckOption +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ViewCheckOption, + ViewOptions, +) +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: @@ -50,7 +55,7 @@ def test_drop_view_if_exists_supported(self): assert dialect.supports_if_exists_view() is True def test_materialized_view_supported(self): - """ClickHouse supports materialized views.""" + """ClickHouse materialized views (incremental and refreshable).""" dialect = ClickHouseDialect() assert dialect.supports_materialized_view() is True @@ -67,3 +72,45 @@ def test_drop_schema_not_supported(self): """ClickHouse does not support DROP SCHEMA.""" dialect = ClickHouseDialect() assert dialect.supports_drop_schema() is False + + +class TestClickHouseTableDeclarationGating: + def test_table_declaration_defaults_are_absent(self): + 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 = CreateTableExpression( + dialect, + "inherited", + [ColumnDefinition(dialect, "id", IntegerType(dialect))], + inherits=["parent_a", "parent_b"], + ) + assert expression.inherits == ["parent_a", "parent_b"] + with pytest.raises(UnsupportedFeatureError, match="INHERITS"): + expression.to_sql() + + def test_table_tablespace_is_propagated_and_rejected(self): + dialect = ClickHouseDialect(version=(26, 7, 3)) + assert dialect.supports_table_tablespace() is False + expression = CreateTableExpression( + dialect, + "tablespaced", + [ColumnDefinition(dialect, "id", IntegerType(dialect))], + tablespace="ts_data", + ) + assert expression.tablespace == "ts_data" + with pytest.raises(UnsupportedFeatureError, match="TABLESPACE"): + expression.to_sql() 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) 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