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